From 2a79c8b28d4639e3a5cdbbb071d28b9107c76c37 Mon Sep 17 00:00:00 2001 From: jhdaws Date: Thu, 4 Dec 2025 11:51:20 -0600 Subject: [PATCH 1/3] added waitlist logic, email notification logic, and notifications related to waitlists --- backend/controllers/courseController.ts | 105 ++++++++++++++++++++++++ backend/controllers/userController.ts | 61 ++++++++++++-- backend/models/courseModel.ts | 8 ++ backend/routes/courseRoutes.ts | 4 + backend/workers/emailWorker.ts | 68 ++++++++++++++- 5 files changed, 237 insertions(+), 9 deletions(-) diff --git a/backend/controllers/courseController.ts b/backend/controllers/courseController.ts index facb19f..cfa1954 100644 --- a/backend/controllers/courseController.ts +++ b/backend/controllers/courseController.ts @@ -3,6 +3,7 @@ import Course from "../models/courseModel"; import Rating from "../models/ratingModel"; import User, { IUser } from "../models/userModel"; import Progress, { IProgress } from "../models/progressModel"; +import { emailQueue } from "../jobs/emailQueue"; // Define an interface for error objects interface ErrorWithDetails { @@ -446,6 +447,110 @@ export const getCourseUsers = async ( } }; +// @desc Drop a user from a course and auto-enroll from waitlist +// @route POST /api/courses/:courseId/drop +// @access Public +export const dropCourseEnrollment = async ( + req: Request, + res: Response +): Promise => { + try { + const { courseId } = req.params; + const { userId } = req.body; + + if (!courseId || !userId) { + res.status(400).json({ + success: false, + message: "Course ID and User ID are required.", + }); + return; + } + + const course = await Course.findById(courseId); + if (!course) { + res.status(404).json({ + success: false, + message: "Course not found.", + }); + return; + } + + const beforeCount = course.students.length; + course.students = course.students.filter( + (id) => id.toString() !== userId.toString() + ); + + if (beforeCount === course.students.length) { + res.status(404).json({ + success: false, + message: "User not enrolled in this course.", + }); + return; + } + + await Progress.deleteMany({ course: courseId, user: userId }); + + let promotedUserId: string | null = null; + + const limit = course.registrationLimit || 0; + const hasCapacity = limit === 0 || course.students.length < limit; + + if (hasCapacity && course.waitlist && course.waitlist.length > 0) { + course.waitlist.sort( + (a, b) => + new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime() + ); + const nextInLine = course.waitlist.shift(); + if (nextInLine) { + const promotedId = nextInLine.user.toString(); + course.students.push(nextInLine.user as any); + promotedUserId = promotedId; + + const existingProgress = await Progress.findOne({ + course: courseId, + user: promotedId, + }); + if (!existingProgress) { + await new Progress({ + course: courseId, + user: promotedId, + isComplete: false, + completedComponents: { + webinar: false, + survey: false, + certificate: false, + }, + dateCompleted: null, + }).save(); + } + + await emailQueue.add("registration-confirmation", { + userId: promotedId, + courseId, + }); + await emailQueue.add("waitlist-promotion", { + userId: promotedId, + courseId, + }); + } + } + + await course.save(); + + res.status(200).json({ + success: true, + message: "User dropped from course.", + promotedUserId, + }); + } catch (error: any) { + console.error("Error dropping course enrollment:", error); + res.status(500).json({ + success: false, + message: error.message || "Internal server error.", + }); + } +}; + // @desc Get progress for all users in a course // @route GET /api/courses/:courseId/progress // @access Public diff --git a/backend/controllers/userController.ts b/backend/controllers/userController.ts index 09663c9..bfb91b2 100644 --- a/backend/controllers/userController.ts +++ b/backend/controllers/userController.ts @@ -5,6 +5,7 @@ import Payment from "../models/paymentModel"; import Course from "../models/courseModel"; import mongoose from "mongoose"; import { AuthenticatedRequest } from "../middlewares/authMiddleware"; +import { emailQueue } from "../jobs/emailQueue"; export const getUsers = async (req: Request, res: Response): Promise => { try { @@ -232,8 +233,45 @@ export const register = async (req: Request, res: Response): Promise => { throw new Error(`Course with ID ${courseId} not found.`); } + const studentId = new mongoose.Types.ObjectId(userId); + const alreadyEnrolled = course.students.some( + (id) => id.toString() === userId.toString() + ); + if (alreadyEnrolled) { + return { + courseId, + status: "already-enrolled", + }; + } + + const limit = course.registrationLimit || 0; + const isFull = limit > 0 && course.students.length >= limit; + const existingWaitlistEntry = course.waitlist?.find( + (entry) => entry.user.toString() === userId.toString() + ); + + if (isFull) { + if (!existingWaitlistEntry) { + course.waitlist = course.waitlist || []; + course.waitlist.push({ user: studentId, joinedAt: new Date() }); + course.waitlist.sort( + (a, b) => + new Date(a.joinedAt).getTime() - new Date(b.joinedAt).getTime() + ); + await course.save(); + await emailQueue.add("waitlist-confirmation", { + userId: userId.toString(), + courseId: courseId.toString(), + }); + } + return { + courseId, + status: "waitlisted", + }; + } + const progress = new Progress({ - user: new mongoose.Types.ObjectId(userId), + user: studentId, course: new mongoose.Types.ObjectId(courseId), isComplete: false, completedComponents: { @@ -245,22 +283,29 @@ export const register = async (req: Request, res: Response): Promise => { }); await progress.save(); - console.log("progress", progress); - - if (!course.students.includes(new mongoose.Types.ObjectId(userId))) { - course.students.push(new mongoose.Types.ObjectId(userId)); + if (!course.students.some((id) => id.toString() === userId.toString())) { + course.students.push(studentId); await course.save(); } - return progress; + await emailQueue.add("registration-confirmation", { + userId: userId.toString(), + courseId: courseId.toString(), + }); + + return { + courseId, + status: "enrolled", + progressId: progress._id, + }; }); const progressResults = await Promise.all(progressPromises); res.status(201).json({ success: true, - message: "User registered to courses successfully.", - progress: progressResults, + message: "User processed for course registration.", + results: progressResults, }); } catch (error) { console.error(error); diff --git a/backend/models/courseModel.ts b/backend/models/courseModel.ts index e5f032b..2e6a92f 100644 --- a/backend/models/courseModel.ts +++ b/backend/models/courseModel.ts @@ -34,6 +34,7 @@ export interface ICourse extends Document { shortUrl: string; draft: boolean; registrationLimit: number; + waitlist: { user: mongoose.Types.ObjectId | IUser; joinedAt: Date }[]; } const CourseSchema: Schema = new Schema( @@ -98,6 +99,13 @@ const CourseSchema: Schema = new Schema( shortUrl: { type: String, required: false }, draft: { type: Boolean, required: true, default: true }, registrationLimit: { type: Number, required: false, default: 0 }, + waitlist: [ + { + user: { type: Schema.Types.ObjectId, ref: "User", required: true }, + joinedAt: { type: Date, required: true, default: Date.now }, + _id: false, + }, + ], }, { timestamps: true, diff --git a/backend/routes/courseRoutes.ts b/backend/routes/courseRoutes.ts index d778342..d4e4950 100644 --- a/backend/routes/courseRoutes.ts +++ b/backend/routes/courseRoutes.ts @@ -10,6 +10,7 @@ import { updateUserProgress, batchUpdateUserProgress, getUserCourseProgress, + dropCourseEnrollment, } from "../controllers/courseController"; const router = express.Router(); @@ -32,6 +33,9 @@ router.delete("/:id", deleteCourse); // GET all users enrolled in a course router.get("/:courseId/users", getCourseUsers); +// Drop a user and auto-enroll from waitlist if available +router.post("/:courseId/drop", dropCourseEnrollment); + // GET progress for all users in a course router.get("/:courseId/progress", getCourseProgress); diff --git a/backend/workers/emailWorker.ts b/backend/workers/emailWorker.ts index c1fe1b0..da6c56d 100644 --- a/backend/workers/emailWorker.ts +++ b/backend/workers/emailWorker.ts @@ -5,7 +5,7 @@ import mongoose from "mongoose"; import Email from "../models/emailModel"; import User from "../models/userModel"; import { sendEmail } from "../config/resend"; -import { ICourse } from "../models/courseModel"; +import Course, { ICourse } from "../models/courseModel"; import connectDB from "../config/db"; import "../models/courseModel"; import { emailQueue } from "../jobs/emailQueue"; @@ -54,6 +54,72 @@ const recoverMissedEmails = async () => { try { console.log("📦 Worker picked up job:", job.id, job.data); + if (job.name === "waitlist-confirmation") { + const { userId, courseId } = job.data; + const [user, course] = await Promise.all([ + User.findById(userId), + Course.findById(courseId), + ]); + if (user && course) { + await sendEmail( + user.email, + `You are waitlisted for ${course.className}`, + `

Hi {{name}},

You have been added to the waitlist for {{course}}. We'll email you if a seat opens.

`, + { name: user.name, course: course.className } + ); + } + return; + } + + if (job.name === "waitlist-promotion") { + const { userId, courseId } = job.data; + const [user, course] = await Promise.all([ + User.findById(userId), + Course.findById(courseId), + ]); + if (user && course) { + await sendEmail( + user.email, + `You're in! ${course.className}`, + `

Hi {{name}},

A seat opened up and you are now enrolled in {{course}}.

`, + { name: user.name, course: course.className } + ); + } + return; + } + + if (job.name === "registration-confirmation") { + const { userId, courseId } = job.data; + const [user, course] = await Promise.all([ + User.findById(userId), + Course.findById(courseId), + ]); + if (user && course) { + await sendEmail( + user.email, + `Registered for ${course.className}`, + `

Hi {{name}},

You are registered for {{course}}.

`, + { name: user.name, course: course.className } + ); + } + return; + } + + if (job.name === "course-reminder") { + console.log("course-reminder "); + return; + } + + if (job.name === "speaker-assignment") { + console.log("speaker-assignment"); + return; + } + + if (job.name !== "send-course-email") { + console.log(`No handler for job type '${job.name}', skipping.`); + return; + } + const email = await Email.findById(job.data.emailId).populate("course"); if (!email) { console.warn("⚠️ Email not found for ID:", job.data.emailId); From 6940c4b169704dfe4bef60a603a37ab3ec79a1cf Mon Sep 17 00:00:00 2001 From: Yifei Fang Date: Tue, 17 Feb 2026 18:16:50 +0100 Subject: [PATCH 2/3] added button on frontend that allows waitlist registration and prevent double waitlist registration --- .../pages/Catalog/CatalogCourseComponent.tsx | 163 ++++++++++++++++-- frontend/src/services/registrationServices.ts | 38 ++++ frontend/src/shared/types/course.tsx | 4 + 3 files changed, 186 insertions(+), 19 deletions(-) diff --git a/frontend/src/pages/Catalog/CatalogCourseComponent.tsx b/frontend/src/pages/Catalog/CatalogCourseComponent.tsx index baee164..69a0673 100644 --- a/frontend/src/pages/Catalog/CatalogCourseComponent.tsx +++ b/frontend/src/pages/Catalog/CatalogCourseComponent.tsx @@ -1,7 +1,16 @@ -import React, { Dispatch, SetStateAction } from "react"; +import React, { + Dispatch, + SetStateAction, + useEffect, + useMemo, + useState, +} from "react"; import { Course } from "../../shared/types/course"; import { Link } from "react-router-dom"; -import { addToCart } from "../../services/registrationServices"; +import { + addToCart, + registerForCourse, +} from "../../services/registrationServices"; interface CatalogCourseComponentProps { course: Course; @@ -10,12 +19,66 @@ interface CatalogCourseComponentProps { isLoggedIn: boolean; } +type RegistrationStatus = + | "none" + | "waitlisted" + | "enrolled" + | "already-enrolled"; + export default function CatalogCourseComponent({ course, setCartItemCount, isInCart, isLoggedIn, }: CatalogCourseComponentProps) { + const storedUser = useMemo(() => { + if (!isLoggedIn) { + return null; + } + + try { + const rawUser = localStorage.getItem("user"); + return rawUser ? JSON.parse(rawUser) : null; + } catch (error) { + console.error("Failed to parse stored user:", error); + return null; + } + }, [isLoggedIn]); + + const userId: string | undefined = storedUser?._id; + + const defaultStatus = useMemo(() => { + if (!userId) { + return "none"; + } + + if (course.students.some((id) => id === userId)) { + return "enrolled"; + } + + const waitlistEntries = course.waitlist ?? []; + const onWaitlist = waitlistEntries.some((entry) => { + const entryUser = entry.user as string | { _id?: string } | undefined; + const entryId = + typeof entryUser === "string" ? entryUser : entryUser?._id; + return entryId === userId; + }); + + return onWaitlist ? "waitlisted" : "none"; + }, [course.students, course.waitlist, userId]); + + const [registrationStatus, setRegistrationStatus] = + useState(defaultStatus); + + useEffect(() => { + setRegistrationStatus(defaultStatus); + }, [defaultStatus]); + + const isRegistrationLocked = + registrationStatus === "waitlisted" || + registrationStatus === "enrolled" || + registrationStatus === "already-enrolled"; + const renderStars = (rating: number) => { const fullStars = Math.floor(rating); const hasHalfStar = rating % 1 !== 0; @@ -39,6 +102,20 @@ export default function CatalogCourseComponent({ }; async function handleRegister(course: Course) { + if (isAtCapacity) { + try { + const data = await registerForCourse(course._id); + const courseResult = data.results.find( + (result) => result.courseId === course._id + ); + + setRegistrationStatus(courseResult?.status ?? "waitlisted"); + } catch (error) { + console.error(error); + } + return; + } + await addToCart(course).then(() => { setCartItemCount( localStorage.user ? JSON.parse(localStorage.user).cart.length : 0 @@ -46,11 +123,62 @@ export default function CatalogCourseComponent({ }); } - // Check if course is at registration capacity const isAtCapacity = course.registrationLimit > 0 && course.students.length >= course.registrationLimit; - const isDisabled = isInCart || isAtCapacity; + const isDisabled = isInCart || isRegistrationLocked; + + const statusMessage = useMemo(() => { + if (registrationStatus === "waitlisted") { + return "You're on the waitlist for this course."; + } + + if ( + registrationStatus === "enrolled" || + registrationStatus === "already-enrolled" + ) { + return "You're registered for this course."; + } + + return null; + }, [registrationStatus]); + + const primaryButtonLabel = useMemo(() => { + if (isInCart) { + return "Already in Cart"; + } + + if (registrationStatus === "waitlisted") { + return "On Waitlist"; + } + + if ( + registrationStatus === "enrolled" || + registrationStatus === "already-enrolled" + ) { + return "Registered"; + } + + if (isAtCapacity) { + return "Join Waitlist"; + } + + const cost = storedUser?.role?.cost; + + if (cost === 0) { + return "Register (Free)"; + } + + if (typeof cost === "number") { + const formattedCost = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(cost); + return `Register (${formattedCost})`; + } + + return "Register (Free)"; + }, [isAtCapacity, isInCart, registrationStatus, storedUser]); return (
@@ -109,21 +237,7 @@ export default function CatalogCourseComponent({ : "bg-orange-500 hover:bg-orange-600" }`} > - {isInCart - ? "Already in Cart" - : isAtCapacity - ? "Course Full" - : `Register (${ - localStorage.user && - JSON.parse(localStorage.user).role?.cost === 0 - ? "Free" - : localStorage.user - ? `${new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(JSON.parse(localStorage.user).role.cost)}` - : "Free" - })`} + {primaryButtonLabel} ) : ( @@ -138,6 +252,17 @@ export default function CatalogCourseComponent({
+ {statusMessage && ( +

+ {statusMessage} +

+ )}
{ + const user = localStorage.user ? JSON.parse(localStorage.user) : null; + + if (user === null) { + throw new Error("User is not logged in."); + } + + try { + const response = await apiClient.post(`/users/register`, { + userId: user._id, + courseIds: [courseId], + }); + + return response.data as RegistrationResponse; + } catch (error) { + console.error( + "Error registering user for course:", + // @ts-ignore + error.response?.data || error.message + ); + throw error; + } +} + export async function insertCoursesIndividually() { try { for (const course of dummyCourses) { diff --git a/frontend/src/shared/types/course.tsx b/frontend/src/shared/types/course.tsx index e1d7458..34e44cd 100644 --- a/frontend/src/shared/types/course.tsx +++ b/frontend/src/shared/types/course.tsx @@ -23,6 +23,10 @@ export type Course = { speakers: string[]; regStart: Date; regEnd: Date; + waitlist?: { + user: string | User; + joinedAt: string; + }[]; productType: | "Virtual Training - Live Meeting" | "In-Person Training" From 926ab4b9b6b819adc57e3754d9939a83620feea1 Mon Sep 17 00:00:00 2001 From: Yifei Fang Date: Tue, 17 Feb 2026 18:38:07 +0100 Subject: [PATCH 3/3] added leave course button that calls existing endpoint that shifts waitlist --- .../courseDetailPage/courseDetailsPage.tsx | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx b/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx index d1ac0f1..35aa028 100644 --- a/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx +++ b/frontend/src/pages/courseDetailPage/courseDetailsPage.tsx @@ -63,6 +63,13 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => { const [surveyCompleted, setSurveyCompleted] = useState(false); const [certificateCompleted, setCertificateCompleted] = useState(false); + const [isDroppingCourse, setIsDroppingCourse] = useState(false); + const [dropStatusMessage, setDropStatusMessage] = useState( + null + ); + const [dropStatusType, setDropStatusType] = useState<"success" | "error" | "idle">( + "idle" + ); useEffect(() => { const fetchProgress = async () => { @@ -162,6 +169,37 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => { }); }; + const handleDropCourse = async () => { + if (!courseId || !user?._id) { + setDropStatusType("error"); + setDropStatusMessage("Missing course or user information."); + return; + } + + setIsDroppingCourse(true); + setDropStatusMessage(null); + setDropStatusType("idle"); + + try { + await apiClient.post(`courses/${courseId}/drop`, { + userId: user._id, + }); + setDropStatusType("success"); + setDropStatusMessage("You have left this course."); + setHasProgress(false); + setProgress(null); + setWorkshopCompleted(false); + setSurveyCompleted(false); + setCertificateCompleted(false); + } catch (error) { + console.error("Error dropping course enrollment:", error); + setDropStatusType("error"); + setDropStatusMessage("Unable to drop the course. Please try again."); + } finally { + setIsDroppingCourse(false); + } + }; + return (
@@ -389,6 +427,32 @@ const CoursePage = ({ setCartItemCount }: CatalogProps) => {
+ {hasProgress && ( +
+ + {dropStatusMessage && ( +

+ {dropStatusMessage} +

+ )} +
+ )} ); };