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