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:
parent
8f2b929587
commit
b99953bf3e
356
src/modules/education/controllers/reviews.controller.ts
Normal file
356
src/modules/education/controllers/reviews.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import * as educationController from './controllers/education.controller';
|
|||||||
import * as quizController from './controllers/quiz.controller';
|
import * as quizController from './controllers/quiz.controller';
|
||||||
import * as gamificationController from './controllers/gamification.controller';
|
import * as gamificationController from './controllers/gamification.controller';
|
||||||
import * as videoController from './controllers/video.controller';
|
import * as videoController from './controllers/video.controller';
|
||||||
|
import * as reviewsController from './controllers/reviews.controller';
|
||||||
import { requireAuth } from '../../core/guards/auth.guard';
|
import { requireAuth } from '../../core/guards/auth.guard';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@ -399,4 +400,66 @@ router.delete('/videos/:videoId', authHandler(requireAuth), authHandler(videoCon
|
|||||||
*/
|
*/
|
||||||
router.post('/videos/:videoId/processing-status', videoController.updateProcessingStatus);
|
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 };
|
export { router as educationRouter };
|
||||||
|
|||||||
336
src/modules/education/services/reviews.service.ts
Normal file
336
src/modules/education/services/reviews.service.ts
Normal 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();
|
||||||
71
src/modules/education/types/reviews.types.ts
Normal file
71
src/modules/education/types/reviews.types.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user