From 9013ffddfaa88fc7a78ce5c971ae402c67c4a3a0 Mon Sep 17 00:00:00 2001 From: mijinummi Date: Sat, 31 Jan 2026 09:49:47 +0100 Subject: [PATCH] feat: add Review entity with session-based constraints --- apps/api/src/reviews/dto/create-review.dto.ts | 15 +++ .../api/src/reviews/entities/review.entity.ts | 41 +++++++ apps/api/src/reviews/reviews.controller.ts | 29 +++++ apps/api/src/reviews/reviews.module.ts | 0 apps/api/src/reviews/reviews.service.spec.ts | 0 apps/api/src/reviews/reviews.service.ts | 102 ++++++++++++++++++ 6 files changed, 187 insertions(+) create mode 100644 apps/api/src/reviews/dto/create-review.dto.ts create mode 100644 apps/api/src/reviews/entities/review.entity.ts create mode 100644 apps/api/src/reviews/reviews.controller.ts create mode 100644 apps/api/src/reviews/reviews.module.ts create mode 100644 apps/api/src/reviews/reviews.service.spec.ts create mode 100644 apps/api/src/reviews/reviews.service.ts diff --git a/apps/api/src/reviews/dto/create-review.dto.ts b/apps/api/src/reviews/dto/create-review.dto.ts new file mode 100644 index 0000000..1f5e0da --- /dev/null +++ b/apps/api/src/reviews/dto/create-review.dto.ts @@ -0,0 +1,15 @@ +import { IsInt, IsOptional, IsString, Max, Min, IsUUID } from 'class-validator'; + +export class CreateReviewDto { + @IsUUID() + sessionId: string; + + @IsInt() + @Min(1) + @Max(5) + rating: number; + + @IsOptional() + @IsString() + comment?: string; +} diff --git a/apps/api/src/reviews/entities/review.entity.ts b/apps/api/src/reviews/entities/review.entity.ts new file mode 100644 index 0000000..efe35ca --- /dev/null +++ b/apps/api/src/reviews/entities/review.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + Index, + Unique, +} from 'typeorm'; +import { User } from '@/users/entities/user.entity'; +import { Session } from '@/sessions/entities/session.entity'; + + + +@Entity('reviews') +@Unique(['session', 'reviewer']) +@Index(['session', 'reviewer']) +export class Review { + @PrimaryGeneratedColumn('uuid') + id: string; + + /** Mentee */ + @ManyToOne(() => User, { eager: false }) + reviewer: User; + + /** Mentor */ + @ManyToOne(() => User, { eager: false }) + reviewee: User; + + @ManyToOne(() => Session, { eager: false }) + session: Session; + + @Column({ type: 'int' }) + rating: number; // 1–5 + + @Column({ type: 'text', nullable: true }) + comment?: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/apps/api/src/reviews/reviews.controller.ts b/apps/api/src/reviews/reviews.controller.ts new file mode 100644 index 0000000..7dee6f9 --- /dev/null +++ b/apps/api/src/reviews/reviews.controller.ts @@ -0,0 +1,29 @@ +import { + Body, + Controller, + Post, + UseGuards, +} from '@nestjs/common'; + +import { ReviewsService } from './reviews.service'; +import { CreateReviewDto } from './dto/create-review.dto'; + +// import { AuthGuard } from '../auth/guards/auth.guard'; +import { CurrentUser } from '../decorators/auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@UseGuards(JwtAuthGuard) +@Controller('reviews') +export class ReviewsController { + constructor( + private readonly reviewsService: ReviewsService, + ) {} + + @Post() + async create( + @CurrentUser('id') userId: string, + @Body() dto: CreateReviewDto, + ) { + return this.reviewsService.createReview(userId, dto); + } +} diff --git a/apps/api/src/reviews/reviews.module.ts b/apps/api/src/reviews/reviews.module.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/reviews/reviews.service.spec.ts b/apps/api/src/reviews/reviews.service.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/reviews/reviews.service.ts b/apps/api/src/reviews/reviews.service.ts new file mode 100644 index 0000000..bdf46ce --- /dev/null +++ b/apps/api/src/reviews/reviews.service.ts @@ -0,0 +1,102 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Review } from './entities/review.entity'; +import { Session } from '../sessions/entities/session.entity'; +import { User } from '../users/entities/user.entity'; + +import { CreateReviewDto } from './dto/create-review.dto'; + +@Injectable() +export class ReviewsService { + constructor( + @InjectRepository(Review) + private readonly reviewRepo: Repository, + + @InjectRepository(Session) + private readonly sessionRepo: Repository, + + @InjectRepository(User) + private readonly userRepo: Repository, + ) {} + + async createReview( + reviewerId: string, + dto: CreateReviewDto, + ): Promise { + const session = await this.sessionRepo.findOne({ + where: { id: dto.sessionId }, + relations: ['mentee', 'mentor'], + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Rule 1: Session must be completed + if (session.status !== 'COMPLETED') { + throw new BadRequestException( + 'Reviews are only allowed after session completion', + ); + } + + // Rule 2: Reviewer must be the mentee + if (session.mentee.id !== reviewerId) { + throw new ForbiddenException( + 'Only the session mentee can submit a review', + ); + } + + // Rule 3: Reviewee must be the mentor + const mentor = session.mentor; + + // Rule 4: Prevent duplicate reviews + const existing = await this.reviewRepo.findOne({ + where: { + session: { id: session.id }, + reviewer: { id: reviewerId }, + }, + }); + + if (existing) { + throw new ConflictException( + 'You have already reviewed this session', + ); + } + + const review = this.reviewRepo.create({ + session, + reviewer: { id: reviewerId } as User, + reviewee: mentor, + rating: dto.rating, + comment: dto.comment, + }); + + const saved = await this.reviewRepo.save(review); + + // Optional hook: update mentor rating + await this.updateMentorRating(mentor.id); + + return saved; + } + + private async updateMentorRating(mentorId: string): Promise { + const { avg } = await this.reviewRepo + .createQueryBuilder('review') + .select('AVG(review.rating)', 'avg') + .where('review.revieweeId = :mentorId', { mentorId }) + .getRawOne<{ avg: string }>(); + + await this.userRepo.update(mentorId, { + averageRating: avg ? Number(avg) : 0, + }); + } +}