diff --git a/BackendAcademy/src/courses/course-rating.entity.ts b/BackendAcademy/src/courses/course-rating.entity.ts new file mode 100644 index 000000000..86e6664f5 --- /dev/null +++ b/BackendAcademy/src/courses/course-rating.entity.ts @@ -0,0 +1,56 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +/** + * A learner's rating and optional review for a course. + * + * Each user can rate a course at most once; submitting a new rating + * will update the existing one (upsert semantics). + */ +@Entity({ name: 'course_ratings' }) +@Index(['courseId', 'userId'], { unique: true }) +export class CourseRatingEntity { + @PrimaryGeneratedColumn('increment') + id: number; + + @Index('idx_course_ratings_course_id') + @Column({ name: 'course_id', type: 'uuid' }) + courseId: string; + + @Index('idx_course_ratings_user_id') + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + /** + * Rating value from 1 to 5 (inclusive). + * Stored as SMALLINT with a CHECK constraint in the migration. + */ + @Column({ type: 'smallint' }) + rating: number; + + /** + * Optional text review from the learner. + * Max 2000 characters. + */ + @Column({ type: 'text', nullable: true }) + review: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + constructor(partial: Partial = {}) { + Object.assign(this, partial); + this.createdAt = this.createdAt || new Date(); + this.updatedAt = this.updatedAt || new Date(); + this.review = this.review || null; + } +} diff --git a/BackendAcademy/src/courses/course-rating.service.spec.ts b/BackendAcademy/src/courses/course-rating.service.spec.ts new file mode 100644 index 000000000..cdc3c9a97 --- /dev/null +++ b/BackendAcademy/src/courses/course-rating.service.spec.ts @@ -0,0 +1,468 @@ +import { NotFoundException } from '@nestjs/common'; +import { CourseRatingService } from './course-rating.service'; +import { CourseRatingEntity } from './course-rating.entity'; +import { CourseEntity } from './course.entity'; +import { CourseLevel } from './interfaces/course-level.enum'; +import { CreateRatingDto } from './dto/create-rating.dto'; + +/** + * Minimal in-memory mock that imitates the subset of the Repository surface + * that CourseRatingService relies on. + */ +class InMemoryRepository { + protected readonly rows: Map = new Map(); + private nextId = 1; + + constructor( + protected readonly EntityCtor?: new (partial?: Partial) => T, + ) {} + + create(partial: Partial = {}): T { + if (this.EntityCtor) { + return new this.EntityCtor(partial); + } + return { ...(partial as T) }; + } + + async save(entity: T): Promise { + if (!entity.id) { + (entity as T & { id: number }).id = this.nextId++; + } + const now = new Date(); + if ('createdAt' in entity && !(entity as { createdAt?: Date }).createdAt) { + (entity as { createdAt: Date }).createdAt = now; + } + if ('updatedAt' in entity) { + (entity as { updatedAt: Date }).updatedAt = now; + } + this.rows.set((entity as T & { id: string | number }).id, entity); + return entity; + } + + async find(options: { + where?: Partial; + order?: { createdAt?: 'DESC' | 'ASC' }; + skip?: number; + take?: number; + } = {}): Promise { + const matches = Object.values(this.matchRows(options.where ?? {})); + if (options.order?.createdAt) { + matches.sort((a, b) => { + const aTime = (a as { createdAt: Date }).createdAt?.getTime() ?? 0; + const bTime = (b as { createdAt: Date }).createdAt?.getTime() ?? 0; + return options.order!.createdAt === 'DESC' ? bTime - aTime : aTime - bTime; + }); + } + const skip = options.skip ?? 0; + const take = options.take ?? matches.length; + return matches.slice(skip, skip + take); + } + + async findOne(options: { where: Partial }): Promise { + const matches = Object.values(this.matchRows(options.where)); + return matches[0] ?? null; + } + + async findAndCount(options: { + where?: Partial; + order?: { createdAt?: 'DESC' | 'ASC' }; + skip?: number; + take?: number; + } = {}): Promise<[T[], number]> { + const all = Object.values(this.matchRows(options.where ?? {})); + const total = all.length; + + if (options.order?.createdAt) { + all.sort((a, b) => { + const aTime = (a as { createdAt: Date }).createdAt?.getTime() ?? 0; + const bTime = (b as { createdAt: Date }).createdAt?.getTime() ?? 0; + return options.order!.createdAt === 'DESC' ? bTime - aTime : aTime - bTime; + }); + } + + const skip = options.skip ?? 0; + const take = options.take ?? total; + const data = all.slice(skip, skip + take); + + return [data, total]; + } + + async remove(entity: T): Promise { + const id = (entity as T & { id: string | number }).id; + this.rows.delete(id); + return entity; + } + + private matchRows(where: Partial): Record { + const matches: Record = {}; + for (const [id, row] of this.rows.entries()) { + const ok = Object.entries(where as Record).every( + ([key, expected]) => { + const actual = (row as Record)[key]; + if (Array.isArray(expected)) { + return Array.isArray(actual) && + expected.length === actual.length && + expected.every((v, i) => v === (actual as unknown[])[i]); + } + return actual === expected; + }, + ); + if (ok) matches[id] = row; + } + return matches; + } +} + +class InMemoryRatingRepo extends InMemoryRepository { + constructor() { + super(CourseRatingEntity); + } +} + +class InMemoryCourseRepo extends InMemoryRepository { + constructor() { + super(CourseEntity); + } + + async findOne(options: { where: { id: string } }): Promise { + const id = options.where.id; + return this.rows.get(id) as CourseEntity | undefined ?? null; + } +} + +describe('CourseRatingService', () => { + let service: CourseRatingService; + let ratingRepo: InMemoryRatingRepo; + let courseRepo: InMemoryCourseRepo; + let testCourseId: string; + let learnerId: string; + let userId2: string; + + beforeEach(async () => { + ratingRepo = new InMemoryRatingRepo(); + courseRepo = new InMemoryCourseRepo(); + service = new CourseRatingService( + ratingRepo as unknown as import('typeorm').Repository, + courseRepo as unknown as import('typeorm').Repository, + ); + + // Create a test course + testCourseId = crypto.randomUUID(); + learnerId = crypto.randomUUID(); + userId2 = crypto.randomUUID(); + + const course = new CourseEntity({ + id: testCourseId, + title: 'Test Course', + description: 'A test course', + level: CourseLevel.BEGINNER, + order: 1, + learningPathId: 'path-1', + duration: 30, + }); + await courseRepo.save(course); + }); + + // --------------------------------------------------------------------------- + // Submit Rating Tests + // --------------------------------------------------------------------------- + + it('should create a new rating when user has not rated the course', async () => { + const dto: CreateRatingDto = { rating: 5, review: 'Great course!' }; + const result = await service.submitRating(testCourseId, learnerId, dto); + + expect(result).toBeDefined(); + expect(result.courseId).toBe(testCourseId); + expect(result.userId).toBe(learnerId); + expect(result.rating).toBe(5); + expect(result.review).toBe('Great course!'); + expect(result.createdAt).toBeDefined(); + expect(result.updatedAt).toBeDefined(); + }); + + it('should update existing rating when user has already rated the course', async () => { + const dto1: CreateRatingDto = { rating: 3, review: 'Good' }; + const rating1 = await service.submitRating(testCourseId, learnerId, dto1); + const initialId = rating1.id; + const createdAtTime = rating1.createdAt.getTime(); + + // Wait a tiny bit to ensure updatedAt changes + await new Promise((resolve) => setTimeout(resolve, 10)); + + const dto2: CreateRatingDto = { rating: 5, review: 'Excellent!' }; + const rating2 = await service.submitRating(testCourseId, learnerId, dto2); + + // Same ID (upsert, not insert) + expect(rating2.id).toBe(initialId); + expect(rating2.rating).toBe(5); + expect(rating2.review).toBe('Excellent!'); + expect(rating2.createdAt.getTime()).toBe(createdAtTime); + expect(rating2.updatedAt.getTime()).toBeGreaterThan(createdAtTime); + + // Verify only one rating exists in DB + const allRatings = await ratingRepo.find({ where: { courseId: testCourseId } }); + expect(allRatings).toHaveLength(1); + }); + + it('should accept rating without review', async () => { + const dto: CreateRatingDto = { rating: 4 }; + const result = await service.submitRating(testCourseId, learnerId, dto); + + expect(result.rating).toBe(4); + expect(result.review).toBeNull(); + }); + + it('should reject rating for non-existent course', async () => { + const fakeCourseId = crypto.randomUUID(); + const dto: CreateRatingDto = { rating: 5 }; + + await expect( + service.submitRating(fakeCourseId, learnerId, dto), + ).rejects.toThrow(NotFoundException); + }); + + // --------------------------------------------------------------------------- + // List Ratings Tests + // --------------------------------------------------------------------------- + + it('should list ratings for a course with pagination', async () => { + // Insert 25 ratings + for (let i = 0; i < 25; i++) { + const userId = crypto.randomUUID(); + const dto: CreateRatingDto = { rating: (i % 5) + 1 }; + await service.submitRating(testCourseId, userId, dto); + } + + // Get first page (default: 20 per page) + const page1 = await service.listRatings(testCourseId, 1, 20); + expect(page1.data).toHaveLength(20); + expect(page1.total).toBe(25); + expect(page1.page).toBe(1); + expect(page1.perPage).toBe(20); + expect(page1.pages).toBe(2); + + // Get second page + const page2 = await service.listRatings(testCourseId, 2, 20); + expect(page2.data).toHaveLength(5); + expect(page2.page).toBe(2); + + // Verify no overlap + const page1Ids = page1.data.map((r) => r.id); + const page2Ids = page2.data.map((r) => r.id); + expect(new Set([...page1Ids, ...page2Ids])).toHaveSize(25); + }); + + it('should default pagination parameters correctly', async () => { + const dto: CreateRatingDto = { rating: 5 }; + await service.submitRating(testCourseId, learnerId, dto); + + const result = await service.listRatings(testCourseId); + expect(result.page).toBe(1); + expect(result.perPage).toBe(20); + expect(result.pages).toBe(1); + expect(result.data).toHaveLength(1); + }); + + it('should clamp invalid pagination parameters', async () => { + const dto: CreateRatingDto = { rating: 5 }; + for (let i = 0; i < 5; i++) { + await service.submitRating(testCourseId, crypto.randomUUID(), dto); + } + + // Negative page → 1 + const badPage = await service.listRatings(testCourseId, -5, 10); + expect(badPage.page).toBe(1); + + // perPage > 100 → 20 + const badPerPage = await service.listRatings(testCourseId, 1, 200); + expect(badPerPage.perPage).toBe(20); + }); + + it('should throw NotFoundException for non-existent course', async () => { + const fakeCourseId = crypto.randomUUID(); + await expect(service.listRatings(fakeCourseId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should return empty list for course with no ratings', async () => { + const result = await service.listRatings(testCourseId); + expect(result.data).toHaveLength(0); + expect(result.total).toBe(0); + expect(result.pages).toBe(0); + }); + + // --------------------------------------------------------------------------- + // Rating Stats Tests + // --------------------------------------------------------------------------- + + it('should calculate correct average rating', async () => { + // Insert ratings: 5, 4, 3 + await service.submitRating(testCourseId, crypto.randomUUID(), { + rating: 5, + }); + await service.submitRating(testCourseId, crypto.randomUUID(), { + rating: 4, + }); + await service.submitRating(testCourseId, crypto.randomUUID(), { + rating: 3, + }); + + const stats = await service.getRatingStats(testCourseId); + expect(stats.averageRating).toBe(4); // (5+4+3)/3 = 4 + expect(stats.totalRatings).toBe(3); + expect(stats.courseId).toBe(testCourseId); + }); + + it('should return correct rating distribution', async () => { + // Insert: 1×1-star, 2×2-star, 3×3-star, 4×4-star, 5×5-star + await service.submitRating(testCourseId, crypto.randomUUID(), { rating: 1 }); + for (let i = 0; i < 2; i++) + await service.submitRating(testCourseId, crypto.randomUUID(), { + rating: 2, + }); + for (let i = 0; i < 3; i++) + await service.submitRating(testCourseId, crypto.randomUUID(), { + rating: 3, + }); + for (let i = 0; i < 4; i++) + await service.submitRating(testCourseId, crypto.randomUUID(), { + rating: 4, + }); + for (let i = 0; i < 5; i++) + await service.submitRating(testCourseId, crypto.randomUUID(), { + rating: 5, + }); + + const stats = await service.getRatingStats(testCourseId); + expect(stats.ratingDistribution.oneStar).toBe(1); + expect(stats.ratingDistribution.twoStar).toBe(2); + expect(stats.ratingDistribution.threeStar).toBe(3); + expect(stats.ratingDistribution.fourStar).toBe(4); + expect(stats.ratingDistribution.fiveStar).toBe(5); + }); + + it('should round average rating to 2 decimal places', async () => { + // Insert ratings: 5, 5, 4 → average = 4.666... → 4.67 + await service.submitRating(testCourseId, crypto.randomUUID(), { rating: 5 }); + await service.submitRating(testCourseId, crypto.randomUUID(), { rating: 5 }); + await service.submitRating(testCourseId, crypto.randomUUID(), { rating: 4 }); + + const stats = await service.getRatingStats(testCourseId); + expect(stats.averageRating).toBe(4.67); + }); + + it('should return 0 average for course with no ratings', async () => { + const stats = await service.getRatingStats(testCourseId); + expect(stats.averageRating).toBe(0); + expect(stats.totalRatings).toBe(0); + expect(stats.ratingDistribution.oneStar).toBe(0); + expect(stats.ratingDistribution.fiveStar).toBe(0); + }); + + it('should throw NotFoundException for non-existent course', async () => { + const fakeCourseId = crypto.randomUUID(); + await expect(service.getRatingStats(fakeCourseId)).rejects.toThrow( + NotFoundException, + ); + }); + + // --------------------------------------------------------------------------- + // Delete Rating Tests + // --------------------------------------------------------------------------- + + it('should delete a user rating', async () => { + const dto: CreateRatingDto = { rating: 5, review: 'Great!' }; + await service.submitRating(testCourseId, learnerId, dto); + + // Verify rating exists + let rating = await service.getUserRating(testCourseId, learnerId); + expect(rating).not.toBeNull(); + + // Delete it + const deleted = await service.deleteRating(testCourseId, learnerId); + expect(deleted).toBe(true); + + // Verify it's gone + rating = await service.getUserRating(testCourseId, learnerId); + expect(rating).toBeNull(); + }); + + it('should throw NotFoundException when deleting non-existent rating', async () => { + // No rating submitted yet + await expect(service.deleteRating(testCourseId, learnerId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should only delete own rating (upsert test)', async () => { + // User 1 submits a rating + await service.submitRating(testCourseId, learnerId, { rating: 5 }); + + // User 2 tries to delete User 1's rating → should fail + await expect(service.deleteRating(testCourseId, userId2)).rejects.toThrow( + NotFoundException, + ); + + // User 1's rating should still exist + const rating = await service.getUserRating(testCourseId, learnerId); + expect(rating).not.toBeNull(); + }); + + // --------------------------------------------------------------------------- + // Helper Methods Tests + // --------------------------------------------------------------------------- + + it('should check if user has rated course', async () => { + const hasRated1 = await service.hasUserRated(testCourseId, learnerId); + expect(hasRated1).toBe(false); + + await service.submitRating(testCourseId, learnerId, { rating: 4 }); + + const hasRated2 = await service.hasUserRated(testCourseId, learnerId); + expect(hasRated2).toBe(true); + }); + + it('should retrieve specific user rating', async () => { + const ratingBefore = await service.getUserRating(testCourseId, learnerId); + expect(ratingBefore).toBeNull(); + + const submitted = await service.submitRating(testCourseId, learnerId, { + rating: 3, + review: 'Okay', + }); + + const retrieved = await service.getUserRating(testCourseId, learnerId); + expect(retrieved).not.toBeNull(); + expect(retrieved!.id).toBe(submitted.id); + expect(retrieved!.rating).toBe(3); + expect(retrieved!.review).toBe('Okay'); + }); + + // --------------------------------------------------------------------------- + // Validation Tests (via DTOs) + // --------------------------------------------------------------------------- + + it('should support multiple users rating the same course independently', async () => { + const userIds = Array.from({ length: 5 }, () => crypto.randomUUID()); + for (let i = 0; i < 5; i++) { + await service.submitRating(testCourseId, userIds[i], { rating: i + 1 }); + } + + const stats = await service.getRatingStats(testCourseId); + expect(stats.totalRatings).toBe(5); + // Average = (1+2+3+4+5)/5 = 3 + expect(stats.averageRating).toBe(3); + }); + + it('should preserve review text exactly as submitted', async () => { + const reviewText = 'This course is amazing!\n\nI learned so much.'; + await service.submitRating(testCourseId, learnerId, { + rating: 5, + review: reviewText, + }); + + const rating = await service.getUserRating(testCourseId, learnerId); + expect(rating!.review).toBe(reviewText); + }); +}); diff --git a/BackendAcademy/src/courses/course-rating.service.ts b/BackendAcademy/src/courses/course-rating.service.ts new file mode 100644 index 000000000..a99f2215c --- /dev/null +++ b/BackendAcademy/src/courses/course-rating.service.ts @@ -0,0 +1,198 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CourseRatingEntity } from './course-rating.entity'; +import { CourseEntity } from './course.entity'; +import { CreateRatingDto } from './dto/create-rating.dto'; +import { CourseRatingStatsDto, RatingDistributionDto } from './dto/rating-stats.dto'; + +/** + * Business logic for course ratings and reviews. + * + * Persistence is delegated to injected TypeORM repositories. + * Each user can rate a course at most once; subsequent ratings update the existing one (upsert semantics). + */ +@Injectable() +export class CourseRatingService { + constructor( + @InjectRepository(CourseRatingEntity) + private readonly ratingRepo: Repository, + @InjectRepository(CourseEntity) + private readonly courseRepo: Repository, + ) {} + + /** + * Submit or update a rating for a course. + * - If the user has not previously rated this course, creates a new rating. + * - If the user has already rated this course, updates the existing rating. + * Returns the saved rating entity. + */ + async submitRating( + courseId: string, + userId: string, + dto: CreateRatingDto, + ): Promise { + // Verify course exists + const course = await this.courseRepo.findOne({ where: { id: courseId } }); + if (!course) { + throw new NotFoundException({ + error: 'COURSE_NOT_FOUND', + message: `Course with ID ${courseId} not found`, + }); + } + + // Check if user already has a rating for this course + let rating = await this.ratingRepo.findOne({ + where: { courseId, userId }, + }); + + if (rating) { + // Update existing rating (upsert) + rating.rating = dto.rating; + rating.review = dto.review ?? null; + rating.updatedAt = new Date(); + } else { + // Create new rating + rating = this.ratingRepo.create({ + courseId, + userId, + rating: dto.rating, + review: dto.review ?? null, + }); + } + + return this.ratingRepo.save(rating); + } + + /** + * Retrieve all ratings for a course with pagination support. + * Returns ratings ordered by most recent first, with reviewer info (no email). + */ + async listRatings( + courseId: string, + page: number = 1, + perPage: number = 20, + ): Promise<{ + data: CourseRatingEntity[]; + total: number; + page: number; + perPage: number; + pages: number; + }> { + // Verify course exists + const course = await this.courseRepo.findOne({ where: { id: courseId } }); + if (!course) { + throw new NotFoundException({ + error: 'COURSE_NOT_FOUND', + message: `Course with ID ${courseId} not found`, + }); + } + + // Validate pagination params + if (page < 1) page = 1; + if (perPage < 1 || perPage > 100) perPage = 20; + + const skip = (page - 1) * perPage; + + const [data, total] = await this.ratingRepo.findAndCount({ + where: { courseId }, + order: { createdAt: 'DESC' }, + skip, + take: perPage, + }); + + const pages = Math.ceil(total / perPage); + + return { data, total, page, perPage, pages }; + } + + /** + * Get aggregated rating statistics for a course. + * Returns average rating (to 2 decimal places), total count, and distribution. + */ + async getRatingStats(courseId: string): Promise { + // Verify course exists + const course = await this.courseRepo.findOne({ where: { id: courseId } }); + if (!course) { + throw new NotFoundException({ + error: 'COURSE_NOT_FOUND', + message: `Course with ID ${courseId} not found`, + }); + } + + // Get all ratings for this course + const ratings = await this.ratingRepo.find({ + where: { courseId }, + }); + + const totalRatings = ratings.length; + const averageRating = + totalRatings > 0 + ? Math.round( + (ratings.reduce((sum, r) => sum + r.rating, 0) / totalRatings) * + 100, + ) / 100 + : 0; + + // Calculate distribution + const distribution: RatingDistributionDto = { + oneStar: ratings.filter((r) => r.rating === 1).length, + twoStar: ratings.filter((r) => r.rating === 2).length, + threeStar: ratings.filter((r) => r.rating === 3).length, + fourStar: ratings.filter((r) => r.rating === 4).length, + fiveStar: ratings.filter((r) => r.rating === 5).length, + }; + + return { + courseId, + averageRating, + totalRatings, + ratingDistribution: distribution, + }; + } + + /** + * Delete a rating submitted by a user for a course. + * Only allows a user to delete their own rating. + * Returns true if a rating was deleted, false if no rating existed. + */ + async deleteRating(courseId: string, userId: string): Promise { + const rating = await this.ratingRepo.findOne({ + where: { courseId, userId }, + }); + + if (!rating) { + // Return 404 without leaking existence + throw new NotFoundException({ + error: 'RATING_NOT_FOUND', + message: 'No rating found for this course', + }); + } + + await this.ratingRepo.remove(rating); + return true; + } + + /** + * Check if a specific user has rated a specific course. + * Used internally for validation. + */ + async hasUserRated(courseId: string, userId: string): Promise { + const rating = await this.ratingRepo.findOne({ + where: { courseId, userId }, + }); + return !!rating; + } + + /** + * Get a specific user's rating for a course (if it exists). + */ + async getUserRating( + courseId: string, + userId: string, + ): Promise { + return this.ratingRepo.findOne({ + where: { courseId, userId }, + }); + } +} diff --git a/BackendAcademy/src/courses/course.controller.ts b/BackendAcademy/src/courses/course.controller.ts index 9a1e8cc32..55ae624ce 100644 --- a/BackendAcademy/src/courses/course.controller.ts +++ b/BackendAcademy/src/courses/course.controller.ts @@ -6,18 +6,31 @@ import { Delete, Body, Param, + UseGuards, + Request, + Query, + HttpCode, } from '@nestjs/common'; import { CourseService } from './course.service'; +import { CourseRatingService } from './course-rating.service'; import { CourseRevisionEntity } from './course-revision.entity'; import { CourseEntity } from './course.entity'; +import { CourseRatingEntity } from './course-rating.entity'; import { CreateCourseDto } from './dto/create-course.dto'; import { UpdateCourseDto } from './dto/update-course.dto'; import { RestoreRevisionDto } from './dto/restore-revision.dto'; import { CompleteCourseDto } from './dto/complete-course.dto'; +import { CreateRatingDto } from './dto/create-rating.dto'; +import { CourseRatingStatsDto } from './dto/rating-stats.dto'; +import { JwtLearnerGuard } from '../auth/guards/jwt-learner.guard'; +import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; @Controller('courses') export class CourseController { - constructor(private readonly courseService: CourseService) {} + constructor( + private readonly courseService: CourseService, + private readonly ratingService: CourseRatingService, + ) {} @Post() async create(@Body() dto: CreateCourseDto) { @@ -107,4 +120,66 @@ export class CourseController { async complete(@Param('id') id: string, @Body() dto: CompleteCourseDto) { return this.courseService.completeCourse(id, dto.userId); } + + // --------------------------------------------------------------------------- + // Course ratings endpoints + // --------------------------------------------------------------------------- + + /** + * Submit or update a rating for a course. + * Requires authentication. If user already rated this course, their rating is updated (upsert). + * Returns 201 Created on first submission, 200 OK on update. + */ + @Post(':id/ratings') + @UseGuards(JwtLearnerGuard) + async submitRating( + @Param('id') courseId: string, + @Body() dto: CreateRatingDto, + @Request() req: Express.Request & { user: JwtPayload }, + ): Promise { + const userId = req.user.sub; + return this.ratingService.submitRating(courseId, userId, dto); + } + + /** + * List all ratings for a course with pagination support. + * Public endpoint (no auth required). + * Supports ?page=1&per_page=20 query params. + */ + @Get(':id/ratings') + async listRatings( + @Param('id') courseId: string, + @Query('page') page?: string, + @Query('per_page') perPage?: string, + ) { + const pageNum = page ? parseInt(page, 10) : 1; + const perPageNum = perPage ? parseInt(perPage, 10) : 20; + return this.ratingService.listRatings(courseId, pageNum, perPageNum); + } + + /** + * Get aggregated rating statistics for a course. + * Public endpoint (no auth required). + * Returns average rating (2 decimal places), total count, and star distribution. + */ + @Get(':id/ratings/stats') + async getRatingStats(@Param('id') courseId: string): Promise { + return this.ratingService.getRatingStats(courseId); + } + + /** + * Delete a user's rating for a course. + * Requires authentication. Only allows deleting own rating. + * Returns 204 No Content on success. + */ + @Delete(':id/ratings') + @HttpCode(204) + @UseGuards(JwtLearnerGuard) + async deleteRating( + @Param('id') courseId: string, + @Request() req: Express.Request & { user: JwtPayload }, + ): Promise { + const userId = req.user.sub; + await this.ratingService.deleteRating(courseId, userId); + } } diff --git a/BackendAcademy/src/courses/course.module.ts b/BackendAcademy/src/courses/course.module.ts index 1aa97b511..3c697b565 100644 --- a/BackendAcademy/src/courses/course.module.ts +++ b/BackendAcademy/src/courses/course.module.ts @@ -2,17 +2,23 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CourseController } from './course.controller'; import { CourseService } from './course.service'; +import { CourseRatingService } from './course-rating.service'; import { CourseEntity } from './course.entity'; import { CourseRevisionEntity } from './course-revision.entity'; +import { CourseRatingEntity } from './course-rating.entity'; import { RewardsModule } from '../rewards/rewards.module'; @Module({ imports: [ - TypeOrmModule.forFeature([CourseEntity, CourseRevisionEntity]), + TypeOrmModule.forFeature([ + CourseEntity, + CourseRevisionEntity, + CourseRatingEntity, + ]), RewardsModule, ], controllers: [CourseController], - providers: [CourseService], - exports: [CourseService], + providers: [CourseService, CourseRatingService], + exports: [CourseService, CourseRatingService], }) export class CourseModule {} diff --git a/BackendAcademy/src/courses/dto/create-rating.dto.ts b/BackendAcademy/src/courses/dto/create-rating.dto.ts new file mode 100644 index 000000000..d2c0b77b0 --- /dev/null +++ b/BackendAcademy/src/courses/dto/create-rating.dto.ts @@ -0,0 +1,22 @@ +import { IsNumber, IsOptional, IsString, Max, Min, Length } from 'class-validator'; + +/** + * Request body for submitting or updating a course rating. + */ +export class CreateRatingDto { + /** + * Rating value from 1 to 5 (inclusive). + */ + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + /** + * Optional review text, max 2000 characters. + */ + @IsOptional() + @IsString() + @Length(0, 2000) + review?: string; +} diff --git a/BackendAcademy/src/courses/dto/rating-stats.dto.ts b/BackendAcademy/src/courses/dto/rating-stats.dto.ts new file mode 100644 index 000000000..a3daa14ee --- /dev/null +++ b/BackendAcademy/src/courses/dto/rating-stats.dto.ts @@ -0,0 +1,20 @@ +/** + * Distribution of ratings (1 to 5 stars). + */ +export class RatingDistributionDto { + oneStar: number; + twoStar: number; + threeStar: number; + fourStar: number; + fiveStar: number; +} + +/** + * Aggregated rating statistics for a course. + */ +export class CourseRatingStatsDto { + courseId: string; + averageRating: number; + totalRatings: number; + ratingDistribution: RatingDistributionDto; +}