diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 782e209..2ff15fc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/modules/reviews/application/__tests__/create-review.handler.spec.ts b/apps/api/src/modules/reviews/application/__tests__/create-review.handler.spec.ts new file mode 100644 index 0000000..410d0a9 --- /dev/null +++ b/apps/api/src/modules/reviews/application/__tests__/create-review.handler.spec.ts @@ -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 }; + let mockEventBus: { publish: ReturnType }; + + 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); + }); +}); diff --git a/apps/api/src/modules/reviews/application/__tests__/delete-review.handler.spec.ts b/apps/api/src/modules/reviews/application/__tests__/delete-review.handler.spec.ts new file mode 100644 index 0000000..e4f2c96 --- /dev/null +++ b/apps/api/src/modules/reviews/application/__tests__/delete-review.handler.spec.ts @@ -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 }; + let mockEventBus: { publish: ReturnType }; + + 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'); + }); +}); diff --git a/apps/api/src/modules/reviews/application/__tests__/get-average-rating.handler.spec.ts b/apps/api/src/modules/reviews/application/__tests__/get-average-rating.handler.spec.ts new file mode 100644 index 0000000..543db3d --- /dev/null +++ b/apps/api/src/modules/reviews/application/__tests__/get-average-rating.handler.spec.ts @@ -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 }; + + 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); + }); +}); diff --git a/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-target.handler.spec.ts b/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-target.handler.spec.ts new file mode 100644 index 0000000..286ac08 --- /dev/null +++ b/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-target.handler.spec.ts @@ -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 }; + + 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); + }); +}); diff --git a/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-user.handler.spec.ts b/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-user.handler.spec.ts new file mode 100644 index 0000000..6e3de66 --- /dev/null +++ b/apps/api/src/modules/reviews/application/__tests__/get-reviews-by-user.handler.spec.ts @@ -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 }; + + 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); + }); +}); diff --git a/apps/api/src/modules/reviews/application/commands/create-review/create-review.command.ts b/apps/api/src/modules/reviews/application/commands/create-review/create-review.command.ts new file mode 100644 index 0000000..0a861f5 --- /dev/null +++ b/apps/api/src/modules/reviews/application/commands/create-review/create-review.command.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts b/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts new file mode 100644 index 0000000..bbf7be0 --- /dev/null +++ b/apps/api/src/modules/reviews/application/commands/create-review/create-review.handler.ts @@ -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 { + private readonly logger = new Logger(CreateReviewHandler.name); + + constructor( + @Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: CreateReviewCommand): Promise { + // 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(), + }; + } +} diff --git a/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.command.ts b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.command.ts new file mode 100644 index 0000000..9d89686 --- /dev/null +++ b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.command.ts @@ -0,0 +1,6 @@ +export class DeleteReviewCommand { + constructor( + public readonly reviewId: string, + public readonly userId: string, + ) {} +} diff --git a/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts new file mode 100644 index 0000000..9aa6ce0 --- /dev/null +++ b/apps/api/src/modules/reviews/application/commands/delete-review/delete-review.handler.ts @@ -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 { + private readonly logger = new Logger(DeleteReviewHandler.name); + + constructor( + @Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository, + private readonly eventBus: EventBus, + ) {} + + async execute(command: DeleteReviewCommand): Promise { + 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}`); + } +} diff --git a/apps/api/src/modules/reviews/application/queries/get-average-rating/get-average-rating.handler.ts b/apps/api/src/modules/reviews/application/queries/get-average-rating/get-average-rating.handler.ts new file mode 100644 index 0000000..b9e0f6c --- /dev/null +++ b/apps/api/src/modules/reviews/application/queries/get-average-rating/get-average-rating.handler.ts @@ -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 { + constructor( + @Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository, + ) {} + + async execute(query: GetAverageRatingQuery): Promise { + return this.reviewRepo.getStats(query.targetType, query.targetId); + } +} diff --git a/apps/api/src/modules/reviews/application/queries/get-average-rating/get-average-rating.query.ts b/apps/api/src/modules/reviews/application/queries/get-average-rating/get-average-rating.query.ts new file mode 100644 index 0000000..7349e2d --- /dev/null +++ b/apps/api/src/modules/reviews/application/queries/get-average-rating/get-average-rating.query.ts @@ -0,0 +1,6 @@ +export class GetAverageRatingQuery { + constructor( + public readonly targetType: string, + public readonly targetId: string, + ) {} +} diff --git a/apps/api/src/modules/reviews/application/queries/get-reviews-by-target/get-reviews-by-target.handler.ts b/apps/api/src/modules/reviews/application/queries/get-reviews-by-target/get-reviews-by-target.handler.ts new file mode 100644 index 0000000..42dbeab --- /dev/null +++ b/apps/api/src/modules/reviews/application/queries/get-reviews-by-target/get-reviews-by-target.handler.ts @@ -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 { + constructor( + @Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository, + ) {} + + async execute(query: GetReviewsByTargetQuery): Promise> { + return this.reviewRepo.findByTarget( + query.targetType, + query.targetId, + query.page, + query.limit, + ); + } +} diff --git a/apps/api/src/modules/reviews/application/queries/get-reviews-by-target/get-reviews-by-target.query.ts b/apps/api/src/modules/reviews/application/queries/get-reviews-by-target/get-reviews-by-target.query.ts new file mode 100644 index 0000000..b8b32b5 --- /dev/null +++ b/apps/api/src/modules/reviews/application/queries/get-reviews-by-target/get-reviews-by-target.query.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/reviews/application/queries/get-reviews-by-user/get-reviews-by-user.handler.ts b/apps/api/src/modules/reviews/application/queries/get-reviews-by-user/get-reviews-by-user.handler.ts new file mode 100644 index 0000000..4ff0eed --- /dev/null +++ b/apps/api/src/modules/reviews/application/queries/get-reviews-by-user/get-reviews-by-user.handler.ts @@ -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 { + constructor( + @Inject(REVIEW_REPOSITORY) private readonly reviewRepo: IReviewRepository, + ) {} + + async execute(query: GetReviewsByUserQuery): Promise> { + return this.reviewRepo.findByUserId(query.userId, query.page, query.limit); + } +} diff --git a/apps/api/src/modules/reviews/application/queries/get-reviews-by-user/get-reviews-by-user.query.ts b/apps/api/src/modules/reviews/application/queries/get-reviews-by-user/get-reviews-by-user.query.ts new file mode 100644 index 0000000..375db55 --- /dev/null +++ b/apps/api/src/modules/reviews/application/queries/get-reviews-by-user/get-reviews-by-user.query.ts @@ -0,0 +1,7 @@ +export class GetReviewsByUserQuery { + constructor( + public readonly userId: string, + public readonly page: number = 1, + public readonly limit: number = 20, + ) {} +} diff --git a/apps/api/src/modules/reviews/domain/entities/review.entity.ts b/apps/api/src/modules/reviews/domain/entities/review.entity.ts new file mode 100644 index 0000000..4f7addf --- /dev/null +++ b/apps/api/src/modules/reviews/domain/entities/review.entity.ts @@ -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 { + 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), + ); + } +} diff --git a/apps/api/src/modules/reviews/domain/events/review-created.event.ts b/apps/api/src/modules/reviews/domain/events/review-created.event.ts new file mode 100644 index 0000000..0081f71 --- /dev/null +++ b/apps/api/src/modules/reviews/domain/events/review-created.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/reviews/domain/events/review-deleted.event.ts b/apps/api/src/modules/reviews/domain/events/review-deleted.event.ts new file mode 100644 index 0000000..b258380 --- /dev/null +++ b/apps/api/src/modules/reviews/domain/events/review-deleted.event.ts @@ -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, + ) {} +} diff --git a/apps/api/src/modules/reviews/domain/repositories/review-read.dto.ts b/apps/api/src/modules/reviews/domain/repositories/review-read.dto.ts new file mode 100644 index 0000000..726be7e --- /dev/null +++ b/apps/api/src/modules/reviews/domain/repositories/review-read.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/reviews/domain/repositories/review.repository.ts b/apps/api/src/modules/reviews/domain/repositories/review.repository.ts new file mode 100644 index 0000000..9fe76b8 --- /dev/null +++ b/apps/api/src/modules/reviews/domain/repositories/review.repository.ts @@ -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 { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface IReviewRepository { + findById(id: string): Promise; + findByUserAndTarget(userId: string, targetType: string, targetId: string): Promise; + save(review: ReviewEntity): Promise; + delete(id: string): Promise; + findByTarget(targetType: string, targetId: string, page: number, limit: number): Promise>; + findByUserId(userId: string, page: number, limit: number): Promise>; + getStats(targetType: string, targetId: string): Promise; +} diff --git a/apps/api/src/modules/reviews/domain/value-objects/rating.vo.ts b/apps/api/src/modules/reviews/domain/value-objects/rating.vo.ts new file mode 100644 index 0000000..bd107c9 --- /dev/null +++ b/apps/api/src/modules/reviews/domain/value-objects/rating.vo.ts @@ -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 { + get value(): number { return this.props.value; } + + static create(value: number): Result { + 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 })); + } +} diff --git a/apps/api/src/modules/reviews/index.ts b/apps/api/src/modules/reviews/index.ts new file mode 100644 index 0000000..702f676 --- /dev/null +++ b/apps/api/src/modules/reviews/index.ts @@ -0,0 +1,2 @@ +export { ReviewsModule } from './reviews.module'; +export { REVIEW_REPOSITORY, type IReviewRepository } from './domain/repositories/review.repository'; diff --git a/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts b/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts new file mode 100644 index 0000000..4599265 --- /dev/null +++ b/apps/api/src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts @@ -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 { + const review = await this.prisma.review.findUnique({ where: { id } }); + return review ? this.toDomain(review) : null; + } + + async findByUserAndTarget( + userId: string, + targetType: string, + targetId: string, + ): Promise { + const review = await this.prisma.review.findFirst({ + where: { userId, targetType, targetId }, + }); + return review ? this.toDomain(review) : null; + } + + async save(entity: ReviewEntity): Promise { + 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 { + await this.prisma.review.delete({ where: { id } }); + } + + async findByTarget( + targetType: string, + targetId: string, + page: number, + limit: number, + ): Promise> { + 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> { + 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 { + const reviews = await this.prisma.review.findMany({ + where: { targetType, targetId }, + select: { rating: true }, + }); + + const totalReviews = reviews.length; + const distribution: Record = { 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, + ); + } +} diff --git a/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts b/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts new file mode 100644 index 0000000..f962971 --- /dev/null +++ b/apps/api/src/modules/reviews/presentation/controllers/reviews.controller.ts @@ -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 { + 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> { + 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 { + 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> { + 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 }; + } +} diff --git a/apps/api/src/modules/reviews/presentation/dto/create-review.dto.ts b/apps/api/src/modules/reviews/presentation/dto/create-review.dto.ts new file mode 100644 index 0000000..807f9f3 --- /dev/null +++ b/apps/api/src/modules/reviews/presentation/dto/create-review.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/reviews/presentation/dto/list-reviews.dto.ts b/apps/api/src/modules/reviews/presentation/dto/list-reviews.dto.ts new file mode 100644 index 0000000..4d762b7 --- /dev/null +++ b/apps/api/src/modules/reviews/presentation/dto/list-reviews.dto.ts @@ -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; +} diff --git a/apps/api/src/modules/reviews/reviews.module.ts b/apps/api/src/modules/reviews/reviews.module.ts new file mode 100644 index 0000000..d9d3f03 --- /dev/null +++ b/apps/api/src/modules/reviews/reviews.module.ts @@ -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 {}