Skip to content

Commit 451a6b9

Browse files
authored
feat: add verified course ratings and reviews (#25)
- add a CourseReview schema with a unique user/course constraint - implement authenticated CRUD APIs for verified course reviews - restrict review submission to enrolled students only - prevent duplicate reviews for the same course - add rating summary with average, distribution, pagination, and sorting - expose endpoints to fetch reviews, summaries, and the current user's review - integrate review and rating UI into the course details page - display course rating badges in the course catalog - add reusable RatingStars, CourseReviews, and CourseRatingBadge components - include loading, empty, error, and eligibility states - add project documentation for review APIs, integration, and manual testing
1 parent 424e3be commit 451a6b9

11 files changed

Lines changed: 1356 additions & 4 deletions

File tree

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
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+
};

backend/index.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Serve admin dashboard at /api/admin/dashboard
1+
// Serve admin dashboard at /api/admin/dashboard
22
// (Moved below app initialization)
33

44
const express = require('express')
@@ -23,7 +23,7 @@ app.use(cors())
2323

2424

2525
const uploadsDir = path.join(__dirname, "uploads");
26-
// Create uploads folder if it doesn’t exist
26+
// Create uploads folder if it doesn’t exist
2727
if (!fs.existsSync(uploadsDir)) {
2828
fs.mkdirSync(uploadsDir);
2929
}
@@ -49,7 +49,8 @@ app.get('/api/admin', (req, res) => {
4949
///ROUTES///
5050
app.use('/api/admin', require('./routers/adminRoutes'))
5151
app.use('/api/user', require('./routers/userRoutes'))
52+
app.use('/api/reviews', require('./routers/courseReviewRoutes'))
5253

5354

5455

55-
app.listen(PORT, () => console.log(`running on ${PORT}`))
56+
app.listen(PORT, () => console.log(`running on ${PORT}`))

0 commit comments

Comments
 (0)