diff --git a/src/modules/education/controllers/reviews.controller.ts b/src/modules/education/controllers/reviews.controller.ts new file mode 100644 index 0000000..3f99127 --- /dev/null +++ b/src/modules/education/controllers/reviews.controller.ts @@ -0,0 +1,356 @@ +/** + * Reviews Controller + * Handles course reviews endpoints + */ + +import { Request, Response, NextFunction } from 'express'; +import { reviewsService } from '../services/reviews.service'; +import type { ReviewsQueryOptions } from '../types/reviews.types'; + +type AuthRequest = Request & { user?: { id: string; email: string } }; + +export async function getCourseReviews(req: Request, res: Response, next: NextFunction): Promise { + try { + const { courseId } = req.params; + + const options: ReviewsQueryOptions = { + courseId, + rating: req.query.rating ? parseInt(req.query.rating as string, 10) : undefined, + sortBy: (req.query.sortBy as 'newest' | 'helpful' | 'rating') || 'newest', + limit: Math.min(parseInt(req.query.limit as string, 10) || 50, 100), + offset: parseInt(req.query.offset as string, 10) || 0, + onlyApproved: req.query.onlyApproved !== 'false', + }; + + const reviews = await reviewsService.getCourseReviews(options); + + res.json({ + success: true, + data: reviews, + }); + } catch (error) { + next(error); + } +} + +export async function getCourseRatingSummary(req: Request, res: Response, next: NextFunction): Promise { + try { + const { courseId } = req.params; + + const summary = await reviewsService.getCourseRatingSummary(courseId); + + res.json({ + success: true, + data: summary, + }); + } catch (error) { + next(error); + } +} + +export async function getMyReview(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { courseId } = req.params; + + const review = await reviewsService.getUserReview(userId, courseId); + + if (!review) { + res.status(404).json({ + success: false, + error: { message: 'Review not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: review, + }); + } catch (error) { + next(error); + } +} + +export async function createReview(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { courseId } = req.params; + const { rating, title, content } = req.body; + + if (!rating || rating < 1 || rating > 5) { + res.status(400).json({ + success: false, + error: { message: 'Rating must be between 1 and 5', code: 'VALIDATION_ERROR' }, + }); + return; + } + + if (!title || title.trim().length === 0) { + res.status(400).json({ + success: false, + error: { message: 'Review title is required', code: 'VALIDATION_ERROR' }, + }); + return; + } + + const review = await reviewsService.createReview(userId, { + courseId, + rating, + title, + content, + }); + + res.status(201).json({ + success: true, + data: review, + message: 'Review created successfully', + }); + } catch (error) { + if ((error as Error).message === 'You must be enrolled in this course to leave a review') { + res.status(403).json({ + success: false, + error: { message: (error as Error).message, code: 'NOT_ENROLLED' }, + }); + return; + } + if ((error as Error).message === 'You have already reviewed this course. Use update instead.') { + res.status(409).json({ + success: false, + error: { message: (error as Error).message, code: 'ALREADY_REVIEWED' }, + }); + return; + } + next(error); + } +} + +export async function updateReview(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { reviewId } = req.params; + const { rating, title, content } = req.body; + + if (rating !== undefined && (rating < 1 || rating > 5)) { + res.status(400).json({ + success: false, + error: { message: 'Rating must be between 1 and 5', code: 'VALIDATION_ERROR' }, + }); + return; + } + + const review = await reviewsService.updateReview(userId, reviewId, { + rating, + title, + content, + }); + + if (!review) { + res.status(404).json({ + success: false, + error: { message: 'Review not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: review, + message: 'Review updated successfully', + }); + } catch (error) { + if ((error as Error).message === 'You can only update your own reviews') { + res.status(403).json({ + success: false, + error: { message: (error as Error).message, code: 'FORBIDDEN' }, + }); + return; + } + if ((error as Error).message === 'Review not found') { + res.status(404).json({ + success: false, + error: { message: (error as Error).message, code: 'NOT_FOUND' }, + }); + return; + } + next(error); + } +} + +export async function deleteReview(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { reviewId } = req.params; + + const deleted = await reviewsService.deleteReview(userId, reviewId); + + if (!deleted) { + res.status(404).json({ + success: false, + error: { message: 'Review not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + message: 'Review deleted successfully', + }); + } catch (error) { + if ((error as Error).message === 'You can only delete your own reviews') { + res.status(403).json({ + success: false, + error: { message: (error as Error).message, code: 'FORBIDDEN' }, + }); + return; + } + if ((error as Error).message === 'Review not found') { + res.status(404).json({ + success: false, + error: { message: (error as Error).message, code: 'NOT_FOUND' }, + }); + return; + } + next(error); + } +} + +export async function markReviewHelpful(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { reviewId } = req.params; + + const review = await reviewsService.markHelpful(userId, reviewId); + + if (!review) { + res.status(404).json({ + success: false, + error: { message: 'Review not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: review, + message: 'Review helpful status updated', + }); + } catch (error) { + next(error); + } +} + +export async function approveReview(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { reviewId } = req.params; + + const review = await reviewsService.approveReview(reviewId, userId); + + if (!review) { + res.status(404).json({ + success: false, + error: { message: 'Review not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: review, + message: 'Review approved successfully', + }); + } catch (error) { + next(error); + } +} + +export async function featureReview(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { reviewId } = req.params; + const { featured } = req.body; + + if (typeof featured !== 'boolean') { + res.status(400).json({ + success: false, + error: { message: 'Featured must be a boolean', code: 'VALIDATION_ERROR' }, + }); + return; + } + + const review = await reviewsService.featureReview(reviewId, featured); + + if (!review) { + res.status(404).json({ + success: false, + error: { message: 'Review not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: review, + message: `Review ${featured ? 'featured' : 'unfeatured'} successfully`, + }); + } catch (error) { + next(error); + } +} diff --git a/src/modules/education/education.routes.ts b/src/modules/education/education.routes.ts index 3707ff0..fd03075 100644 --- a/src/modules/education/education.routes.ts +++ b/src/modules/education/education.routes.ts @@ -8,6 +8,7 @@ import * as educationController from './controllers/education.controller'; import * as quizController from './controllers/quiz.controller'; import * as gamificationController from './controllers/gamification.controller'; import * as videoController from './controllers/video.controller'; +import * as reviewsController from './controllers/reviews.controller'; import { requireAuth } from '../../core/guards/auth.guard'; const router = Router(); @@ -399,4 +400,66 @@ router.delete('/videos/:videoId', authHandler(requireAuth), authHandler(videoCon */ router.post('/videos/:videoId/processing-status', videoController.updateProcessingStatus); +// ============================================================================ +// Reviews Routes +// ============================================================================ + +/** + * GET /api/v1/education/courses/:courseId/reviews + * Get all reviews for a course + * Query params: rating, sortBy (newest|helpful|rating), limit, offset, onlyApproved + */ +router.get('/courses/:courseId/reviews', reviewsController.getCourseReviews); + +/** + * GET /api/v1/education/courses/:courseId/reviews/summary + * Get rating summary for a course + */ +router.get('/courses/:courseId/reviews/summary', reviewsController.getCourseRatingSummary); + +/** + * GET /api/v1/education/courses/:courseId/reviews/my-review + * Get current user's review for a course + */ +router.get('/courses/:courseId/reviews/my-review', authHandler(requireAuth), authHandler(reviewsController.getMyReview)); + +/** + * POST /api/v1/education/courses/:courseId/reviews + * Create a review for a course + * Body: { rating, title, content } + */ +router.post('/courses/:courseId/reviews', authHandler(requireAuth), authHandler(reviewsController.createReview)); + +/** + * PUT /api/v1/education/reviews/:reviewId + * Update a review + * Body: { rating?, title?, content? } + */ +router.put('/reviews/:reviewId', authHandler(requireAuth), authHandler(reviewsController.updateReview)); + +/** + * DELETE /api/v1/education/reviews/:reviewId + * Delete a review + */ +router.delete('/reviews/:reviewId', authHandler(requireAuth), authHandler(reviewsController.deleteReview)); + +/** + * POST /api/v1/education/reviews/:reviewId/helpful + * Mark/unmark a review as helpful (toggle) + */ +router.post('/reviews/:reviewId/helpful', authHandler(requireAuth), authHandler(reviewsController.markReviewHelpful)); + +/** + * POST /api/v1/education/reviews/:reviewId/approve + * Approve a review (admin/moderator only) + */ +router.post('/reviews/:reviewId/approve', authHandler(requireAuth), authHandler(reviewsController.approveReview)); + +/** + * POST /api/v1/education/reviews/:reviewId/feature + * Feature/unfeature a review (admin only) + * Body: { featured: boolean } + */ +router.post('/reviews/:reviewId/feature', authHandler(requireAuth), authHandler(reviewsController.featureReview)); + export { router as educationRouter }; diff --git a/src/modules/education/services/reviews.service.ts b/src/modules/education/services/reviews.service.ts new file mode 100644 index 0000000..1dcda24 --- /dev/null +++ b/src/modules/education/services/reviews.service.ts @@ -0,0 +1,336 @@ +/** + * Reviews Service + * Handles course reviews and ratings + */ + +import { db } from '../../../shared/database'; +import { logger } from '../../../shared/utils/logger'; +import { enrollmentService } from './enrollment.service'; +import type { + CourseReview, + CreateReviewDTO, + UpdateReviewDTO, + ReviewsQueryOptions, + CourseRatingSummary, +} from '../types/reviews.types'; + +function transformReview(row: Record): CourseReview { + return { + id: row.id as string, + userId: row.user_id as string, + courseId: row.course_id as string, + enrollmentId: row.enrollment_id as string, + rating: row.rating as number, + title: row.title as string | undefined, + content: row.content as string | undefined, + isApproved: (row.is_approved as boolean) || false, + isFeatured: (row.is_featured as boolean) || false, + approvedBy: row.approved_by as string | undefined, + approvedAt: row.approved_at ? new Date(row.approved_at as string) : undefined, + helpfulVotes: (row.helpful_votes as number) || 0, + createdAt: new Date(row.created_at as string), + updatedAt: new Date(row.updated_at as string), + userName: row.user_name as string | undefined, + userAvatar: row.user_avatar as string | undefined, + }; +} + +class ReviewsService { + async getCourseReviews(options: ReviewsQueryOptions): Promise { + const { courseId, rating, sortBy = 'newest', limit = 50, offset = 0, onlyApproved = true } = options; + + const conditions: string[] = ['course_id = $1']; + const params: (string | number | boolean)[] = [courseId]; + let paramIndex = 2; + + if (onlyApproved) { + conditions.push('is_approved = true'); + } + + if (rating !== undefined) { + conditions.push(`rating = $${paramIndex++}`); + params.push(rating); + } + + let orderClause = 'ORDER BY created_at DESC'; + if (sortBy === 'helpful') { + orderClause = 'ORDER BY helpful_votes DESC, created_at DESC'; + } else if (sortBy === 'rating') { + orderClause = 'ORDER BY rating DESC, created_at DESC'; + } + + params.push(limit, offset); + const result = await db.query>( + `SELECT r.*, + u.email as user_email, + CONCAT(COALESCE(up.first_name, ''), ' ', COALESCE(up.last_name, '')) as user_name, + up.avatar_url as user_avatar + FROM education.course_reviews r + JOIN auth.users u ON r.user_id = u.id + LEFT JOIN auth.user_profiles up ON u.id = up.user_id + WHERE ${conditions.join(' AND ')} + ${orderClause} + LIMIT $${paramIndex++} OFFSET $${paramIndex}`, + params + ); + + return result.rows.map(transformReview); + } + + async getUserReview(userId: string, courseId: string): Promise { + const result = await db.query>( + `SELECT r.*, + u.email as user_email, + CONCAT(COALESCE(up.first_name, ''), ' ', COALESCE(up.last_name, '')) as user_name, + up.avatar_url as user_avatar + FROM education.course_reviews r + JOIN auth.users u ON r.user_id = u.id + LEFT JOIN auth.user_profiles up ON u.id = up.user_id + WHERE r.user_id = $1 AND r.course_id = $2`, + [userId, courseId] + ); + + if (result.rows.length === 0) return null; + return transformReview(result.rows[0]); + } + + async getReviewById(reviewId: string): Promise { + const result = await db.query>( + `SELECT r.*, + u.email as user_email, + CONCAT(COALESCE(up.first_name, ''), ' ', COALESCE(up.last_name, '')) as user_name, + up.avatar_url as user_avatar + FROM education.course_reviews r + JOIN auth.users u ON r.user_id = u.id + LEFT JOIN auth.user_profiles up ON u.id = up.user_id + WHERE r.id = $1`, + [reviewId] + ); + + if (result.rows.length === 0) return null; + return transformReview(result.rows[0]); + } + + async createReview(userId: string, data: CreateReviewDTO): Promise { + if (data.rating < 1 || data.rating > 5) { + throw new Error('Rating must be between 1 and 5'); + } + + const enrollment = await enrollmentService.getEnrollment(userId, data.courseId); + if (!enrollment) { + throw new Error('You must be enrolled in this course to leave a review'); + } + + const existing = await this.getUserReview(userId, data.courseId); + if (existing) { + throw new Error('You have already reviewed this course. Use update instead.'); + } + + const result = await db.query>( + `INSERT INTO education.course_reviews ( + user_id, course_id, enrollment_id, rating, title, content + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [userId, data.courseId, enrollment.id, data.rating, data.title, data.content] + ); + + await this.updateCourseRatingStats(data.courseId); + + logger.info('[ReviewsService] Review created:', { + userId, + courseId: data.courseId, + rating: data.rating, + }); + + const review = await this.getReviewById(result.rows[0].id as string); + return review!; + } + + async updateReview(userId: string, reviewId: string, data: UpdateReviewDTO): Promise { + const review = await this.getReviewById(reviewId); + if (!review) { + throw new Error('Review not found'); + } + + if (review.userId !== userId) { + throw new Error('You can only update your own reviews'); + } + + if (data.rating !== undefined && (data.rating < 1 || data.rating > 5)) { + throw new Error('Rating must be between 1 and 5'); + } + + const updates: string[] = []; + const params: (string | number | null)[] = []; + let paramIndex = 1; + + if (data.rating !== undefined) { + updates.push(`rating = $${paramIndex++}`); + params.push(data.rating); + } + if (data.title !== undefined) { + updates.push(`title = $${paramIndex++}`); + params.push(data.title); + } + if (data.content !== undefined) { + updates.push(`content = $${paramIndex++}`); + params.push(data.content); + } + + if (updates.length === 0) { + return review; + } + + updates.push(`is_approved = false`); + + params.push(reviewId); + await db.query( + `UPDATE education.course_reviews + SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP + WHERE id = $${paramIndex}`, + params + ); + + await this.updateCourseRatingStats(review.courseId); + + logger.info('[ReviewsService] Review updated:', { reviewId, userId }); + + return this.getReviewById(reviewId); + } + + async deleteReview(userId: string, reviewId: string): Promise { + const review = await this.getReviewById(reviewId); + if (!review) { + throw new Error('Review not found'); + } + + if (review.userId !== userId) { + throw new Error('You can only delete your own reviews'); + } + + const result = await db.query( + `DELETE FROM education.course_reviews WHERE id = $1`, + [reviewId] + ); + + if ((result.rowCount ?? 0) > 0) { + await this.updateCourseRatingStats(review.courseId); + logger.info('[ReviewsService] Review deleted:', { reviewId, userId }); + } + + return (result.rowCount ?? 0) > 0; + } + + async markHelpful(userId: string, reviewId: string): Promise { + const checkResult = await db.query<{ exists: boolean }>( + `SELECT EXISTS( + SELECT 1 FROM education.review_helpful_votes + WHERE user_id = $1 AND review_id = $2 + ) as exists`, + [userId, reviewId] + ); + + if (checkResult.rows[0].exists) { + await db.query( + `DELETE FROM education.review_helpful_votes + WHERE user_id = $1 AND review_id = $2`, + [userId, reviewId] + ); + await db.query( + `UPDATE education.course_reviews + SET helpful_votes = GREATEST(helpful_votes - 1, 0) + WHERE id = $1`, + [reviewId] + ); + } else { + await db.query( + `INSERT INTO education.review_helpful_votes (user_id, review_id) + VALUES ($1, $2) + ON CONFLICT (user_id, review_id) DO NOTHING`, + [userId, reviewId] + ); + await db.query( + `UPDATE education.course_reviews + SET helpful_votes = helpful_votes + 1 + WHERE id = $1`, + [reviewId] + ); + } + + return this.getReviewById(reviewId); + } + + async getCourseRatingSummary(courseId: string): Promise { + const result = await db.query>( + `SELECT + COALESCE(AVG(rating), 0) as average_rating, + COUNT(*) as total_reviews, + COUNT(*) FILTER (WHERE rating = 5) as five_stars, + COUNT(*) FILTER (WHERE rating = 4) as four_stars, + COUNT(*) FILTER (WHERE rating = 3) as three_stars, + COUNT(*) FILTER (WHERE rating = 2) as two_stars, + COUNT(*) FILTER (WHERE rating = 1) as one_star + FROM education.course_reviews + WHERE course_id = $1 AND is_approved = true`, + [courseId] + ); + + const stats = result.rows[0]; + return { + courseId, + averageRating: parseFloat(stats.average_rating) || 0, + totalReviews: parseInt(stats.total_reviews, 10), + distribution: { + fiveStars: parseInt(stats.five_stars, 10), + fourStars: parseInt(stats.four_stars, 10), + threeStars: parseInt(stats.three_stars, 10), + twoStars: parseInt(stats.two_stars, 10), + oneStar: parseInt(stats.one_star, 10), + }, + }; + } + + async approveReview(reviewId: string, approvedBy: string): Promise { + await db.query( + `UPDATE education.course_reviews + SET is_approved = true, approved_by = $1, approved_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [approvedBy, reviewId] + ); + + logger.info('[ReviewsService] Review approved:', { reviewId, approvedBy }); + return this.getReviewById(reviewId); + } + + async featureReview(reviewId: string, featured: boolean): Promise { + await db.query( + `UPDATE education.course_reviews + SET is_featured = $1 + WHERE id = $2`, + [featured, reviewId] + ); + + logger.info('[ReviewsService] Review featured status updated:', { reviewId, featured }); + return this.getReviewById(reviewId); + } + + private async updateCourseRatingStats(courseId: string): Promise { + await db.query( + `UPDATE education.courses + SET avg_rating = ( + SELECT COALESCE(AVG(rating), 0) + FROM education.course_reviews + WHERE course_id = $1 AND is_approved = true + ), + total_reviews = ( + SELECT COUNT(*) + FROM education.course_reviews + WHERE course_id = $1 AND is_approved = true + ) + WHERE id = $1`, + [courseId] + ); + } +} + +export const reviewsService = new ReviewsService(); diff --git a/src/modules/education/types/reviews.types.ts b/src/modules/education/types/reviews.types.ts new file mode 100644 index 0000000..1d1fbb2 --- /dev/null +++ b/src/modules/education/types/reviews.types.ts @@ -0,0 +1,71 @@ +/** + * Course Reviews Types + * Type definitions for course reviews module + * Aligned with education.course_reviews DDL schema + */ + +export interface CourseReview { + id: string; + userId: string; + courseId: string; + enrollmentId: string; + rating: number; + title?: string; + content?: string; + isApproved: boolean; + isFeatured: boolean; + approvedBy?: string; + approvedAt?: Date; + helpfulVotes: number; + createdAt: Date; + updatedAt: Date; + userName?: string; + userAvatar?: string; +} + +export interface CourseReviewWithUser extends CourseReview { + user: { + id: string; + email: string; + profile?: { + firstName?: string; + lastName?: string; + avatarUrl?: string; + }; + }; +} + +export interface CreateReviewDTO { + courseId: string; + rating: number; + title: string; + content: string; +} + +export interface UpdateReviewDTO { + rating?: number; + title?: string; + content?: string; +} + +export interface ReviewsQueryOptions { + courseId: string; + rating?: number; + sortBy?: 'newest' | 'helpful' | 'rating'; + limit?: number; + offset?: number; + onlyApproved?: boolean; +} + +export interface CourseRatingSummary { + courseId: string; + averageRating: number; + totalReviews: number; + distribution: { + fiveStars: number; + fourStars: number; + threeStars: number; + twoStars: number; + oneStar: number; + }; +}