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