feat(api): add reviews module with CRUD endpoints and CQRS
Implement polymorphic reviews system supporting any target type (agent, property, etc.) with DDD/CQRS architecture following existing patterns. Endpoints: - POST /api/reviews — create review (authenticated) - GET /api/reviews?targetType=&targetId= — list reviews by target - GET /api/reviews/stats?targetType=&targetId= — aggregate rating stats - GET /api/reviews/me — list authenticated user's reviews - DELETE /api/reviews/:id — delete own review Business rules: 1-5 rating validation, self-review prevention, one review per user per target. Includes 15 unit tests for all handlers. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -11,6 +11,7 @@ import { McpIntegrationModule } from '@modules/mcp';
|
||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||
import { NotificationsModule } from '@modules/notifications';
|
||||
import { PaymentsModule } from '@modules/payments';
|
||||
import { ReviewsModule } from '@modules/reviews';
|
||||
import { SearchModule } from '@modules/search';
|
||||
import { SharedModule } from '@modules/shared';
|
||||
import { ThrottlerBehindProxyGuard } from '@modules/shared/infrastructure/guards/throttler-behind-proxy.guard';
|
||||
@@ -26,6 +27,7 @@ import { AppController } from './app.controller';
|
||||
SharedModule,
|
||||
AuthModule,
|
||||
ListingsModule,
|
||||
ReviewsModule,
|
||||
SearchModule,
|
||||
NotificationsModule,
|
||||
PaymentsModule,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { EventBus } from '@nestjs/cqrs';
|
||||
import { ReviewEntity } from '../../domain/entities/review.entity';
|
||||
import type { IReviewRepository } from '../../domain/repositories/review.repository';
|
||||
import { Rating } from '../../domain/value-objects/rating.vo';
|
||||
import { CreateReviewCommand } from '../commands/create-review/create-review.command';
|
||||
import { CreateReviewHandler } from '../commands/create-review/create-review.handler';
|
||||
|
||||
describe('CreateReviewHandler', () => {
|
||||
let handler: CreateReviewHandler;
|
||||
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockReviewRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserAndTarget: vi.fn(),
|
||||
save: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByTarget: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new CreateReviewHandler(
|
||||
mockReviewRepo as any,
|
||||
mockEventBus as unknown as EventBus,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a review successfully', async () => {
|
||||
mockReviewRepo.findByUserAndTarget.mockResolvedValue(null);
|
||||
mockReviewRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 5, 'Great!');
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.rating).toBe(5);
|
||||
expect(result.targetType).toBe('agent');
|
||||
expect(result.targetId).toBe('agent-1');
|
||||
expect(result.id).toBeDefined();
|
||||
expect(mockReviewRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ValidationException for invalid rating', async () => {
|
||||
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 0, null);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws ValidationException for rating > 5', async () => {
|
||||
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 6, null);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('prevents self-review for user target type', async () => {
|
||||
const command = new CreateReviewCommand('user-1', 'user', 'user-1', 5, null);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Không thể tự đánh giá bản thân');
|
||||
});
|
||||
|
||||
it('prevents duplicate review', async () => {
|
||||
const existingReview = new ReviewEntity('existing-1', {
|
||||
userId: 'user-1',
|
||||
targetType: 'agent',
|
||||
targetId: 'agent-1',
|
||||
rating: Rating.create(4).unwrap(),
|
||||
comment: null,
|
||||
});
|
||||
mockReviewRepo.findByUserAndTarget.mockResolvedValue(existingReview);
|
||||
|
||||
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 5, null);
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Bạn đã đánh giá mục tiêu này rồi');
|
||||
});
|
||||
|
||||
it('allows review with null comment', async () => {
|
||||
mockReviewRepo.findByUserAndTarget.mockResolvedValue(null);
|
||||
mockReviewRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const command = new CreateReviewCommand('user-1', 'agent', 'agent-1', 3, null);
|
||||
const result = await handler.execute(command);
|
||||
|
||||
expect(result.rating).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { EventBus } from '@nestjs/cqrs';
|
||||
import { ReviewEntity } from '../../domain/entities/review.entity';
|
||||
import type { IReviewRepository } from '../../domain/repositories/review.repository';
|
||||
import { Rating } from '../../domain/value-objects/rating.vo';
|
||||
import { DeleteReviewCommand } from '../commands/delete-review/delete-review.command';
|
||||
import { DeleteReviewHandler } from '../commands/delete-review/delete-review.handler';
|
||||
|
||||
describe('DeleteReviewHandler', () => {
|
||||
let handler: DeleteReviewHandler;
|
||||
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
|
||||
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockReview = new ReviewEntity('review-1', {
|
||||
userId: 'user-1',
|
||||
targetType: 'agent',
|
||||
targetId: 'agent-1',
|
||||
rating: Rating.create(4).unwrap(),
|
||||
comment: 'Good service',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockReviewRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserAndTarget: vi.fn(),
|
||||
save: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByTarget: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new DeleteReviewHandler(
|
||||
mockReviewRepo as any,
|
||||
mockEventBus as unknown as EventBus,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes own review successfully', async () => {
|
||||
mockReviewRepo.findById.mockResolvedValue(mockReview);
|
||||
mockReviewRepo.delete.mockResolvedValue(undefined);
|
||||
|
||||
const command = new DeleteReviewCommand('review-1', 'user-1');
|
||||
await handler.execute(command);
|
||||
|
||||
expect(mockReviewRepo.delete).toHaveBeenCalledWith('review-1');
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when review not found', async () => {
|
||||
mockReviewRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const command = new DeleteReviewCommand('nonexistent', 'user-1');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Review');
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when deleting another user review', async () => {
|
||||
mockReviewRepo.findById.mockResolvedValue(mockReview);
|
||||
|
||||
const command = new DeleteReviewCommand('review-1', 'user-2');
|
||||
|
||||
await expect(handler.execute(command)).rejects.toThrow('Bạn chỉ có thể xóa đánh giá của chính mình');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type IReviewRepository } from '../../domain/repositories/review.repository';
|
||||
import { GetAverageRatingHandler } from '../queries/get-average-rating/get-average-rating.handler';
|
||||
import { GetAverageRatingQuery } from '../queries/get-average-rating/get-average-rating.query';
|
||||
|
||||
describe('GetAverageRatingHandler', () => {
|
||||
let handler: GetAverageRatingHandler;
|
||||
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockStats = {
|
||||
targetType: 'agent',
|
||||
targetId: 'agent-1',
|
||||
averageRating: 4.3,
|
||||
totalReviews: 10,
|
||||
distribution: { 1: 0, 2: 1, 3: 1, 4: 3, 5: 5 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockReviewRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserAndTarget: vi.fn(),
|
||||
save: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByTarget: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetAverageRatingHandler(mockReviewRepo as any);
|
||||
});
|
||||
|
||||
it('returns rating stats for a target', async () => {
|
||||
mockReviewRepo.getStats.mockResolvedValue(mockStats);
|
||||
|
||||
const query = new GetAverageRatingQuery('agent', 'agent-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockStats);
|
||||
expect(result.averageRating).toBe(4.3);
|
||||
expect(result.totalReviews).toBe(10);
|
||||
expect(mockReviewRepo.getStats).toHaveBeenCalledWith('agent', 'agent-1');
|
||||
});
|
||||
|
||||
it('returns zero stats when no reviews exist', async () => {
|
||||
const emptyStats = {
|
||||
targetType: 'property',
|
||||
targetId: 'prop-1',
|
||||
averageRating: 0,
|
||||
totalReviews: 0,
|
||||
distribution: { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 },
|
||||
};
|
||||
mockReviewRepo.getStats.mockResolvedValue(emptyStats);
|
||||
|
||||
const query = new GetAverageRatingQuery('property', 'prop-1');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.averageRating).toBe(0);
|
||||
expect(result.totalReviews).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type IReviewRepository } from '../../domain/repositories/review.repository';
|
||||
import { GetReviewsByTargetHandler } from '../queries/get-reviews-by-target/get-reviews-by-target.handler';
|
||||
import { GetReviewsByTargetQuery } from '../queries/get-reviews-by-target/get-reviews-by-target.query';
|
||||
|
||||
describe('GetReviewsByTargetHandler', () => {
|
||||
let handler: GetReviewsByTargetHandler;
|
||||
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockPaginatedResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'review-1',
|
||||
userId: 'user-1',
|
||||
userName: 'Nguyen Van A',
|
||||
targetType: 'agent',
|
||||
targetId: 'agent-1',
|
||||
rating: 5,
|
||||
comment: 'Excellent!',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockReviewRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserAndTarget: vi.fn(),
|
||||
save: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByTarget: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetReviewsByTargetHandler(mockReviewRepo as any);
|
||||
});
|
||||
|
||||
it('returns paginated reviews for a target', async () => {
|
||||
mockReviewRepo.findByTarget.mockResolvedValue(mockPaginatedResult);
|
||||
|
||||
const query = new GetReviewsByTargetQuery('agent', 'agent-1', 1, 20);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockPaginatedResult);
|
||||
expect(mockReviewRepo.findByTarget).toHaveBeenCalledWith('agent', 'agent-1', 1, 20);
|
||||
});
|
||||
|
||||
it('passes custom pagination params', async () => {
|
||||
mockReviewRepo.findByTarget.mockResolvedValue({ ...mockPaginatedResult, page: 2 });
|
||||
|
||||
const query = new GetReviewsByTargetQuery('property', 'prop-1', 2, 10);
|
||||
await handler.execute(query);
|
||||
|
||||
expect(mockReviewRepo.findByTarget).toHaveBeenCalledWith('property', 'prop-1', 2, 10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { type IReviewRepository } from '../../domain/repositories/review.repository';
|
||||
import { GetReviewsByUserHandler } from '../queries/get-reviews-by-user/get-reviews-by-user.handler';
|
||||
import { GetReviewsByUserQuery } from '../queries/get-reviews-by-user/get-reviews-by-user.query';
|
||||
|
||||
describe('GetReviewsByUserHandler', () => {
|
||||
let handler: GetReviewsByUserHandler;
|
||||
let mockReviewRepo: { [K in keyof IReviewRepository]: ReturnType<typeof vi.fn> };
|
||||
|
||||
const mockPaginatedResult = {
|
||||
data: [
|
||||
{
|
||||
id: 'review-1',
|
||||
userId: 'user-1',
|
||||
userName: 'Nguyen Van A',
|
||||
targetType: 'agent',
|
||||
targetId: 'agent-1',
|
||||
rating: 5,
|
||||
comment: 'Very good',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockReviewRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserAndTarget: vi.fn(),
|
||||
save: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findByTarget: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
};
|
||||
|
||||
handler = new GetReviewsByUserHandler(mockReviewRepo as any);
|
||||
});
|
||||
|
||||
it('returns paginated reviews for a user', async () => {
|
||||
mockReviewRepo.findByUserId.mockResolvedValue(mockPaginatedResult);
|
||||
|
||||
const query = new GetReviewsByUserQuery('user-1', 1, 20);
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result).toEqual(mockPaginatedResult);
|
||||
expect(mockReviewRepo.findByUserId).toHaveBeenCalledWith('user-1', 1, 20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
export class CreateReviewCommand {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly targetType: string,
|
||||
public readonly targetId: string,
|
||||
public readonly rating: number,
|
||||
public readonly comment: string | null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { ConflictException, ValidationException } from '@modules/shared/domain/domain-exception';
|
||||
import { ReviewEntity } from '../../../domain/entities/review.entity';
|
||||
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
|
||||
import { Rating } from '../../../domain/value-objects/rating.vo';
|
||||
import { CreateReviewCommand } from './create-review.command';
|
||||
|
||||
export interface CreateReviewResult {
|
||||
id: string;
|
||||
rating: number;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@CommandHandler(CreateReviewCommand)
|
||||
export class CreateReviewHandler implements ICommandHandler<CreateReviewCommand> {
|
||||
private readonly logger = new Logger(CreateReviewHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateReviewCommand): Promise<CreateReviewResult> {
|
||||
// Validate rating value object
|
||||
const ratingResult = Rating.create(command.rating);
|
||||
if (ratingResult.isErr) {
|
||||
throw new ValidationException(ratingResult.unwrapErr());
|
||||
}
|
||||
const rating = ratingResult.unwrap();
|
||||
|
||||
// Prevent self-review
|
||||
if (command.userId === command.targetId && command.targetType === 'user') {
|
||||
throw new ValidationException('Không thể tự đánh giá bản thân');
|
||||
}
|
||||
|
||||
// One review per user per target
|
||||
const existing = await this.reviewRepo.findByUserAndTarget(
|
||||
command.userId,
|
||||
command.targetType,
|
||||
command.targetId,
|
||||
);
|
||||
if (existing) {
|
||||
throw new ConflictException('Bạn đã đánh giá mục tiêu này rồi');
|
||||
}
|
||||
|
||||
const id = createId();
|
||||
const review = ReviewEntity.createNew(
|
||||
id,
|
||||
command.userId,
|
||||
command.targetType,
|
||||
command.targetId,
|
||||
rating,
|
||||
command.comment,
|
||||
);
|
||||
|
||||
await this.reviewRepo.save(review);
|
||||
|
||||
// Publish domain events
|
||||
const events = review.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
this.logger.log(`Review ${id} created by user ${command.userId} for ${command.targetType}:${command.targetId}`);
|
||||
|
||||
return {
|
||||
id,
|
||||
rating: rating.value,
|
||||
targetType: command.targetType,
|
||||
targetId: command.targetId,
|
||||
createdAt: review.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteReviewCommand {
|
||||
constructor(
|
||||
public readonly reviewId: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenException, NotFoundException } from '@modules/shared/domain/domain-exception';
|
||||
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
|
||||
import { DeleteReviewCommand } from './delete-review.command';
|
||||
|
||||
@CommandHandler(DeleteReviewCommand)
|
||||
export class DeleteReviewHandler implements ICommandHandler<DeleteReviewCommand> {
|
||||
private readonly logger = new Logger(DeleteReviewHandler.name);
|
||||
|
||||
constructor(
|
||||
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteReviewCommand): Promise<void> {
|
||||
const review = await this.reviewRepo.findById(command.reviewId);
|
||||
if (!review) {
|
||||
throw new NotFoundException('Review', command.reviewId);
|
||||
}
|
||||
|
||||
if (review.userId !== command.userId) {
|
||||
throw new ForbiddenException('Bạn chỉ có thể xóa đánh giá của chính mình');
|
||||
}
|
||||
|
||||
review.markDeleted();
|
||||
await this.reviewRepo.delete(command.reviewId);
|
||||
|
||||
// Publish domain events
|
||||
const events = review.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
this.logger.log(`Review ${command.reviewId} deleted by user ${command.userId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type ReviewStatsData } from '../../../domain/repositories/review-read.dto';
|
||||
import { REVIEW_REPOSITORY, type IReviewRepository } from '../../../domain/repositories/review.repository';
|
||||
import { GetAverageRatingQuery } from './get-average-rating.query';
|
||||
|
||||
@QueryHandler(GetAverageRatingQuery)
|
||||
export class GetAverageRatingHandler implements IQueryHandler<GetAverageRatingQuery> {
|
||||
constructor(
|
||||
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAverageRatingQuery): Promise<ReviewStatsData> {
|
||||
return this.reviewRepo.getStats(query.targetType, query.targetId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetAverageRatingQuery {
|
||||
constructor(
|
||||
public readonly targetType: string,
|
||||
public readonly targetId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type ReviewItemData } from '../../../domain/repositories/review-read.dto';
|
||||
import { REVIEW_REPOSITORY, type IReviewRepository, type PaginatedResult } from '../../../domain/repositories/review.repository';
|
||||
import { GetReviewsByTargetQuery } from './get-reviews-by-target.query';
|
||||
|
||||
@QueryHandler(GetReviewsByTargetQuery)
|
||||
export class GetReviewsByTargetHandler implements IQueryHandler<GetReviewsByTargetQuery> {
|
||||
constructor(
|
||||
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetReviewsByTargetQuery): Promise<PaginatedResult<ReviewItemData>> {
|
||||
return this.reviewRepo.findByTarget(
|
||||
query.targetType,
|
||||
query.targetId,
|
||||
query.page,
|
||||
query.limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class GetReviewsByTargetQuery {
|
||||
constructor(
|
||||
public readonly targetType: string,
|
||||
public readonly targetId: string,
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { type ReviewItemData } from '../../../domain/repositories/review-read.dto';
|
||||
import { REVIEW_REPOSITORY, type IReviewRepository, type PaginatedResult } from '../../../domain/repositories/review.repository';
|
||||
import { GetReviewsByUserQuery } from './get-reviews-by-user.query';
|
||||
|
||||
@QueryHandler(GetReviewsByUserQuery)
|
||||
export class GetReviewsByUserHandler implements IQueryHandler<GetReviewsByUserQuery> {
|
||||
constructor(
|
||||
@Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetReviewsByUserQuery): Promise<PaginatedResult<ReviewItemData>> {
|
||||
return this.reviewRepo.findByUserId(query.userId, query.page, query.limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class GetReviewsByUserQuery {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly page: number = 1,
|
||||
public readonly limit: number = 20,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AggregateRoot } from '@modules/shared/domain/aggregate-root';
|
||||
import { ReviewCreatedEvent } from '../events/review-created.event';
|
||||
import { ReviewDeletedEvent } from '../events/review-deleted.event';
|
||||
import { type Rating } from '../value-objects/rating.vo';
|
||||
|
||||
export interface ReviewProps {
|
||||
userId: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
rating: Rating;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
export class ReviewEntity extends AggregateRoot<string> {
|
||||
private _userId: string;
|
||||
private _targetType: string;
|
||||
private _targetId: string;
|
||||
private _rating: Rating;
|
||||
private _comment: string | null;
|
||||
|
||||
constructor(id: string, props: ReviewProps, createdAt?: Date) {
|
||||
super(id, createdAt);
|
||||
this._userId = props.userId;
|
||||
this._targetType = props.targetType;
|
||||
this._targetId = props.targetId;
|
||||
this._rating = props.rating;
|
||||
this._comment = props.comment;
|
||||
}
|
||||
|
||||
get userId(): string { return this._userId; }
|
||||
get targetType(): string { return this._targetType; }
|
||||
get targetId(): string { return this._targetId; }
|
||||
get rating(): Rating { return this._rating; }
|
||||
get comment(): string | null { return this._comment; }
|
||||
|
||||
static createNew(
|
||||
id: string,
|
||||
userId: string,
|
||||
targetType: string,
|
||||
targetId: string,
|
||||
rating: Rating,
|
||||
comment: string | null,
|
||||
): ReviewEntity {
|
||||
const review = new ReviewEntity(id, {
|
||||
userId,
|
||||
targetType,
|
||||
targetId,
|
||||
rating,
|
||||
comment,
|
||||
});
|
||||
|
||||
review.addDomainEvent(
|
||||
new ReviewCreatedEvent(id, userId, targetType, targetId, rating.value),
|
||||
);
|
||||
return review;
|
||||
}
|
||||
|
||||
markDeleted(): void {
|
||||
this.addDomainEvent(
|
||||
new ReviewDeletedEvent(this.id, this._userId, this._targetType, this._targetId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
|
||||
export class ReviewCreatedEvent implements DomainEvent {
|
||||
readonly eventName = 'review.created';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly userId: string,
|
||||
public readonly targetType: string,
|
||||
public readonly targetId: string,
|
||||
public readonly rating: number,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type DomainEvent } from '@modules/shared/domain/domain-event';
|
||||
|
||||
export class ReviewDeletedEvent implements DomainEvent {
|
||||
readonly eventName = 'review.deleted';
|
||||
readonly occurredAt = new Date();
|
||||
|
||||
constructor(
|
||||
public readonly aggregateId: string,
|
||||
public readonly userId: string,
|
||||
public readonly targetType: string,
|
||||
public readonly targetId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface ReviewItemData {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
rating: number;
|
||||
comment: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ReviewStatsData {
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
averageRating: number;
|
||||
totalReviews: number;
|
||||
distribution: Record<number, number>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { type ReviewEntity } from '../entities/review.entity';
|
||||
import { type ReviewItemData, type ReviewStatsData } from './review-read.dto';
|
||||
|
||||
export const REVIEW_REPOSITORY = Symbol('REVIEW_REPOSITORY');
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface IReviewRepository {
|
||||
findById(id: string): Promise<ReviewEntity | null>;
|
||||
findByUserAndTarget(userId: string, targetType: string, targetId: string): Promise<ReviewEntity | null>;
|
||||
save(review: ReviewEntity): Promise<void>;
|
||||
delete(id: string): Promise<void>;
|
||||
findByTarget(targetType: string, targetId: string, page: number, limit: number): Promise<PaginatedResult<ReviewItemData>>;
|
||||
findByUserId(userId: string, page: number, limit: number): Promise<PaginatedResult<ReviewItemData>>;
|
||||
getStats(targetType: string, targetId: string): Promise<ReviewStatsData>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Result } from '@modules/shared/domain/result';
|
||||
import { ValueObject } from '@modules/shared/domain/value-object';
|
||||
|
||||
interface RatingProps {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export class Rating extends ValueObject<RatingProps> {
|
||||
get value(): number { return this.props.value; }
|
||||
|
||||
static create(value: number): Result<Rating, string> {
|
||||
if (!Number.isInteger(value) || value < 1 || value > 5) {
|
||||
return Result.err('Đánh giá phải từ 1 đến 5 sao');
|
||||
}
|
||||
return Result.ok(new Rating({ value }));
|
||||
}
|
||||
}
|
||||
2
apps/api/src/modules/reviews/index.ts
Normal file
2
apps/api/src/modules/reviews/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ReviewsModule } from './reviews.module';
|
||||
export { REVIEW_REPOSITORY, type IReviewRepository } from './domain/repositories/review.repository';
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Review as PrismaReview } from '@prisma/client';
|
||||
import type { PrismaService } from '@modules/shared/infrastructure/prisma.service';
|
||||
import { ReviewEntity } from '../../domain/entities/review.entity';
|
||||
import type { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import type { IReviewRepository, PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
import { Rating } from '../../domain/value-objects/rating.vo';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaReviewRepository implements IReviewRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: string): Promise<ReviewEntity | null> {
|
||||
const review = await this.prisma.review.findUnique({ where: { id } });
|
||||
return review ? this.toDomain(review) : null;
|
||||
}
|
||||
|
||||
async findByUserAndTarget(
|
||||
userId: string,
|
||||
targetType: string,
|
||||
targetId: string,
|
||||
): Promise<ReviewEntity | null> {
|
||||
const review = await this.prisma.review.findFirst({
|
||||
where: { userId, targetType, targetId },
|
||||
});
|
||||
return review ? this.toDomain(review) : null;
|
||||
}
|
||||
|
||||
async save(entity: ReviewEntity): Promise<void> {
|
||||
await this.prisma.review.create({
|
||||
data: {
|
||||
id: entity.id,
|
||||
userId: entity.userId,
|
||||
targetType: entity.targetType,
|
||||
targetId: entity.targetId,
|
||||
rating: entity.rating.value,
|
||||
comment: entity.comment,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.review.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async findByTarget(
|
||||
targetType: string,
|
||||
targetId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Promise<PaginatedResult<ReviewItemData>> {
|
||||
const take = Math.min(limit, 100);
|
||||
const skip = (page - 1) * take;
|
||||
const where = { targetType, targetId };
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.review.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: { select: { id: true, fullName: true } } },
|
||||
}),
|
||||
this.prisma.review.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: data.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
userName: r.user.fullName,
|
||||
targetType: r.targetType,
|
||||
targetId: r.targetId,
|
||||
rating: r.rating,
|
||||
comment: r.comment,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
totalPages: Math.ceil(total / take),
|
||||
};
|
||||
}
|
||||
|
||||
async findByUserId(
|
||||
userId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
): Promise<PaginatedResult<ReviewItemData>> {
|
||||
const take = Math.min(limit, 100);
|
||||
const skip = (page - 1) * take;
|
||||
const where = { userId };
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.review.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: { select: { id: true, fullName: true } } },
|
||||
}),
|
||||
this.prisma.review.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: data.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
userName: r.user.fullName,
|
||||
targetType: r.targetType,
|
||||
targetId: r.targetId,
|
||||
rating: r.rating,
|
||||
comment: r.comment,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
limit: take,
|
||||
totalPages: Math.ceil(total / take),
|
||||
};
|
||||
}
|
||||
|
||||
async getStats(targetType: string, targetId: string): Promise<ReviewStatsData> {
|
||||
const reviews = await this.prisma.review.findMany({
|
||||
where: { targetType, targetId },
|
||||
select: { rating: true },
|
||||
});
|
||||
|
||||
const totalReviews = reviews.length;
|
||||
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
let sum = 0;
|
||||
|
||||
for (const r of reviews) {
|
||||
sum += r.rating;
|
||||
distribution[r.rating] = (distribution[r.rating] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
targetType,
|
||||
targetId,
|
||||
averageRating: totalReviews > 0 ? Math.round((sum / totalReviews) * 10) / 10 : 0,
|
||||
totalReviews,
|
||||
distribution,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(raw: PrismaReview): ReviewEntity {
|
||||
const rating = Rating.create(raw.rating).unwrap();
|
||||
return new ReviewEntity(
|
||||
raw.id,
|
||||
{
|
||||
userId: raw.userId,
|
||||
targetType: raw.targetType,
|
||||
targetId: raw.targetId,
|
||||
rating,
|
||||
comment: raw.comment,
|
||||
},
|
||||
raw.createdAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import type { JwtPayload } from '@modules/auth/infrastructure/services/token.service';
|
||||
import { CurrentUser } from '@modules/auth/presentation/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '@modules/auth/presentation/guards/jwt-auth.guard';
|
||||
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
|
||||
import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
|
||||
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
|
||||
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
|
||||
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
|
||||
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
|
||||
import type { ReviewItemData, ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
||||
import type { PaginatedResult } from '../../domain/repositories/review.repository';
|
||||
import type { CreateReviewDto } from '../dto/create-review.dto';
|
||||
import type { ListReviewsByTargetDto, ReviewStatsDto } from '../dto/list-reviews.dto';
|
||||
|
||||
@ApiTags('reviews')
|
||||
@Controller('reviews')
|
||||
export class ReviewsController {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Create a review' })
|
||||
@ApiResponse({ status: 201, description: 'Review created successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 409, description: 'Already reviewed this target' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post()
|
||||
async createReview(
|
||||
@Body() dto: CreateReviewDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<CreateReviewResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateReviewCommand(
|
||||
user.sub,
|
||||
dto.targetType,
|
||||
dto.targetId,
|
||||
dto.rating,
|
||||
dto.comment ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'List reviews by target' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated list of reviews' })
|
||||
@Get()
|
||||
async getReviewsByTarget(
|
||||
@Query() dto: ListReviewsByTargetDto,
|
||||
): Promise<PaginatedResult<ReviewItemData>> {
|
||||
return this.queryBus.execute(
|
||||
new GetReviewsByTargetQuery(
|
||||
dto.targetType,
|
||||
dto.targetId,
|
||||
dto.page ?? 1,
|
||||
dto.limit ?? 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: 'Get aggregate rating stats for a target' })
|
||||
@ApiResponse({ status: 200, description: 'Rating statistics' })
|
||||
@Get('stats')
|
||||
async getStats(
|
||||
@Query() dto: ReviewStatsDto,
|
||||
): Promise<ReviewStatsData> {
|
||||
return this.queryBus.execute(
|
||||
new GetAverageRatingQuery(dto.targetType, dto.targetId),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Get reviews by authenticated user' })
|
||||
@ApiResponse({ status: 200, description: 'Paginated list of user reviews' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('me')
|
||||
async getMyReviews(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('page') page?: number,
|
||||
@Query('limit') limit?: number,
|
||||
): Promise<PaginatedResult<ReviewItemData>> {
|
||||
return this.queryBus.execute(
|
||||
new GetReviewsByUserQuery(user.sub, page ?? 1, limit ?? 20),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT')
|
||||
@ApiOperation({ summary: 'Delete own review' })
|
||||
@ApiParam({ name: 'id', description: 'Review ID' })
|
||||
@ApiResponse({ status: 200, description: 'Review deleted' })
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 403, description: 'Cannot delete another user\'s review' })
|
||||
@ApiResponse({ status: 404, description: 'Review not found' })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
async deleteReview(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
await this.commandBus.execute(new DeleteReviewCommand(id, user.sub));
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateReviewDto {
|
||||
@ApiProperty({ example: 'agent', description: 'Target entity type (e.g. agent, property)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetType!: string;
|
||||
|
||||
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetId!: string;
|
||||
|
||||
@ApiProperty({ example: 5, description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Dịch vụ rất tốt!', description: 'Optional review comment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
comment?: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
export class ListReviewsByTargetDto {
|
||||
@ApiProperty({ example: 'agent', description: 'Target entity type' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetType!: string;
|
||||
|
||||
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetId!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class ReviewStatsDto {
|
||||
@ApiProperty({ example: 'agent', description: 'Target entity type' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetType!: string;
|
||||
|
||||
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
targetId!: string;
|
||||
}
|
||||
30
apps/api/src/modules/reviews/reviews.module.ts
Normal file
30
apps/api/src/modules/reviews/reviews.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { CreateReviewHandler } from './application/commands/create-review/create-review.handler';
|
||||
import { DeleteReviewHandler } from './application/commands/delete-review/delete-review.handler';
|
||||
import { GetAverageRatingHandler } from './application/queries/get-average-rating/get-average-rating.handler';
|
||||
import { GetReviewsByTargetHandler } from './application/queries/get-reviews-by-target/get-reviews-by-target.handler';
|
||||
import { GetReviewsByUserHandler } from './application/queries/get-reviews-by-user/get-reviews-by-user.handler';
|
||||
import { REVIEW_REPOSITORY } from './domain/repositories/review.repository';
|
||||
import { PrismaReviewRepository } from './infrastructure/repositories/prisma-review.repository';
|
||||
import { ReviewsController } from './presentation/controllers/reviews.controller';
|
||||
|
||||
const CommandHandlers = [CreateReviewHandler, DeleteReviewHandler];
|
||||
|
||||
const QueryHandlers = [
|
||||
GetReviewsByTargetHandler,
|
||||
GetAverageRatingHandler,
|
||||
GetReviewsByUserHandler,
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [CqrsModule],
|
||||
controllers: [ReviewsController],
|
||||
providers: [
|
||||
{ provide: REVIEW_REPOSITORY, useClass: PrismaReviewRepository },
|
||||
...CommandHandlers,
|
||||
...QueryHandlers,
|
||||
],
|
||||
exports: [REVIEW_REPOSITORY],
|
||||
})
|
||||
export class ReviewsModule {}
|
||||
Reference in New Issue
Block a user