From 472831ee8f9d3166a5b630537625dcc28434b418 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 00:27:51 +0200 Subject: [PATCH 01/19] Add endpoint to get enrolled and unenrolled courses --- routes/courses.js | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index ec17ff0..e52fca4 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -715,4 +715,59 @@ router.put("/update-module", upload.single("file"), async (req, res) => { client.release(); } }); + +router.get("/all-user-courses", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organisation context missing" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1) Courses the user is enrolled in + const enrolledRes = await client.query( + `SELECT c.id, c.name, c.description + FROM courses c + JOIN enrollments e ON e.course_id = c.id + WHERE e.user_id = $1`, + [userId] + ); + + // 2) All other courses in the same org that they're NOT enrolled in + const otherRes = await client.query( + `SELECT c.id, c.name, c.description + FROM courses c + WHERE c.organisation_id = $1 + AND c.id NOT IN ( + SELECT course_id FROM enrollments WHERE user_id = $2 + )`, + [organisationId, userId] + ); + + await client.query("COMMIT"); + return res.status(200).json({ + enrolled: enrolledRes.rows, + other: otherRes.rows, + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error in all-user-courses:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); module.exports = router; From cbe6d82f9420dde7b17d7860eadfae9d14fddc63 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 00:32:57 +0200 Subject: [PATCH 02/19] Add endpoint to enroll user --- routes/courses.js | 48 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index e52fca4..6b0cbc8 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -770,4 +770,52 @@ router.get("/all-user-courses", async (req, res) => { client.release(); } }); + +router.post("/enroll-course", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const { courseId } = req.body; + if (!courseId) { + return res.status(400).json({ message: "courseId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const insertRes = await client.query( + `INSERT INTO enrollments (user_id, course_id) + VALUES ($1, $2) + RETURNING id, status, started_at`, + [userId, courseId] + ); + + await client.query("COMMIT"); + return res.status(201).json({ + success: true, + enrollment: insertRes.rows[0], + }); + } catch (err) { + await client.query("ROLLBACK"); + // if the user is already enrolled, unique constraint violation + if (err.code === "23505") { + return res + .status(400) + .json({ message: "Already enrolled in this course" }); + } + console.error("Error enrolling user:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); module.exports = router; From ff06bd27af92eff8faa2c95e980f95b47a66ae88 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 00:35:44 +0200 Subject: [PATCH 03/19] Add unenrolled endpoint --- routes/courses.js | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index 6b0cbc8..313b730 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -818,4 +818,49 @@ router.post("/enroll-course", async (req, res) => { client.release(); } }); + +router.post("/unenroll-course", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const { courseId } = req.body; + if (!courseId) { + return res.status(400).json({ message: "courseId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const delRes = await client.query( + `DELETE FROM enrollments + WHERE user_id = $1 + AND course_id = $2 + RETURNING id`, + [userId, courseId] + ); + + if (!delRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Not enrolled in this course" }); + } + + await client.query("COMMIT"); + return res.status(200).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error unenrolling user:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); module.exports = router; From 1b9c288ea55c520eed09bf624361ded0147e90a4 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 00:46:40 +0200 Subject: [PATCH 04/19] Add endpoint to check for enrollment --- routes/courses.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index 313b730..23913a8 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -863,4 +863,46 @@ router.post("/unenroll-course", async (req, res) => { client.release(); } }); + +router.post("/is-enrolled", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const { courseId } = req.body; + if (!courseId) { + return res.status(400).json({ message: "courseId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const enrollRes = await client.query( + `SELECT 1 FROM enrollments + WHERE user_id = $1 + AND course_id = $2 + LIMIT 1`, + [userId, courseId] + ); + + await client.query("COMMIT"); + return res.status(200).json({ + enrolled: enrollRes.rows.length > 0, + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error checking enrollment:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); module.exports = router; From b7ef3e41e56bb07affd713c62cd2821594e7cc24 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 01:04:17 +0200 Subject: [PATCH 05/19] send id for q and opt to ensure unique key prop --- routes/courses.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/routes/courses.js b/routes/courses.js index 23913a8..7f896d8 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -499,7 +499,7 @@ router.post("/get-module", async (req, res) => { let optionsRes = { rows: [] }; if (questionIds.length) { optionsRes = await client.query( - `SELECT question_id, option_text, is_correct + `SELECT id, question_id, option_text, is_correct FROM question_options WHERE question_id = ANY($1)`, [questionIds] @@ -507,11 +507,13 @@ router.post("/get-module", async (req, res) => { } const questions = questionsRes.rows.map((q) => ({ + id: q.id, question_text: q.question_text, question_type: q.question_type, options: optionsRes.rows .filter((opt) => opt.question_id === q.id) .map((opt) => ({ + id: opt.id, option_text: opt.option_text, is_correct: opt.is_correct, })), From ddf771739f1bcf530ff2670a51259ebabd257360 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 01:09:50 +0200 Subject: [PATCH 06/19] Admin is considered enrolled in check enrollment --- routes/courses.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index 7f896d8..9995d5f 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -878,6 +878,14 @@ router.post("/is-enrolled", async (req, res) => { } const userId = session.userId; + const isOrganisationAdmin = session.organisation?.role === "admin"; + + if (isOrganisationAdmin) { + return res.status(200).json({ + enrolled: true, + }); + } + const { courseId } = req.body; if (!courseId) { return res.status(400).json({ message: "courseId is required" }); From 747084616397884ca4b2ec0624d06e4d8a6400fa Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 01:59:03 +0200 Subject: [PATCH 07/19] Store quiz response --- database/schema.sql | 2 +- routes/courses.js | 61 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/database/schema.sql b/database/schema.sql index a919bd5..c472877 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -159,7 +159,7 @@ CREATE TABLE quiz_answers ( selected_option_id INTEGER REFERENCES question_options(id) ON DELETE SET NULL, answer_text TEXT, - UNIQUE(response_id, question_id) + -- UNIQUE(response_id, question_id) ); diff --git a/routes/courses.js b/routes/courses.js index 9995d5f..5ac33fa 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -520,6 +520,7 @@ router.post("/get-module", async (req, res) => { })); module.quiz = { + id: quiz.id, quiz_type: quiz.quiz_type, questions, }; @@ -915,4 +916,64 @@ router.post("/is-enrolled", async (req, res) => { client.release(); } }); + +router.post("/submit-quiz", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + const userId = session.userId; + + const { quizId, answers } = req.body; + if (!quizId || !Array.isArray(answers)) { + return res + .status(400) + .json({ message: "quizId and answers array are required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const respRes = await client.query( + `INSERT INTO quiz_responses (user_id, quiz_id) + VALUES ($1, $2) + RETURNING id`, + [userId, quizId] + ); + const responseId = respRes.rows[0].id; + + for (const ans of answers) { + const { questionId, selectedOptionIds } = ans; + if (!questionId || !Array.isArray(selectedOptionIds)) { + throw new Error( + "Each answer must have questionId and selectedOptionIds[]" + ); + } + for (const optId of selectedOptionIds) { + await client.query( + `INSERT INTO quiz_answers + (response_id, question_id, selected_option_id) + VALUES ($1, $2, $3)`, + [responseId, questionId, optId] + ); + } + } + + await client.query("COMMIT"); + return res.status(201).json({ success: true, responseId }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error submitting quiz:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + module.exports = router; From 0c1687b3f4231623272bafabeeb72e52c77e2db9 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 03:00:47 +0200 Subject: [PATCH 08/19] Submit and get quiz results endpoint --- routes/courses.js | 226 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 199 insertions(+), 27 deletions(-) diff --git a/routes/courses.js b/routes/courses.js index 5ac33fa..601b306 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -917,10 +917,161 @@ router.post("/is-enrolled", async (req, res) => { } }); +// helper +async function submitQuizResponse(client, userId, quizId, answers) { + const respRes = await client.query( + `INSERT INTO quiz_responses (user_id, quiz_id) + VALUES ($1, $2) + RETURNING id`, + [userId, quizId] + ); + const responseId = respRes.rows[0].id; + + for (const ans of answers) { + const { questionId, selectedOptionIds } = ans; + if (!questionId || !Array.isArray(selectedOptionIds)) { + throw new Error( + "Each answer must have questionId and selectedOptionIds[]" + ); + } + for (const optId of selectedOptionIds) { + await client.query( + `INSERT INTO quiz_answers + (response_id, question_id, selected_option_id) + VALUES ($1, $2, $3)`, + [responseId, questionId, optId] + ); + } + } + + return responseId; +} + +// helper +async function gradeQuizResponse(client, responseId) { + const userAnsRes = await client.query( + `SELECT question_id, + ARRAY_AGG(selected_option_id) AS selected_option_ids + FROM quiz_answers + WHERE response_id = $1 + GROUP BY question_id`, + [responseId] + ); + const userAnswers = userAnsRes.rows; + const questionIds = userAnswers.map((r) => r.question_id); + + const correctMap = {}; + if (questionIds.length) { + const correctRes = await client.query( + `SELECT question_id, + ARRAY_AGG(id) AS correct_option_ids + FROM question_options + WHERE question_id = ANY($1) AND is_correct = TRUE + GROUP BY question_id`, + [questionIds] + ); + for (const row of correctRes.rows) { + correctMap[row.question_id] = row.correct_option_ids; + } + } + + const optionTextMap = {}; + if (questionIds.length) { + const optsRes = await client.query( + `SELECT id, option_text + FROM question_options + WHERE question_id = ANY($1)`, + [questionIds] + ); + for (const { id, option_text } of optsRes.rows) { + optionTextMap[id] = option_text; + } + } + + return userAnswers.map(({ question_id, selected_option_ids }) => { + const correctIds = correctMap[question_id] || []; + const selectedIds = selected_option_ids || []; + + const correctOptions = correctIds.map((id) => ({ + id, + text: optionTextMap[id] || "", + })); + const selectedOptions = selectedIds.map((id) => ({ + id, + text: optionTextMap[id] || "", + })); + + const isCorrect = + correctIds.length === selectedIds.length && + correctIds.every((id) => selectedIds.includes(id)); + + return { + questionId: question_id, + correctOptions, + selectedOptions, + isCorrect, + }; + }); +} + router.post("/submit-quiz", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + const { quizId, answers } = req.body; + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const responseId = await submitQuizResponse( + client, + userId, + quizId, + answers + ); + await client.query("COMMIT"); + return res.status(201).json({ success: true, responseId }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); +router.post("/grade-quiz", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + const { responseId } = req.body; + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const results = await gradeQuizResponse(client, responseId); + await client.query("COMMIT"); + return res.status(200).json({ results }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/submit-and-grade-quiz", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); let session; try { session = JSON.parse(auth); @@ -931,45 +1082,66 @@ router.post("/submit-quiz", async (req, res) => { const { quizId, answers } = req.body; if (!quizId || !Array.isArray(answers)) { - return res - .status(400) - .json({ message: "quizId and answers array are required" }); + return res.status(400).json({ message: "quizId and answers[] required" }); } const client = await pool.connect(); try { await client.query("BEGIN"); + const responseId = await submitQuizResponse( + client, + userId, + quizId, + answers + ); + const results = await gradeQuizResponse(client, responseId); + await client.query("COMMIT"); + return res.status(200).json({ responseId, results }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error in submit-and-grade-quiz:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); +router.post("/get-latest-quiz-response", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + const userId = session.userId; + + const { quizId } = req.body; + if (!quizId) { + return res.status(400).json({ message: "quizId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); const respRes = await client.query( - `INSERT INTO quiz_responses (user_id, quiz_id) - VALUES ($1, $2) - RETURNING id`, + `SELECT id + FROM quiz_responses + WHERE user_id = $1 + AND quiz_id = $2 + ORDER BY submitted_at DESC + LIMIT 1`, [userId, quizId] ); - const responseId = respRes.rows[0].id; - - for (const ans of answers) { - const { questionId, selectedOptionIds } = ans; - if (!questionId || !Array.isArray(selectedOptionIds)) { - throw new Error( - "Each answer must have questionId and selectedOptionIds[]" - ); - } - for (const optId of selectedOptionIds) { - await client.query( - `INSERT INTO quiz_answers - (response_id, question_id, selected_option_id) - VALUES ($1, $2, $3)`, - [responseId, questionId, optId] - ); - } - } - await client.query("COMMIT"); - return res.status(201).json({ success: true, responseId }); + if (!respRes.rows.length) { + return res.status(200).json({ responseId: null }); + } + return res.status(200).json({ responseId: respRes.rows[0].id }); } catch (err) { await client.query("ROLLBACK"); - console.error("Error submitting quiz:", err); + console.error("Error fetching latest quiz response:", err); return res.status(500).json({ message: "Server error" }); } finally { client.release(); From 26834237942ec50db3d07a80fd53e31755228f11 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Tue, 24 Jun 2025 03:16:45 +0200 Subject: [PATCH 09/19] Add endpoint to get the student latest results --- routes/courses.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/routes/courses.js b/routes/courses.js index 601b306..607c4b9 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -1125,6 +1125,7 @@ router.post("/get-latest-quiz-response", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); + const respRes = await client.query( `SELECT id FROM quiz_responses @@ -1134,14 +1135,20 @@ router.post("/get-latest-quiz-response", async (req, res) => { LIMIT 1`, [userId, quizId] ); - await client.query("COMMIT"); if (!respRes.rows.length) { - return res.status(200).json({ responseId: null }); + await client.query("COMMIT"); + return res.status(200).json({ responseId: null, results: null }); } - return res.status(200).json({ responseId: respRes.rows[0].id }); + + const responseId = respRes.rows[0].id; + + const results = await gradeQuizResponse(client, responseId); + + await client.query("COMMIT"); + return res.status(200).json({ responseId, results }); } catch (err) { await client.query("ROLLBACK"); - console.error("Error fetching latest quiz response:", err); + console.error("Error fetching & grading quiz:", err); return res.status(500).json({ message: "Server error" }); } finally { client.release(); From fd0fbacd7b1cdfed913a82dcf032fd90944812d0 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 25 Jun 2025 00:10:00 +0200 Subject: [PATCH 10/19] Add new table for module status --- database/schema.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/database/schema.sql b/database/schema.sql index c472877..a4efa45 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -61,6 +61,18 @@ CREATE TABLE enrollments ( UNIQUE(user_id, course_id) ); +CREATE TABLE MODULE_STATUS ( + id SERIAL PRIMARY KEY, + enrollment_id INTEGER NOT NULL + REFERENCES enrollments(id) ON DELETE CASCADE, + module_id INTEGER NOT NULL + REFERENCES modules(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'not_started', -- 'not_started', 'in_progress', 'completed' + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + UNIQUE(enrollment_id, module_id) +); + -- 5. MODULES & REVISIONS CREATE TABLE modules ( From 54887b14d4fe80f05110936873020541fe031395 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 25 Jun 2025 00:10:16 +0200 Subject: [PATCH 11/19] Modify table name --- database/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/schema.sql b/database/schema.sql index a4efa45..03e9687 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -61,7 +61,7 @@ CREATE TABLE enrollments ( UNIQUE(user_id, course_id) ); -CREATE TABLE MODULE_STATUS ( +CREATE TABLE module_status ( id SERIAL PRIMARY KEY, enrollment_id INTEGER NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, From f5650689b24a12b52033d5b9466743275b97bbaf Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 25 Jun 2025 22:37:08 +0200 Subject: [PATCH 12/19] Add endpoint to check for module status --- routes/courses.js | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index 607c4b9..2c7aa47 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -802,6 +802,25 @@ router.post("/enroll-course", async (req, res) => { [userId, courseId] ); + const enrollmentId = insertRes.rows[0].id; + + const modulesRes = await client.query( + `SELECT id + FROM modules + WHERE course_id = $1`, + [courseId] + ); + + for (const { id: moduleId } of modulesRes.rows) { + await client.query( + `INSERT INTO module_status + (enrollment_id, module_id, status) + VALUES ($1, $2, 'not_started') + ON CONFLICT (enrollment_id, module_id) DO NOTHING`, + [enrollmentId, moduleId] + ); + } + await client.query("COMMIT"); return res.status(201).json({ success: true, @@ -856,6 +875,14 @@ router.post("/unenroll-course", async (req, res) => { return res.status(404).json({ message: "Not enrolled in this course" }); } + const enrollmentId = delRes.rows[0].id; + + await client.query( + `DELETE FROM module_status + WHERE enrollment_id = $1`, + [enrollmentId] + ); + await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) { @@ -1155,4 +1182,59 @@ router.post("/get-latest-quiz-response", async (req, res) => { } }); +router.post("/get-module-status", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const moduleId = req.body.moduleId; + if (!moduleId) { + return res.status(400).json({ message: "moduleId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const courseIdRes = await client.query( + `SELECT course_id FROM modules WHERE id = $1`, + [moduleId] + ); + + const courseId = courseIdRes.rows[0]?.course_id; + + const enrolmentRes = await client.query( + `SELECT id FROM enrollments + WHERE user_id = $1 AND course_id = $2`, + [userId, courseId] + ); + + const enrollmentId = enrolmentRes.rows[0]?.id; + + const statusRes = await client.query( + `SELECT status FROM module_status + WHERE enrollment_id = $1 AND module_id = $2`, + [enrollmentId, moduleId] + ); + + await client.query("COMMIT"); + return res.status(200).json({ + status: statusRes.rows.length ? statusRes.rows[0].status : "not_started", + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + module.exports = router; From 0c6badb41f44bfe69c4f0aec9668822c72934fbc Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 25 Jun 2025 23:03:38 +0200 Subject: [PATCH 13/19] Add endpoints to update module status --- routes/courses.js | 127 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index 2c7aa47..ce5f4e3 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -1237,4 +1237,131 @@ router.post("/get-module-status", async (req, res) => { } }); +router.post("/mark-module-started", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const moduleId = req.body.moduleId; + if (!moduleId) { + return res.status(400).json({ message: "moduleId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const courseIdRes = await client.query( + `SELECT course_id FROM modules WHERE id = $1`, + [moduleId] + ); + + const courseId = courseIdRes.rows[0]?.course_id; + + const enrolmentRes = await client.query( + `SELECT id FROM enrollments + WHERE user_id = $1 AND course_id = $2`, + [userId, courseId] + ); + + const enrollmentId = enrolmentRes.rows[0]?.id; + + const statusRes = await client.query( + `SELECT status FROM module_status + WHERE enrollment_id = $1 AND module_id = $2`, + [enrollmentId, moduleId] + ); + + await client.query( + `UPDATE module_status + SET status = 'in_progress' + WHERE enrollment_id = $1 AND module_id = $2`, + [enrollmentId, moduleId] + ); + + await client.query("COMMIT"); + return res.status(200).json({ status: "in_progress" }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/mark-module-completed", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const moduleId = req.body.moduleId; + if (!moduleId) { + return res.status(400).json({ message: "moduleId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const courseIdRes = await client.query( + `SELECT course_id FROM modules WHERE id = $1`, + [moduleId] + ); + + const courseId = courseIdRes.rows[0]?.course_id; + + const enrolmentRes = await client.query( + `SELECT id FROM enrollments + WHERE user_id = $1 AND course_id = $2`, + [userId, courseId] + ); + + const enrollmentId = enrolmentRes.rows[0]?.id; + + const statusRes = await client.query( + `SELECT status FROM module_status + WHERE enrollment_id = $1 AND module_id = $2`, + [enrollmentId, moduleId] + ); + + if (statusRes.rows.length && statusRes.rows[0].status !== "in_progress") { + await client.query("ROLLBACK"); + return res.status(400).json({ + message: "Module must be marked as in_progress before completing", + }); + } + + await client.query( + `UPDATE module_status + SET status = 'completed' + WHERE enrollment_id = $1 AND module_id = $2`, + [enrollmentId, moduleId] + ); + + await client.query("COMMIT"); + return res.status(200).json({ status: "in_progress" }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + module.exports = router; From 090fc44e6cf2ebfa1fdf687983230e465e6fe22c Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 25 Jun 2025 23:09:56 +0200 Subject: [PATCH 14/19] Update module status for quizzes, and update time stamp --- routes/courses.js | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/routes/courses.js b/routes/courses.js index ce5f4e3..3a5cf82 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -971,6 +971,43 @@ async function submitQuizResponse(client, userId, quizId, answers) { } } + const revisionRes = await client.query( + `SELECT revision_id + FROM quizzes + WHERE id = $1`, + [quizId] + ); + + const revisionId = revisionRes.rows[0].revision_id; + + const moduleRes = await client.query( + `SELECT module_id + FROM revisions + WHERE id = $1`, + [revisionId] + ); + + const moduleId = moduleRes.rows[0].module_id; + + const enrollmentRes = await client.query( + `SELECT id + FROM enrollments + WHERE user_id = $1 AND course_id = ( + SELECT course_id FROM modules WHERE id = $2 + )`, + [userId, moduleId] + ); + + const enrollmentId = enrollmentRes.rows[0].id; + + await client.query( + `UPDATE module_status + SET status = 'completed', + completed_at = NOW() + WHERE enrollment_id = $1 AND module_id = $2`, + [enrollmentId, moduleId] + ); + return responseId; } @@ -1281,7 +1318,8 @@ router.post("/mark-module-started", async (req, res) => { await client.query( `UPDATE module_status - SET status = 'in_progress' + SET status = 'in_progress', + started_at = NOW() WHERE enrollment_id = $1 AND module_id = $2`, [enrollmentId, moduleId] ); @@ -1348,7 +1386,8 @@ router.post("/mark-module-completed", async (req, res) => { await client.query( `UPDATE module_status - SET status = 'completed' + SET status = 'completed', + completed_at = NOW() WHERE enrollment_id = $1 AND module_id = $2`, [enrollmentId, moduleId] ); From 3eb6cb3c266cf7618a80ba5958bf6822dba0e36d Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 25 Jun 2025 23:43:29 +0200 Subject: [PATCH 15/19] Add progress count for courses --- routes/courses.js | 96 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/routes/courses.js b/routes/courses.js index 3a5cf82..91a0002 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -742,10 +742,32 @@ router.get("/all-user-courses", async (req, res) => { // 1) Courses the user is enrolled in const enrolledRes = await client.query( - `SELECT c.id, c.name, c.description - FROM courses c - JOIN enrollments e ON e.course_id = c.id - WHERE e.user_id = $1`, + ` + SELECT + c.id, + c.name, + c.description, + -- total modules in the course + ( + SELECT COUNT(*) + FROM modules m + WHERE m.course_id = c.id + ) AS total_modules, + -- modules completed by this user + ( + SELECT COUNT(*) + FROM enrollments e + JOIN module_status ms ON ms.enrollment_id = e.id + WHERE e.user_id = $1 + AND e.course_id = c.id + AND ms.module_id IN (SELECT id FROM modules WHERE course_id = c.id) + AND ms.status = 'completed' + ) AS completed_modules + FROM courses c + JOIN enrollments e + ON e.course_id = c.id + WHERE e.user_id = $1 + `, [userId] ); @@ -796,8 +818,8 @@ router.post("/enroll-course", async (req, res) => { await client.query("BEGIN"); const insertRes = await client.query( - `INSERT INTO enrollments (user_id, course_id) - VALUES ($1, $2) + `INSERT INTO enrollments (user_id, course_id, started_at) + VALUES ($1, $2, NOW()) RETURNING id, status, started_at`, [userId, courseId] ); @@ -894,6 +916,68 @@ router.post("/unenroll-course", async (req, res) => { } }); +router.post("/complete-course", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const { courseId } = req.body; + if (!courseId) { + return res.status(400).json({ message: "courseId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const modRes = await client.query( + `SELECT COUNT(*) AS total_modules, + SUM(CASE WHEN ms.status = 'completed' THEN 1 ELSE 0 END) AS completed_modules + FROM modules m + JOIN module_status ms ON ms.module_id = m.id + WHERE m.course_id = $1 + AND ms.enrollment_id = ( + SELECT id FROM enrollments WHERE user_id = $2 AND course_id = $1 + )`, + [courseId, userId] + ); + + const totalModules = modRes.rows[0].total_modules; + const completedModules = modRes.rows[0].completed_modules; + if (totalModules !== completedModules) { + await client.query("ROLLBACK"); + return res.status(400).json({ + message: "Cannot complete course - not all modules are completed", + }); + } + + await client.query( + `UPDATE enrollments + SET status = 'completed', + completed_at = NOW() + WHERE user_id = $1 + AND course_id = $2`, + [userId, courseId] + ); + + await client.query("COMMIT"); + return res.status(200).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error completing course:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + router.post("/is-enrolled", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); From 571df18517b70fb9768dd68bb71c3d9d4e3d2598 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 25 Jun 2025 23:52:53 +0200 Subject: [PATCH 16/19] Add completed courses --- routes/courses.js | 78 +++++++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/routes/courses.js b/routes/courses.js index 91a0002..aa56813 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -740,51 +740,75 @@ router.get("/all-user-courses", async (req, res) => { try { await client.query("BEGIN"); - // 1) Courses the user is enrolled in + // 1) “Enrolled” courses (status = 'enrolled') const enrolledRes = await client.query( ` SELECT c.id, c.name, c.description, - -- total modules in the course - ( - SELECT COUNT(*) - FROM modules m - WHERE m.course_id = c.id - ) AS total_modules, - -- modules completed by this user - ( - SELECT COUNT(*) - FROM enrollments e - JOIN module_status ms ON ms.enrollment_id = e.id - WHERE e.user_id = $1 - AND e.course_id = c.id - AND ms.module_id IN (SELECT id FROM modules WHERE course_id = c.id) - AND ms.status = 'completed' - ) AS completed_modules + COUNT(m.id) AS total_modules, + COUNT(ms.id) FILTER (WHERE ms.status = 'completed') + AS completed_modules FROM courses c - JOIN enrollments e - ON e.course_id = c.id - WHERE e.user_id = $1 + JOIN enrollments e + ON e.course_id = c.id + AND e.user_id = $1 + AND e.status = 'enrolled' + LEFT JOIN modules m + ON m.course_id = c.id + LEFT JOIN module_status ms + ON ms.module_id = m.id + AND ms.enrollment_id = e.id + GROUP BY c.id, c.name, c.description; `, [userId] ); - // 2) All other courses in the same org that they're NOT enrolled in + // 2) “Completed” courses (status = 'completed') + const completedRes = await client.query( + ` + SELECT + c.id, + c.name, + c.description, + COUNT(m.id) AS total_modules, + COUNT(ms.id) FILTER (WHERE ms.status = 'completed') + AS completed_modules + FROM courses c + JOIN enrollments e + ON e.course_id = c.id + AND e.user_id = $1 + AND e.status = 'completed' + LEFT JOIN modules m + ON m.course_id = c.id + LEFT JOIN module_status ms + ON ms.module_id = m.id + AND ms.enrollment_id = e.id + GROUP BY c.id, c.name, c.description; + `, + [userId] + ); + + // 3) Others in same org, not enrolled at all const otherRes = await client.query( - `SELECT c.id, c.name, c.description - FROM courses c - WHERE c.organisation_id = $1 - AND c.id NOT IN ( - SELECT course_id FROM enrollments WHERE user_id = $2 - )`, + ` + SELECT c.id, c.name, c.description + FROM courses c + WHERE c.organisation_id = $1 + AND c.id NOT IN ( + SELECT course_id + FROM enrollments + WHERE user_id = $2 + ) + `, [organisationId, userId] ); await client.query("COMMIT"); return res.status(200).json({ enrolled: enrolledRes.rows, + completed: completedRes.rows, other: otherRes.rows, }); } catch (err) { From 4b91a635783cb15e7e334063a0abde3ee9fd5970 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 26 Jun 2025 00:03:44 +0200 Subject: [PATCH 17/19] Allow course to be unenrolled --- routes/courses.js | 58 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index aa56813..c271df9 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -333,6 +333,23 @@ router.post("/add-module", upload.single("file"), async (req, res) => { const module_id = moduleRes.rows[0].id; + const { rows: enrollments } = await client.query( + `SELECT id + FROM enrollments + WHERE course_id = $1`, + [courseId] + ); + + for (const { id: enrollmentId } of enrollments) { + await client.query( + `INSERT INTO module_status + (enrollment_id, module_id, status) + VALUES ($1, $2, 'not_started') + ON CONFLICT (enrollment_id, module_id) DO NOTHING`, + [enrollmentId, module_id] + ); + } + if (type === "quiz") { // revision for this module const revRes = await client.query( @@ -1002,6 +1019,47 @@ router.post("/complete-course", async (req, res) => { } }); +router.post("/uncomplete-course", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const { courseId } = req.body; + if (!courseId) { + return res.status(400).json({ message: "courseId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + await client.query( + `UPDATE enrollments + SET status = 'enrolled', + completed_at = NULL + WHERE user_id = $1 + AND course_id = $2`, + [userId, courseId] + ); + + await client.query("COMMIT"); + return res.status(200).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error completing course:", err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + router.post("/is-enrolled", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); From 02f7910987ae38e5c491d10da748a1f47334d1ce Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 26 Jun 2025 00:21:15 +0200 Subject: [PATCH 18/19] manage permissions if course is completed --- routes/courses.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index c271df9..eb8a380 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -946,6 +946,19 @@ router.post("/unenroll-course", async (req, res) => { [enrollmentId] ); + await client.query( + `DELETE FROM quiz_responses + WHERE user_id = $1 + AND quiz_id IN ( + SELECT id FROM quizzes WHERE revision_id IN ( + SELECT id FROM revisions WHERE module_id IN ( + SELECT id FROM modules WHERE course_id = $2 + ) + ) + )`, + [userId, courseId] + ); + await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) { @@ -1421,6 +1434,12 @@ router.post("/get-module-status", async (req, res) => { const enrollmentId = enrolmentRes.rows[0]?.id; + const isCourseCompleted = await client.query( + `SELECT 1 FROM enrollments + WHERE id = $1 AND status = 'completed'`, + [enrollmentId] + ); + const statusRes = await client.query( `SELECT status FROM module_status WHERE enrollment_id = $1 AND module_id = $2`, @@ -1430,6 +1449,7 @@ router.post("/get-module-status", async (req, res) => { await client.query("COMMIT"); return res.status(200).json({ status: statusRes.rows.length ? statusRes.rows[0].status : "not_started", + isCourseCompleted: isCourseCompleted.rows.length > 0, }); } catch (err) { await client.query("ROLLBACK"); From ef7859da6ed9eb938047beda9bc0a2acc78cf175 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 26 Jun 2025 00:36:28 +0200 Subject: [PATCH 19/19] Update module urges user to uncomplete course and do the new modules --- routes/courses.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index eb8a380..33e55fc 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -703,6 +703,12 @@ router.put("/update-module", upload.single("file"), async (req, res) => { ); await client.query(`DELETE FROM questions WHERE quiz_id = $1`, [quizId]); + await client.query( + `DELETE FROM quiz_responses + WHERE quiz_id = $1`, + [quizId] + ); + for (let i = 0; i < qs.length; i++) { const { question_text, question_type, options } = qs[i]; const qRes = await client.query( @@ -725,6 +731,34 @@ router.put("/update-module", upload.single("file"), async (req, res) => { } } + if (type) { + const courseIdRes = await client.query( + `SELECT course_id FROM modules WHERE id = $1`, + [moduleId] + ); + + if (!courseIdRes.rows.length) { + return res.status(404).json({ message: "Module not found" }); + } + const courseId = courseIdRes.rows[0].course_id; + + const { rows: enrollments } = await client.query( + `SELECT id + FROM enrollments + WHERE course_id = $1`, + [courseId] + ); + + for (const { id: enrollmentId } of enrollments) { + await client.query( + `UPDATE module_status + SET status = 'not_started' + WHERE enrollment_id = $1 AND module_id = $2`, + [enrollmentId, moduleId] + ); + } + } + await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) {