feat: Add course reviews module (OQI-002)

Created complete reviews module for education courses:
- types/reviews.types.ts: Type definitions aligned with DDL
- services/reviews.service.ts: Business logic for reviews
- controllers/reviews.controller.ts: HTTP endpoints
- Updated education.routes.ts with review routes

Features:
- Create/update/delete reviews (users)
- Get reviews by course with filters
- Rating summary and distribution
- Mark reviews as helpful (toggle vote)
- Approve/feature reviews (admin)
- Automatic course rating stats update

Endpoints:
- GET /courses/:courseId/reviews
- GET /courses/:courseId/reviews/summary
- POST /courses/:courseId/reviews
- PUT /reviews/:reviewId
- DELETE /reviews/:reviewId
- POST /reviews/:reviewId/helpful
- POST /reviews/:reviewId/approve (admin)
- POST /reviews/:reviewId/feature (admin)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-28 12:40:02 -06:00
parent 8f2b929587
commit b99953bf3e
4 changed files with 826 additions and 0 deletions

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
}

View File

@ -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 };

View File

@ -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<string, unknown>): 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<CourseReview[]> {
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<Record<string, unknown>>(
`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<CourseReview | null> {
const result = await db.query<Record<string, unknown>>(
`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<CourseReview | null> {
const result = await db.query<Record<string, unknown>>(
`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<CourseReview> {
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<Record<string, unknown>>(
`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<CourseReview | null> {
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<boolean> {
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<CourseReview | null> {
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<CourseRatingSummary> {
const result = await db.query<Record<string, string>>(
`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<CourseReview | null> {
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<CourseReview | null> {
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<void> {
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();

View File

@ -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;
};
}