|
| 1 | +const mongoose = require("mongoose"); |
| 2 | +const CourseReview = require("../schemas/courseReviewModel"); |
| 3 | +const Course = require("../schemas/courseModel"); |
| 4 | +const EnrolledCourse = require("../schemas/enrolledCourseModel"); |
| 5 | + |
| 6 | +const ALLOWED_SORTS = new Set(["newest", "oldest", "highest", "lowest"]); |
| 7 | + |
| 8 | +const parsePositiveInteger = (value, fallback, max) => { |
| 9 | + const parsed = Number.parseInt(value, 10); |
| 10 | + if (!Number.isFinite(parsed) || parsed < 1) return fallback; |
| 11 | + return Math.min(parsed, max); |
| 12 | +}; |
| 13 | + |
| 14 | +const validateCourseId = (courseId) => mongoose.Types.ObjectId.isValid(courseId); |
| 15 | + |
| 16 | +const getAuthenticatedUserId = (req) => |
| 17 | + req.user?._id?.toString() || req.body?.userId || null; |
| 18 | + |
| 19 | +const serializeReview = (review, currentUserId = null) => ({ |
| 20 | + id: review._id.toString(), |
| 21 | + rating: review.rating, |
| 22 | + reviewText: review.reviewText || "", |
| 23 | + createdAt: review.createdAt, |
| 24 | + updatedAt: review.updatedAt, |
| 25 | + verifiedEnrollment: true, |
| 26 | + user: { |
| 27 | + id: review.userId?._id?.toString() || review.userId?.toString() || null, |
| 28 | + name: review.userId?.name || "LearnHub student", |
| 29 | + }, |
| 30 | + isOwner: |
| 31 | + Boolean(currentUserId) && |
| 32 | + String(review.userId?._id || review.userId) === String(currentUserId), |
| 33 | +}); |
| 34 | + |
| 35 | +const getSummary = async (courseId) => { |
| 36 | + const objectId = new mongoose.Types.ObjectId(courseId); |
| 37 | + const [summary] = await CourseReview.aggregate([ |
| 38 | + { $match: { courseId: objectId } }, |
| 39 | + { |
| 40 | + $group: { |
| 41 | + _id: "$courseId", |
| 42 | + averageRating: { $avg: "$rating" }, |
| 43 | + totalReviews: { $sum: 1 }, |
| 44 | + oneStar: { $sum: { $cond: [{ $eq: ["$rating", 1] }, 1, 0] } }, |
| 45 | + twoStar: { $sum: { $cond: [{ $eq: ["$rating", 2] }, 1, 0] } }, |
| 46 | + threeStar: { $sum: { $cond: [{ $eq: ["$rating", 3] }, 1, 0] } }, |
| 47 | + fourStar: { $sum: { $cond: [{ $eq: ["$rating", 4] }, 1, 0] } }, |
| 48 | + fiveStar: { $sum: { $cond: [{ $eq: ["$rating", 5] }, 1, 0] } }, |
| 49 | + }, |
| 50 | + }, |
| 51 | + ]); |
| 52 | + |
| 53 | + return { |
| 54 | + averageRating: summary |
| 55 | + ? Number(summary.averageRating.toFixed(1)) |
| 56 | + : 0, |
| 57 | + totalReviews: summary?.totalReviews || 0, |
| 58 | + distribution: { |
| 59 | + 1: summary?.oneStar || 0, |
| 60 | + 2: summary?.twoStar || 0, |
| 61 | + 3: summary?.threeStar || 0, |
| 62 | + 4: summary?.fourStar || 0, |
| 63 | + 5: summary?.fiveStar || 0, |
| 64 | + }, |
| 65 | + }; |
| 66 | +}; |
| 67 | + |
| 68 | +const ensureCourseExists = async (courseId) => { |
| 69 | + const course = await Course.findById(courseId).select("_id C_title").lean(); |
| 70 | + return course; |
| 71 | +}; |
| 72 | + |
| 73 | +const ensureEnrollment = async (userId, courseId) => { |
| 74 | + return EnrolledCourse.findOne({ userId, courseId }).select("_id").lean(); |
| 75 | +}; |
| 76 | + |
| 77 | +const createReview = async (req, res) => { |
| 78 | + try { |
| 79 | + const { courseId } = req.params; |
| 80 | + const userId = getAuthenticatedUserId(req); |
| 81 | + const rating = Number(req.body.rating); |
| 82 | + const reviewText = String(req.body.reviewText || "").trim(); |
| 83 | + |
| 84 | + if (!userId || !validateCourseId(courseId)) { |
| 85 | + return res.status(400).send({ |
| 86 | + success: false, |
| 87 | + message: "A valid course and authenticated user are required.", |
| 88 | + }); |
| 89 | + } |
| 90 | + |
| 91 | + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { |
| 92 | + return res.status(400).send({ |
| 93 | + success: false, |
| 94 | + message: "Rating must be an integer from 1 to 5.", |
| 95 | + }); |
| 96 | + } |
| 97 | + |
| 98 | + if (reviewText.length > 1000) { |
| 99 | + return res.status(400).send({ |
| 100 | + success: false, |
| 101 | + message: "Review text cannot exceed 1000 characters.", |
| 102 | + }); |
| 103 | + } |
| 104 | + |
| 105 | + const course = await ensureCourseExists(courseId); |
| 106 | + if (!course) { |
| 107 | + return res.status(404).send({ |
| 108 | + success: false, |
| 109 | + message: "Course not found.", |
| 110 | + }); |
| 111 | + } |
| 112 | + |
| 113 | + const enrollment = await ensureEnrollment(userId, courseId); |
| 114 | + if (!enrollment) { |
| 115 | + return res.status(403).send({ |
| 116 | + success: false, |
| 117 | + message: "Only enrolled students can review this course.", |
| 118 | + }); |
| 119 | + } |
| 120 | + |
| 121 | + const review = await CourseReview.create({ |
| 122 | + userId, |
| 123 | + courseId, |
| 124 | + rating, |
| 125 | + reviewText, |
| 126 | + }); |
| 127 | + |
| 128 | + await review.populate("userId", "name"); |
| 129 | + |
| 130 | + return res.status(201).send({ |
| 131 | + success: true, |
| 132 | + message: "Review submitted successfully.", |
| 133 | + data: serializeReview(review, userId), |
| 134 | + summary: await getSummary(courseId), |
| 135 | + }); |
| 136 | + } catch (error) { |
| 137 | + if (error?.code === 11000) { |
| 138 | + return res.status(409).send({ |
| 139 | + success: false, |
| 140 | + message: "You have already reviewed this course.", |
| 141 | + }); |
| 142 | + } |
| 143 | + |
| 144 | + console.error("Unable to create course review:", error); |
| 145 | + return res.status(500).send({ |
| 146 | + success: false, |
| 147 | + message: "Unable to submit the review.", |
| 148 | + }); |
| 149 | + } |
| 150 | +}; |
| 151 | + |
| 152 | +const listReviews = async (req, res) => { |
| 153 | + try { |
| 154 | + const { courseId } = req.params; |
| 155 | + if (!validateCourseId(courseId)) { |
| 156 | + return res.status(400).send({ |
| 157 | + success: false, |
| 158 | + message: "Invalid course ID.", |
| 159 | + }); |
| 160 | + } |
| 161 | + |
| 162 | + const page = parsePositiveInteger(req.query.page, 1, 100000); |
| 163 | + const limit = parsePositiveInteger(req.query.limit, 5, 25); |
| 164 | + const sort = String(req.query.sort || "newest").toLowerCase(); |
| 165 | + |
| 166 | + if (!ALLOWED_SORTS.has(sort)) { |
| 167 | + return res.status(400).send({ |
| 168 | + success: false, |
| 169 | + message: "Invalid review sort option.", |
| 170 | + }); |
| 171 | + } |
| 172 | + |
| 173 | + const sortMap = { |
| 174 | + newest: { createdAt: -1 }, |
| 175 | + oldest: { createdAt: 1 }, |
| 176 | + highest: { rating: -1, createdAt: -1 }, |
| 177 | + lowest: { rating: 1, createdAt: -1 }, |
| 178 | + }; |
| 179 | + |
| 180 | + const totalItems = await CourseReview.countDocuments({ courseId }); |
| 181 | + const totalPages = Math.max(1, Math.ceil(totalItems / limit)); |
| 182 | + const safePage = Math.min(page, totalPages); |
| 183 | + const currentUserId = req.user?._id?.toString() || null; |
| 184 | + |
| 185 | + const reviews = await CourseReview.find({ courseId }) |
| 186 | + .populate("userId", "name") |
| 187 | + .sort(sortMap[sort]) |
| 188 | + .skip((safePage - 1) * limit) |
| 189 | + .limit(limit) |
| 190 | + .lean(); |
| 191 | + |
| 192 | + return res.status(200).send({ |
| 193 | + success: true, |
| 194 | + data: reviews.map((review) => serializeReview(review, currentUserId)), |
| 195 | + summary: await getSummary(courseId), |
| 196 | + pagination: { |
| 197 | + page: safePage, |
| 198 | + limit, |
| 199 | + totalItems, |
| 200 | + totalPages, |
| 201 | + hasPreviousPage: safePage > 1, |
| 202 | + hasNextPage: safePage < totalPages, |
| 203 | + }, |
| 204 | + sort, |
| 205 | + }); |
| 206 | + } catch (error) { |
| 207 | + console.error("Unable to retrieve course reviews:", error); |
| 208 | + return res.status(500).send({ |
| 209 | + success: false, |
| 210 | + message: "Unable to retrieve course reviews.", |
| 211 | + }); |
| 212 | + } |
| 213 | +}; |
| 214 | + |
| 215 | +const getRatingSummary = async (req, res) => { |
| 216 | + try { |
| 217 | + const { courseId } = req.params; |
| 218 | + if (!validateCourseId(courseId)) { |
| 219 | + return res.status(400).send({ |
| 220 | + success: false, |
| 221 | + message: "Invalid course ID.", |
| 222 | + }); |
| 223 | + } |
| 224 | + |
| 225 | + return res.status(200).send({ |
| 226 | + success: true, |
| 227 | + data: await getSummary(courseId), |
| 228 | + }); |
| 229 | + } catch (error) { |
| 230 | + console.error("Unable to retrieve rating summary:", error); |
| 231 | + return res.status(500).send({ |
| 232 | + success: false, |
| 233 | + message: "Unable to retrieve rating summary.", |
| 234 | + }); |
| 235 | + } |
| 236 | +}; |
| 237 | + |
| 238 | +const updateReview = async (req, res) => { |
| 239 | + try { |
| 240 | + const { reviewId } = req.params; |
| 241 | + const userId = getAuthenticatedUserId(req); |
| 242 | + const rating = Number(req.body.rating); |
| 243 | + const reviewText = String(req.body.reviewText || "").trim(); |
| 244 | + |
| 245 | + if (!mongoose.Types.ObjectId.isValid(reviewId)) { |
| 246 | + return res.status(400).send({ |
| 247 | + success: false, |
| 248 | + message: "Invalid review ID.", |
| 249 | + }); |
| 250 | + } |
| 251 | + |
| 252 | + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { |
| 253 | + return res.status(400).send({ |
| 254 | + success: false, |
| 255 | + message: "Rating must be an integer from 1 to 5.", |
| 256 | + }); |
| 257 | + } |
| 258 | + |
| 259 | + if (reviewText.length > 1000) { |
| 260 | + return res.status(400).send({ |
| 261 | + success: false, |
| 262 | + message: "Review text cannot exceed 1000 characters.", |
| 263 | + }); |
| 264 | + } |
| 265 | + |
| 266 | + const review = await CourseReview.findOneAndUpdate( |
| 267 | + { _id: reviewId, userId }, |
| 268 | + { rating, reviewText }, |
| 269 | + { new: true, runValidators: true }, |
| 270 | + ).populate("userId", "name"); |
| 271 | + |
| 272 | + if (!review) { |
| 273 | + return res.status(404).send({ |
| 274 | + success: false, |
| 275 | + message: "Review not found or you do not own it.", |
| 276 | + }); |
| 277 | + } |
| 278 | + |
| 279 | + return res.status(200).send({ |
| 280 | + success: true, |
| 281 | + message: "Review updated successfully.", |
| 282 | + data: serializeReview(review, userId), |
| 283 | + summary: await getSummary(review.courseId.toString()), |
| 284 | + }); |
| 285 | + } catch (error) { |
| 286 | + console.error("Unable to update course review:", error); |
| 287 | + return res.status(500).send({ |
| 288 | + success: false, |
| 289 | + message: "Unable to update the review.", |
| 290 | + }); |
| 291 | + } |
| 292 | +}; |
| 293 | + |
| 294 | +const deleteReview = async (req, res) => { |
| 295 | + try { |
| 296 | + const { reviewId } = req.params; |
| 297 | + const userId = getAuthenticatedUserId(req); |
| 298 | + |
| 299 | + if (!mongoose.Types.ObjectId.isValid(reviewId)) { |
| 300 | + return res.status(400).send({ |
| 301 | + success: false, |
| 302 | + message: "Invalid review ID.", |
| 303 | + }); |
| 304 | + } |
| 305 | + |
| 306 | + const review = await CourseReview.findOneAndDelete({ |
| 307 | + _id: reviewId, |
| 308 | + userId, |
| 309 | + }); |
| 310 | + |
| 311 | + if (!review) { |
| 312 | + return res.status(404).send({ |
| 313 | + success: false, |
| 314 | + message: "Review not found or you do not own it.", |
| 315 | + }); |
| 316 | + } |
| 317 | + |
| 318 | + return res.status(200).send({ |
| 319 | + success: true, |
| 320 | + message: "Review deleted successfully.", |
| 321 | + summary: await getSummary(review.courseId.toString()), |
| 322 | + }); |
| 323 | + } catch (error) { |
| 324 | + console.error("Unable to delete course review:", error); |
| 325 | + return res.status(500).send({ |
| 326 | + success: false, |
| 327 | + message: "Unable to delete the review.", |
| 328 | + }); |
| 329 | + } |
| 330 | +}; |
| 331 | + |
| 332 | +const getMyReview = async (req, res) => { |
| 333 | + try { |
| 334 | + const { courseId } = req.params; |
| 335 | + const userId = getAuthenticatedUserId(req); |
| 336 | + |
| 337 | + if (!validateCourseId(courseId)) { |
| 338 | + return res.status(400).send({ |
| 339 | + success: false, |
| 340 | + message: "Invalid course ID.", |
| 341 | + }); |
| 342 | + } |
| 343 | + |
| 344 | + const [review, enrollment] = await Promise.all([ |
| 345 | + CourseReview.findOne({ courseId, userId }) |
| 346 | + .populate("userId", "name") |
| 347 | + .lean(), |
| 348 | + ensureEnrollment(userId, courseId), |
| 349 | + ]); |
| 350 | + |
| 351 | + return res.status(200).send({ |
| 352 | + success: true, |
| 353 | + data: review ? serializeReview(review, userId) : null, |
| 354 | + canReview: Boolean(enrollment), |
| 355 | + }); |
| 356 | + } catch (error) { |
| 357 | + console.error("Unable to retrieve current review:", error); |
| 358 | + return res.status(500).send({ |
| 359 | + success: false, |
| 360 | + message: "Unable to retrieve your review.", |
| 361 | + }); |
| 362 | + } |
| 363 | +}; |
| 364 | + |
| 365 | +module.exports = { |
| 366 | + createReview, |
| 367 | + listReviews, |
| 368 | + getRatingSummary, |
| 369 | + updateReview, |
| 370 | + deleteReview, |
| 371 | + getMyReview, |
| 372 | +}; |
0 commit comments