Skip to content

Commit d6eb25b

Browse files
feat: add persistent course bookmarks and wishlist system
feat: add persistent course bookmarks and wishlist system - implement persistent per-student course bookmarks using MongoDB - add protected bookmark APIs with duplicate prevention - support search, filtering, sorting, pagination, and bulk state checks - handle unavailable/deleted courses safely - introduce shared BookmarksProvider and reusable BookmarkButton - add Saved Courses page with optimistic UI updates and rollback support - synchronize bookmark state across catalog, course pages, and navigation - improve accessibility with keyboard support and responsive layouts - add documentation and manual validation for bookmark workflows Closes #19
2 parents c0c734f + b799d73 commit d6eb25b

14 files changed

Lines changed: 1791 additions & 1 deletion

File tree

Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
const mongoose = require("mongoose");
2+
const CourseBookmark = require("../schemas/courseBookmarkModel");
3+
const Course = require("../schemas/courseModel");
4+
5+
const ALLOWED_SORTS = new Set([
6+
"recent",
7+
"title-asc",
8+
"title-desc",
9+
"price-asc",
10+
"price-desc",
11+
]);
12+
13+
const parsePositiveInteger = (value, fallback, maximum) => {
14+
const parsed = Number.parseInt(value, 10);
15+
16+
if (!Number.isFinite(parsed) || parsed < 1) {
17+
return fallback;
18+
}
19+
20+
return Math.min(parsed, maximum);
21+
};
22+
23+
const getUserId = (req) =>
24+
req.user?._id?.toString() || req.body?.userId || null;
25+
26+
const isPaidCourse = (price) => /\d/.test(String(price || ""));
27+
28+
const parsePrice = (price) => {
29+
const parsed = Number.parseFloat(
30+
String(price || "").replace(/[^0-9.-]/g, ""),
31+
);
32+
33+
return Number.isFinite(parsed) ? parsed : 0;
34+
};
35+
36+
const serializeCourse = (course) => {
37+
if (!course) {
38+
return {
39+
id: null,
40+
title: "Course unavailable",
41+
category: "Unavailable",
42+
educator: "Unknown",
43+
description:
44+
"This saved course is no longer available in the catalog.",
45+
price: null,
46+
numericPrice: 0,
47+
accessType: "unavailable",
48+
availability: "deleted",
49+
enrolled: 0,
50+
};
51+
}
52+
53+
const paid = isPaidCourse(course.C_price);
54+
55+
return {
56+
id: course._id.toString(),
57+
title: course.C_title,
58+
category: course.C_categories,
59+
educator: course.C_educator,
60+
description: course.C_description,
61+
price: course.C_price || "Free",
62+
numericPrice: paid ? parsePrice(course.C_price) : 0,
63+
accessType: paid ? "paid" : "free",
64+
availability: "available",
65+
enrolled: course.enrolled || 0,
66+
createdAt: course.createdAt,
67+
updatedAt: course.updatedAt,
68+
};
69+
};
70+
71+
const addBookmark = async (req, res) => {
72+
try {
73+
const userId = getUserId(req);
74+
const { courseId } = req.params;
75+
76+
if (!userId || !mongoose.Types.ObjectId.isValid(courseId)) {
77+
return res.status(400).send({
78+
success: false,
79+
message: "A valid course and authenticated user are required.",
80+
});
81+
}
82+
83+
const course = await Course.findById(courseId)
84+
.select("_id C_title")
85+
.lean();
86+
87+
if (!course) {
88+
return res.status(404).send({
89+
success: false,
90+
message: "Course not found.",
91+
});
92+
}
93+
94+
const result = await CourseBookmark.findOneAndUpdate(
95+
{ userId, courseId },
96+
{ $setOnInsert: { userId, courseId } },
97+
{ upsert: true, new: true, rawResult: true },
98+
);
99+
100+
const created = Boolean(result?.lastErrorObject?.upserted);
101+
102+
return res.status(created ? 201 : 200).send({
103+
success: true,
104+
created,
105+
bookmarked: true,
106+
courseId,
107+
message: created
108+
? "Course saved successfully."
109+
: "Course was already saved.",
110+
});
111+
} catch (error) {
112+
if (error?.code === 11000) {
113+
return res.status(200).send({
114+
success: true,
115+
created: false,
116+
bookmarked: true,
117+
courseId: req.params.courseId,
118+
message: "Course was already saved.",
119+
});
120+
}
121+
122+
console.error("Unable to save course bookmark:", error);
123+
124+
return res.status(500).send({
125+
success: false,
126+
message: "Unable to save this course.",
127+
});
128+
}
129+
};
130+
131+
const removeBookmark = async (req, res) => {
132+
try {
133+
const userId = getUserId(req);
134+
const { courseId } = req.params;
135+
136+
if (!mongoose.Types.ObjectId.isValid(courseId)) {
137+
return res.status(400).send({
138+
success: false,
139+
message: "Invalid course ID.",
140+
});
141+
}
142+
143+
const result = await CourseBookmark.deleteOne({ userId, courseId });
144+
145+
return res.status(200).send({
146+
success: true,
147+
bookmarked: false,
148+
removed: result.deletedCount > 0,
149+
courseId,
150+
message:
151+
result.deletedCount > 0
152+
? "Course removed from saved courses."
153+
: "Course was not in your saved courses.",
154+
});
155+
} catch (error) {
156+
console.error("Unable to remove course bookmark:", error);
157+
158+
return res.status(500).send({
159+
success: false,
160+
message: "Unable to remove this saved course.",
161+
});
162+
}
163+
};
164+
165+
const getBookmarkStatus = async (req, res) => {
166+
try {
167+
const userId = getUserId(req);
168+
const rawIds = [
169+
...String(req.query.courseIds || "")
170+
.split(",")
171+
.map((id) => id.trim())
172+
.filter(Boolean),
173+
...(Array.isArray(req.query.courseId)
174+
? req.query.courseId
175+
: req.query.courseId
176+
? [req.query.courseId]
177+
: []),
178+
];
179+
180+
const courseIds = [...new Set(rawIds)].filter((id) =>
181+
mongoose.Types.ObjectId.isValid(id),
182+
);
183+
184+
if (courseIds.length > 100) {
185+
return res.status(400).send({
186+
success: false,
187+
message: "A maximum of 100 course IDs can be checked at once.",
188+
});
189+
}
190+
191+
const bookmarks = await CourseBookmark.find({
192+
userId,
193+
courseId: { $in: courseIds },
194+
})
195+
.select("courseId")
196+
.lean();
197+
198+
return res.status(200).send({
199+
success: true,
200+
data: bookmarks.map((bookmark) =>
201+
bookmark.courseId.toString(),
202+
),
203+
count: bookmarks.length,
204+
});
205+
} catch (error) {
206+
console.error("Unable to retrieve bookmark status:", error);
207+
208+
return res.status(500).send({
209+
success: false,
210+
message: "Unable to retrieve bookmark status.",
211+
});
212+
}
213+
};
214+
215+
const getSavedCourses = async (req, res) => {
216+
try {
217+
const userId = getUserId(req);
218+
const page = parsePositiveInteger(req.query.page, 1, 100000);
219+
const limit = parsePositiveInteger(req.query.limit, 12, 50);
220+
const category = String(req.query.category || "").trim();
221+
const access = String(req.query.access || "").trim().toLowerCase();
222+
const availability = String(
223+
req.query.availability || "",
224+
).trim().toLowerCase();
225+
const search = String(req.query.search || "")
226+
.trim()
227+
.toLowerCase()
228+
.slice(0, 120);
229+
const sort = String(req.query.sort || "recent").trim().toLowerCase();
230+
231+
if (access && !["free", "paid"].includes(access)) {
232+
return res.status(400).send({
233+
success: false,
234+
message: "Invalid access filter.",
235+
});
236+
}
237+
238+
if (
239+
availability &&
240+
!["available", "deleted"].includes(availability)
241+
) {
242+
return res.status(400).send({
243+
success: false,
244+
message: "Invalid availability filter.",
245+
});
246+
}
247+
248+
if (!ALLOWED_SORTS.has(sort)) {
249+
return res.status(400).send({
250+
success: false,
251+
message: "Invalid saved-course sort option.",
252+
});
253+
}
254+
255+
const bookmarkDocs = await CourseBookmark.find({ userId })
256+
.populate({
257+
path: "courseId",
258+
select:
259+
"C_title C_categories C_educator C_description C_price enrolled createdAt updatedAt",
260+
})
261+
.sort({ createdAt: -1 })
262+
.lean();
263+
264+
let items = bookmarkDocs.map((bookmark) => ({
265+
bookmarkId: bookmark._id.toString(),
266+
savedAt: bookmark.createdAt,
267+
course: serializeCourse(bookmark.courseId),
268+
}));
269+
270+
if (search) {
271+
items = items.filter(({ course }) =>
272+
[
273+
course.title,
274+
course.category,
275+
course.educator,
276+
course.description,
277+
].some((field) =>
278+
String(field || "").toLowerCase().includes(search),
279+
),
280+
);
281+
}
282+
283+
if (category) {
284+
items = items.filter(
285+
({ course }) =>
286+
String(course.category || "").toLowerCase() ===
287+
category.toLowerCase(),
288+
);
289+
}
290+
291+
if (access) {
292+
items = items.filter(
293+
({ course }) => course.accessType === access,
294+
);
295+
}
296+
297+
if (availability) {
298+
items = items.filter(
299+
({ course }) => course.availability === availability,
300+
);
301+
}
302+
303+
const sorters = {
304+
recent: (a, b) =>
305+
new Date(b.savedAt || 0) - new Date(a.savedAt || 0),
306+
"title-asc": (a, b) =>
307+
a.course.title.localeCompare(b.course.title),
308+
"title-desc": (a, b) =>
309+
b.course.title.localeCompare(a.course.title),
310+
"price-asc": (a, b) =>
311+
a.course.numericPrice - b.course.numericPrice,
312+
"price-desc": (a, b) =>
313+
b.course.numericPrice - a.course.numericPrice,
314+
};
315+
316+
items.sort(sorters[sort]);
317+
318+
const categories = [
319+
...new Set(
320+
bookmarkDocs
321+
.map((bookmark) => bookmark.courseId?.C_categories)
322+
.filter(Boolean),
323+
),
324+
].sort((a, b) => a.localeCompare(b));
325+
326+
const totalItems = items.length;
327+
const totalPages = Math.max(1, Math.ceil(totalItems / limit));
328+
const safePage = Math.min(page, totalPages);
329+
const start = (safePage - 1) * limit;
330+
331+
return res.status(200).send({
332+
success: true,
333+
data: items.slice(start, start + limit),
334+
categories,
335+
pagination: {
336+
page: safePage,
337+
limit,
338+
totalItems,
339+
totalPages,
340+
hasPreviousPage: safePage > 1,
341+
hasNextPage: safePage < totalPages,
342+
},
343+
filters: {
344+
search,
345+
category,
346+
access,
347+
availability,
348+
sort,
349+
},
350+
});
351+
} catch (error) {
352+
console.error("Unable to retrieve saved courses:", error);
353+
354+
return res.status(500).send({
355+
success: false,
356+
message: "Unable to retrieve saved courses.",
357+
});
358+
}
359+
};
360+
361+
const clearBookmarks = async (req, res) => {
362+
try {
363+
const userId = getUserId(req);
364+
const result = await CourseBookmark.deleteMany({ userId });
365+
366+
return res.status(200).send({
367+
success: true,
368+
removedCount: result.deletedCount,
369+
message: "Saved courses cleared successfully.",
370+
});
371+
} catch (error) {
372+
console.error("Unable to clear saved courses:", error);
373+
374+
return res.status(500).send({
375+
success: false,
376+
message: "Unable to clear saved courses.",
377+
});
378+
}
379+
};
380+
381+
module.exports = {
382+
addBookmark,
383+
removeBookmark,
384+
getBookmarkStatus,
385+
getSavedCourses,
386+
clearBookmarks,
387+
};

backend/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ 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/bookmarks', require('./routers/courseBookmarkRoutes'))
5253
app.use('/api/reviews', require('./routers/courseReviewRoutes'))
5354

5455

0 commit comments

Comments
 (0)