From 3aec94e4f0a5a76261a99ec09e1c3f758ab16ed0 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 00:22:05 +0800 Subject: [PATCH 01/32] Add relevant sql tables for onboarding assessment --- database/schema.sql | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/database/schema.sql b/database/schema.sql index 943c800..827abf7 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -269,4 +269,29 @@ CREATE TABLE roadmap_items ( PRIMARY KEY(roadmap_id, material_id) ); -COMMIT; +--- + + -- Table to store onboarding questions + CREATE TABLE onboarding_questions ( + id SERIAL PRIMARY KEY, + question_text TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0 + ); + + -- Table to store answer options for each question + CREATE TABLE onboarding_question_options ( + id SERIAL PRIMARY KEY, + question_id INTEGER NOT NULL REFERENCES onboarding_questions(id) ON DELETE CASCADE, + option_text TEXT NOT NULL, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE + ); + + -- Table to store user responses + CREATE TABLE onboarding_responses ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_id INTEGER NOT NULL REFERENCES onboarding_question_options(id) ON DELETE CASCADE, + PRIMARY KEY(user_id, option_id) + ); + + +COMMIT; \ No newline at end of file From d7145c773f37123d650a37c7df997c968e8f6df3 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 01:05:16 +0800 Subject: [PATCH 02/32] Add endpoints for onboarding form --- routes/onboarding.js | 293 +++++++++++++++++++++++++++++++++++++++++++ server.js | 5 +- 2 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 routes/onboarding.js diff --git a/routes/onboarding.js b/routes/onboarding.js new file mode 100644 index 0000000..5a5a41b --- /dev/null +++ b/routes/onboarding.js @@ -0,0 +1,293 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); + +function getAuthUser(req) { + const { auth } = req.cookies; + if (!auth) return null; + try { + return JSON.parse(auth); + } catch { + return null; + } +} + +function isAdmin(user) { + return user && user.organisation && user.organisation.role === "admin"; +} + +function isEmployee(user) { + return user && user.organisation && user.organisation.role === "employee"; +} + +router.get("/questions", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + // if (!isEmployee(user)) { + // return res.status(403).json({ message: "Employee access required" }); + // } + + try { + const questionsResult = await pool.query(` + SELECT id, question_text, position + FROM onboarding_questions + ORDER BY position ASC + `); + + const questions = []; + for (const question of questionsResult.rows) { + const optionsResult = await pool.query( + ` + SELECT oqo.id, oqo.option_text, oqo.tag_id, t.name as tag_name + FROM onboarding_question_options oqo + JOIN tags t ON t.id = oqo.tag_id + WHERE oqo.question_id = $1 + ORDER BY oqo.id ASC + `, + [question.id] + ); + + questions.push({ + id: question.id, + question_text: question.question_text, + position: question.position, + options: optionsResult.rows, + }); + } + + res.json({ questions }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/questions", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isAdmin(user)) { + return res.status(403).json({ message: "Admin access required" }); + } + + const { question_text, position = 0 } = req.body; + if (!question_text) { + return res.status(400).json({ message: "question_text is required" }); + } + + try { + const result = await pool.query( + ` + INSERT INTO onboarding_questions (question_text, position) + VALUES ($1, $2) + RETURNING id, question_text, position + `, + [question_text, position] + ); + + res.status(201).json({ question: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/questions/:id/options", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isAdmin(user)) { + return res.status(403).json({ message: "Admin access required" }); + } + + const { id } = req.params; + const { option_text, tag_id } = req.body; + + if (!option_text || !tag_id) { + return res + .status(400) + .json({ message: "option_text and tag_id are required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const questionCheck = await client.query( + "SELECT id FROM onboarding_questions WHERE id = $1", + [id] + ); + if (questionCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Question not found" }); + } + + const tagCheck = await client.query( + "SELECT id, name FROM tags WHERE id = $1", + [tag_id] + ); + if (tagCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Tag not found" }); + } + + const result = await client.query( + ` + INSERT INTO onboarding_question_options (question_id, option_text, tag_id) + VALUES ($1, $2, $3) + RETURNING id, option_text, tag_id + `, + [id, option_text, tag_id] + ); + + await client.query("COMMIT"); + + const option = { + ...result.rows[0], + tag_name: tagCheck.rows[0].name, + }; + + res.status(201).json({ option }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.delete("/questions/:id", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isAdmin(user)) { + return res.status(403).json({ message: "Admin access required" }); + } + + const { id } = req.params; + + try { + const result = await pool.query( + "DELETE FROM onboarding_questions WHERE id = $1 RETURNING id", + [id] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ message: "Question not found" }); + } + + res.json({ message: "Question deleted successfully" }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/responses", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isEmployee(user)) { + return res.status(403).json({ message: "Employee access required" }); + } + + const { option_ids } = req.body; + if (!option_ids || !Array.isArray(option_ids) || option_ids.length === 0) { + return res.status(400).json({ message: "option_ids array is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + await client.query("DELETE FROM onboarding_responses WHERE user_id = $1", [ + user.userId, + ]); + + for (const optionId of option_ids) { + await client.query( + ` + INSERT INTO onboarding_responses (user_id, option_id) + VALUES ($1, $2) + ON CONFLICT (user_id, option_id) DO NOTHING + `, + [user.userId, optionId] + ); + } + + await client.query( + "UPDATE users SET has_completed_onboarding = true WHERE id = $1", + [user.userId] + ); + + await client.query("COMMIT"); + + res.json({ message: "Responses submitted successfully" }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.get("/responses", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isEmployee(user)) { + return res.status(403).json({ message: "Employee access required" }); + } + + try { + const result = await pool.query( + ` + SELECT + or.option_id, + oqo.option_text, + oqo.tag_id, + t.name as tag_name, + oq.question_text, + oq.id as question_id + FROM onboarding_responses or + JOIN onboarding_question_options oqo ON oqo.id = or.option_id + JOIN onboarding_questions oq ON oq.id = oqo.question_id + JOIN tags t ON t.id = oqo.tag_id + WHERE or.user_id = $1 + ORDER BY oq.position ASC + `, + [user.userId] + ); + + const responses = result.rows.map((row) => ({ + option_id: row.option_id, + option_text: row.option_text, + tag_id: row.tag_id, + tag_name: row.tag_name, + question_text: row.question_text, + question_id: row.question_id, + })); + + res.json({ responses }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 0a736ba..83b5518 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ const orgRoutes = require("./routes/orgs"); const courseRoutes = require("./routes/courses"); const userRoutes = require("./routes/users"); const reportsRoutes = require("./routes/reports"); +const onboardingRoutes = require("./routes/onboarding"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; @@ -19,7 +20,6 @@ app.use("/uploads", express.static(uploadsDir)); app.use(express.json()); app.use(cookieParser()); -// optional CORS settings if Next.js runs on a different origin app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "http://localhost:3000"); res.header("Access-Control-Allow-Credentials", "true"); @@ -27,7 +27,6 @@ app.use((req, res, next) => { next(); }); -// your existing endpoints app.get("/", (req, res) => res.send("Welcome to PostgreSQL with Node.js and Express!") ); @@ -41,12 +40,12 @@ app.get("/checkconnection", async (req, res) => { } }); -// mount all auth routes under /api app.use("/api", authRoutes); app.use("/api/orgs", orgRoutes); app.use("/api/courses", courseRoutes); app.use("/api/users", userRoutes); app.use("/api/reports", reportsRoutes); +app.use("/api/onboarding", onboardingRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); From 0eac90943251fd2c0cdbc65a5983dc176d164553 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 09:15:35 +0800 Subject: [PATCH 03/32] Add onboarding form as questionaire for onboarding --- database/schema.sql | 2 +- routes/auth.js | 28 ++++++++++++++++++---------- routes/onboarding.js | 35 +++++++++++++++-------------------- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/database/schema.sql b/database/schema.sql index 827abf7..aeccf93 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -283,7 +283,7 @@ CREATE TABLE roadmap_items ( id SERIAL PRIMARY KEY, question_id INTEGER NOT NULL REFERENCES onboarding_questions(id) ON DELETE CASCADE, option_text TEXT NOT NULL, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE + tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE ); -- Table to store user responses diff --git a/routes/auth.js b/routes/auth.js index 3615166..c7b63ec 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -13,7 +13,6 @@ function setAuthCookie(res, payload) { }); } -// SIGN UP → POST /api/signup router.post("/signup", async (req, res) => { const { email, password, firstname, lastname } = req.body; try { @@ -45,7 +44,6 @@ router.post("/signup", async (req, res) => { } }); -// LOG IN → POST /api/login router.post("/login", async (req, res) => { const { email, password } = req.body; try { @@ -90,12 +88,10 @@ router.post("/login", async (req, res) => { } }); -// LOGOUT → POST /api/logout router.post("/logout", (req, res) => { res.clearCookie("auth", { path: "/" }).json({ success: true }); }); -// WHOAMI → GET /api/me router.get("/me", (req, res) => { const { auth } = req.cookies; if (!auth) return res.json({ isLoggedIn: false }); @@ -118,11 +114,6 @@ router.post("/complete-onboarding", async (req, res) => { } try { - await pool.query( - `UPDATE users SET has_completed_onboarding = true WHERE id = $1`, - [user.userId] - ); - const mem = await pool.query( `SELECT o.id AS id, @@ -137,7 +128,24 @@ router.post("/complete-onboarding", async (req, res) => { const organisation = mem.rows[0] || null; - // Regenerate auth cookie + if (organisation && organisation.role === "employee") { + const responseCheck = await pool.query( + `SELECT COUNT(*) as response_count FROM onboarding_responses WHERE user_id = $1`, + [user.userId] + ); + + if (parseInt(responseCheck.rows[0].response_count) === 0) { + return res.status(400).json({ + message: "Onboarding questionnaire must be completed first", + }); + } + } + + await pool.query( + `UPDATE users SET has_completed_onboarding = true WHERE id = $1`, + [user.userId] + ); + setAuthCookie(res, { ...user, hasCompletedOnboarding: true, diff --git a/routes/onboarding.js b/routes/onboarding.js index 5a5a41b..87a458f 100644 --- a/routes/onboarding.js +++ b/routes/onboarding.js @@ -43,7 +43,7 @@ router.get("/questions", async (req, res) => { ` SELECT oqo.id, oqo.option_text, oqo.tag_id, t.name as tag_name FROM onboarding_question_options oqo - JOIN tags t ON t.id = oqo.tag_id + LEFT JOIN tags t ON t.id = oqo.tag_id WHERE oqo.question_id = $1 ORDER BY oqo.id ASC `, @@ -110,10 +110,8 @@ router.post("/questions/:id/options", async (req, res) => { const { id } = req.params; const { option_text, tag_id } = req.body; - if (!option_text || !tag_id) { - return res - .status(400) - .json({ message: "option_text and tag_id are required" }); + if (!option_text) { + return res.status(400).json({ message: "option_text is required" }); } const client = await pool.connect(); @@ -129,13 +127,15 @@ router.post("/questions/:id/options", async (req, res) => { return res.status(404).json({ message: "Question not found" }); } - const tagCheck = await client.query( - "SELECT id, name FROM tags WHERE id = $1", - [tag_id] - ); - if (tagCheck.rows.length === 0) { - await client.query("ROLLBACK"); - return res.status(404).json({ message: "Tag not found" }); + let tagCheck = { rows: [] }; + if (tag_id) { + tagCheck = await client.query("SELECT id, name FROM tags WHERE id = $1", [ + tag_id, + ]); + if (tagCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Tag not found" }); + } } const result = await client.query( @@ -144,14 +144,14 @@ router.post("/questions/:id/options", async (req, res) => { VALUES ($1, $2, $3) RETURNING id, option_text, tag_id `, - [id, option_text, tag_id] + [id, option_text, tag_id || null] ); await client.query("COMMIT"); const option = { ...result.rows[0], - tag_name: tagCheck.rows[0].name, + tag_name: tag_id ? tagCheck.rows[0].name : null, }; res.status(201).json({ option }); @@ -198,11 +198,6 @@ router.post("/responses", async (req, res) => { if (!user || !user.isLoggedIn) { return res.status(401).json({ message: "Not logged in" }); } - - if (!isEmployee(user)) { - return res.status(403).json({ message: "Employee access required" }); - } - const { option_ids } = req.body; if (!option_ids || !Array.isArray(option_ids) || option_ids.length === 0) { return res.status(400).json({ message: "option_ids array is required" }); @@ -267,7 +262,7 @@ router.get("/responses", async (req, res) => { FROM onboarding_responses or JOIN onboarding_question_options oqo ON oqo.id = or.option_id JOIN onboarding_questions oq ON oq.id = oqo.question_id - JOIN tags t ON t.id = oqo.tag_id + LEFT JOIN tags t ON t.id = oqo.tag_id WHERE or.user_id = $1 ORDER BY oq.position ASC `, From ca2223fdd10bc7317dd78ceac8c3c55ff12f8615 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 09:48:53 +0800 Subject: [PATCH 04/32] Make tags and form organisation specific --- routes/auth.js | 23 +++++--- routes/courses.js | 36 ++++++++++-- routes/onboarding.js | 127 ++++++++++++++++++++++++++++++++++++++----- routes/orgs.js | 11 +++- 4 files changed, 168 insertions(+), 29 deletions(-) diff --git a/routes/auth.js b/routes/auth.js index c7b63ec..0d601f9 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -129,15 +129,24 @@ router.post("/complete-onboarding", async (req, res) => { const organisation = mem.rows[0] || null; if (organisation && organisation.role === "employee") { - const responseCheck = await pool.query( - `SELECT COUNT(*) as response_count FROM onboarding_responses WHERE user_id = $1`, - [user.userId] + const questionCheck = await pool.query( + `SELECT COUNT(*) as question_count FROM onboarding_questions WHERE organisation_id = $1`, + [organisation.id] ); - if (parseInt(responseCheck.rows[0].response_count) === 0) { - return res.status(400).json({ - message: "Onboarding questionnaire must be completed first", - }); + const hasQuestions = parseInt(questionCheck.rows[0].question_count) > 0; + + if (hasQuestions) { + const responseCheck = await pool.query( + `SELECT COUNT(*) as response_count FROM onboarding_responses WHERE user_id = $1`, + [user.userId] + ); + + if (parseInt(responseCheck.rows[0].response_count) === 0) { + return res.status(400).json({ + message: "Onboarding questionnaire must be completed first", + }); + } } } diff --git a/routes/courses.js b/routes/courses.js index 6ef818a..2065ff2 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -1810,10 +1810,14 @@ router.post("/add-tags", async (req, res) => { } const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { return res.status(403).json({ message: "Forbidden" }); } + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } const { tags } = req.body; if (!Array.isArray(tags)) { return res.status(400).json({ message: "tags[] are required" }); @@ -1827,10 +1831,10 @@ router.post("/add-tags", async (req, res) => { continue; // skip if tag has no name } await client.query( - `INSERT INTO tags (name) - VALUES ($1) - ON CONFLICT (name) DO NOTHING`, - [tag.name] + `INSERT INTO tags (name, organisation_id) + VALUES ($1, $2) + ON CONFLICT (name, organisation_id) DO NOTHING`, + [tag.name, organisationId] ); } await client.query("COMMIT"); @@ -1845,10 +1849,26 @@ router.post("/add-tags", async (req, res) => { }); router.get("/tags", 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 organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + const client = await pool.connect(); try { const { rows } = await client.query( - `SELECT id, name FROM tags ORDER BY name` + `SELECT id, name FROM tags WHERE organisation_id = $1 ORDER BY name`, + [organisationId] ); res.json(rows); } catch (err) { @@ -1870,10 +1890,14 @@ router.delete("/delete-tag", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { return res.status(403).json({ message: "Forbidden" }); } + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } const { tagId } = req.body; if (!tagId) { return res.status(400).json({ message: "tagId is required" }); @@ -1882,7 +1906,7 @@ router.delete("/delete-tag", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); - await client.query(`DELETE FROM tags WHERE id = $1`, [tagId]); + await client.query(`DELETE FROM tags WHERE id = $1 AND organisation_id = $2`, [tagId, organisationId]); await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) { diff --git a/routes/onboarding.js b/routes/onboarding.js index 87a458f..021b8d5 100644 --- a/routes/onboarding.js +++ b/routes/onboarding.js @@ -26,16 +26,21 @@ router.get("/questions", async (req, res) => { return res.status(401).json({ message: "Not logged in" }); } - // if (!isEmployee(user)) { - // return res.status(403).json({ message: "Employee access required" }); - // } + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } try { - const questionsResult = await pool.query(` + const questionsResult = await pool.query( + ` SELECT id, question_text, position FROM onboarding_questions + WHERE organisation_id = $1 ORDER BY position ASC - `); + `, + [organisationId] + ); const questions = []; for (const question of questionsResult.rows) { @@ -80,14 +85,19 @@ router.post("/questions", async (req, res) => { return res.status(400).json({ message: "question_text is required" }); } + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + try { const result = await pool.query( ` - INSERT INTO onboarding_questions (question_text, position) - VALUES ($1, $2) + INSERT INTO onboarding_questions (question_text, position, organisation_id) + VALUES ($1, $2, $3) RETURNING id, question_text, position `, - [question_text, position] + [question_text, position, organisationId] ); res.status(201).json({ question: result.rows[0] }); @@ -118,9 +128,15 @@ router.post("/questions/:id/options", async (req, res) => { try { await client.query("BEGIN"); + const organisationId = user.organisation?.id; + if (!organisationId) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Organization required" }); + } + const questionCheck = await client.query( - "SELECT id FROM onboarding_questions WHERE id = $1", - [id] + "SELECT id FROM onboarding_questions WHERE id = $1 AND organisation_id = $2", + [id, organisationId] ); if (questionCheck.rows.length === 0) { await client.query("ROLLBACK"); @@ -129,9 +145,10 @@ router.post("/questions/:id/options", async (req, res) => { let tagCheck = { rows: [] }; if (tag_id) { - tagCheck = await client.query("SELECT id, name FROM tags WHERE id = $1", [ - tag_id, - ]); + tagCheck = await client.query( + "SELECT id, name FROM tags WHERE id = $1 AND organisation_id = $2", + [tag_id, organisationId] + ); if (tagCheck.rows.length === 0) { await client.query("ROLLBACK"); return res.status(404).json({ message: "Tag not found" }); @@ -176,12 +193,31 @@ router.delete("/questions/:id", async (req, res) => { const { id } = req.params; + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + try { - const result = await pool.query( - "DELETE FROM onboarding_questions WHERE id = $1 RETURNING id", + // Check if question has any options before deletion + const optionCheck = await pool.query( + "SELECT COUNT(*) as option_count FROM onboarding_question_options WHERE question_id = $1", [id] ); + const hasOptions = parseInt(optionCheck.rows[0].option_count) > 0; + if (hasOptions) { + return res.status(400).json({ + message: + "Cannot delete question that has options. Please delete all options first.", + }); + } + + const result = await pool.query( + "DELETE FROM onboarding_questions WHERE id = $1 AND organisation_id = $2 RETURNING id", + [id, organisationId] + ); + if (result.rows.length === 0) { return res.status(404).json({ message: "Question not found" }); } @@ -193,6 +229,67 @@ router.delete("/questions/:id", async (req, res) => { } }); +router.delete("/options/:optionId", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + if (!isAdmin(user)) { + return res.status(403).json({ message: "Admin access required" }); + } + + const { optionId } = req.params; + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + const optionCheck = await pool.query( + ` + SELECT oqo.question_id, oq.organisation_id + FROM onboarding_question_options oqo + JOIN onboarding_questions oq ON oq.id = oqo.question_id + WHERE oqo.id = $1 + `, + [optionId] + ); + + if (optionCheck.rows.length === 0) { + return res.status(404).json({ message: "Option not found" }); + } + + const option = optionCheck.rows[0]; + if (option.organisation_id !== organisationId) { + return res.status(403).json({ message: "Access denied" }); + } + + const optionCountCheck = await pool.query( + "SELECT COUNT(*) as option_count FROM onboarding_question_options WHERE question_id = $1", + [option.question_id] + ); + + const optionCount = parseInt(optionCountCheck.rows[0].option_count); + if (optionCount <= 1) { + return res.status(400).json({ + message: + "Cannot delete the last option. Questions must have at least one option.", + }); + } + + const result = await pool.query( + "DELETE FROM onboarding_question_options WHERE id = $1 RETURNING id", + [optionId] + ); + + res.json({ message: "Option deleted successfully" }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + router.post("/responses", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { diff --git a/routes/orgs.js b/routes/orgs.js index fe383d5..1ec4bbb 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -14,7 +14,6 @@ function setAuthCookie(res, payload) { }); } -// Create a new organization AND make the current user its admin router.post("/", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -115,6 +114,16 @@ router.post("/addemployee", async (req, res) => { ); await client.query("COMMIT"); + + setAuthCookie(res, { + ...session, + organisation: { + id: org.id, + organisationname: org.organisation_name, + role: "employee", + }, + }); + return res.status(201).json({ organisation: { ...org, role: "employee" } }); } catch (err) { await client.query("ROLLBACK"); From a4d2820419203d5b3788989298b58a1a031696a2 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 10:12:34 +0800 Subject: [PATCH 05/32] Update schema --- database/schema.sql | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/database/schema.sql b/database/schema.sql index aeccf93..9d0be2a 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -178,7 +178,9 @@ CREATE TABLE quiz_answers ( -- 8. TAGS CREATE TABLE tags ( id SERIAL PRIMARY KEY, - name VARCHAR(50) NOT NULL UNIQUE + name VARCHAR(50) NOT NULL, + organisation_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + UNIQUE (name, organisation_id) ); CREATE TABLE course_tags ( @@ -275,7 +277,8 @@ CREATE TABLE roadmap_items ( CREATE TABLE onboarding_questions ( id SERIAL PRIMARY KEY, question_text TEXT NOT NULL, - position INTEGER NOT NULL DEFAULT 0 + position INTEGER NOT NULL DEFAULT 0, + organisation_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE ); -- Table to store answer options for each question From b02452cd3f7470b5ae3f03b61ec32773f831663d Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 12:19:51 +0800 Subject: [PATCH 06/32] Add roadmap endpoints --- database/schema.sql | 6 +- routes/materials.js | 131 ++++++++++++ routes/roadmaps.js | 511 ++++++++++++++++++++++++++++++++++++++++++++ server.js | 4 + 4 files changed, 649 insertions(+), 3 deletions(-) create mode 100644 routes/materials.js create mode 100644 routes/roadmaps.js diff --git a/database/schema.sql b/database/schema.sql index 9d0be2a..7a3cc62 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -265,10 +265,10 @@ CREATE TABLE roadmaps ( CREATE TABLE roadmap_items ( roadmap_id INTEGER NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE, - material_id INTEGER NOT NULL - REFERENCES materials(id) ON DELETE CASCADE, + module_id INTEGER NOT NULL + REFERENCES modules(id) ON DELETE CASCADE, position INTEGER NOT NULL, - PRIMARY KEY(roadmap_id, material_id) + PRIMARY KEY(roadmap_id, module_id) ); --- diff --git a/routes/materials.js b/routes/materials.js new file mode 100644 index 0000000..3955a46 --- /dev/null +++ b/routes/materials.js @@ -0,0 +1,131 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); + +// Helper function to parse auth cookie +function getAuthUser(req) { + const { auth } = req.cookies; + if (!auth) return null; + try { + return JSON.parse(auth); + } catch { + return null; + } +} + +// GET /api/materials - Get all modules for the user's organization +router.get("/", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const { tag_ids } = req.query; // Comma-separated tag IDs + + try { + let query = ` + SELECT DISTINCT + mod.id, + mod.title as module_title, + mod.description, + mod.module_type, + mod.file_url, + c.name as course_name, + c.id as course_id, + ARRAY_AGG(DISTINCT t.name) as tags + FROM modules mod + JOIN courses c ON c.id = mod.course_id + LEFT JOIN module_tags mt ON mt.module_id = mod.id + LEFT JOIN tags t ON t.id = mt.tag_id + WHERE c.organisation_id = $1`; + + const params = [organisationId]; + + // If tag_ids are provided, filter by those tags + if (tag_ids) { + const tagIdArray = tag_ids.split(',').map(id => parseInt(id)).filter(id => !isNaN(id)); + if (tagIdArray.length > 0) { + query += ` AND mt.tag_id = ANY($2)`; + params.push(tagIdArray); + } + } + + query += ` GROUP BY mod.id, mod.title, mod.description, mod.module_type, mod.file_url, c.name, c.id + ORDER BY c.name, mod.title`; + + const result = await pool.query(query, params); + + res.json({ materials: result.rows }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +// GET /api/materials/by-user-tags - Get modules recommended based on user's onboarding tags +router.get("/by-user-tags", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + // Get user's tags from onboarding responses + const userTagsResult = await pool.query( + `SELECT DISTINCT oqo.tag_id + FROM onboarding_responses or_table + JOIN onboarding_question_options oqo ON oqo.id = or_table.option_id + WHERE or_table.user_id = $1 AND oqo.tag_id IS NOT NULL`, + [user.userId] + ); + + const userTagIds = userTagsResult.rows.map(row => row.tag_id); + + if (userTagIds.length === 0) { + return res.json({ materials: [] }); + } + + // Get modules that match user's tags + const result = await pool.query( + `SELECT DISTINCT + mod.id, + mod.title as module_title, + mod.description, + mod.module_type, + mod.file_url, + c.name as course_name, + c.id as course_id, + ARRAY_AGG(DISTINCT t.name) as tags, + COUNT(DISTINCT mt.tag_id) as matching_tags + FROM modules mod + JOIN courses c ON c.id = mod.course_id + JOIN module_tags mt ON mt.module_id = mod.id + JOIN tags t ON t.id = mt.tag_id + WHERE c.organisation_id = $1 + AND mt.tag_id = ANY($2) + GROUP BY mod.id, mod.title, mod.description, mod.module_type, mod.file_url, c.name, c.id + ORDER BY matching_tags DESC, c.name, mod.title`, + [organisationId, userTagIds] + ); + + res.json({ + materials: result.rows, + userTags: userTagIds + }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/routes/roadmaps.js b/routes/roadmaps.js new file mode 100644 index 0000000..638c0f1 --- /dev/null +++ b/routes/roadmaps.js @@ -0,0 +1,511 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); + +// Helper function to parse auth cookie +function getAuthUser(req) { + const { auth } = req.cookies; + if (!auth) return null; + try { + return JSON.parse(auth); + } catch { + return null; + } +} + +// Helper function to get courses from module IDs +async function getCoursesFromModules(client, moduleIds) { + if (moduleIds.length === 0) return []; + + const result = await client.query( + `SELECT DISTINCT course_id FROM modules WHERE id = ANY($1)`, + [moduleIds] + ); + + return result.rows.map(row => row.course_id); +} + +// Helper function to auto-enroll user in courses +async function ensureUserEnrolledInCourses(client, userId, courseIds) { + const enrolledCourses = []; + + for (const courseId of courseIds) { + try { + // Check if already enrolled + const existingEnrollment = await client.query( + `SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2`, + [userId, courseId] + ); + + if (existingEnrollment.rows.length > 0) { + continue; // Skip if already enrolled + } + + // Create enrollment + const insertRes = await client.query( + `INSERT INTO enrollments (user_id, course_id, started_at) + VALUES ($1, $2, NOW()) + RETURNING id`, + [userId, courseId] + ); + + const enrollmentId = insertRes.rows[0].id; + + // Get all modules for this course + const modulesRes = await client.query( + `SELECT id FROM modules WHERE course_id = $1`, + [courseId] + ); + + // Create module_status records + 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] + ); + } + + enrolledCourses.push(courseId); + } catch (err) { + if (err.code !== "23505") { // Ignore duplicate enrollment errors + throw err; + } + } + } + + return enrolledCourses; +} + +// GET /api/roadmaps - Get user's roadmaps +router.get("/", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + try { + const result = await pool.query( + `SELECT id, name, user_id + FROM roadmaps + WHERE user_id = $1 + ORDER BY id DESC`, + [user.userId] + ); + + res.json({ roadmaps: result.rows }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +// POST /api/roadmaps - Create new roadmap +router.post("/", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const { name } = req.body; + if (!name || !name.trim()) { + return res.status(400).json({ message: "Roadmap name is required" }); + } + + try { + const result = await pool.query( + `INSERT INTO roadmaps (user_id, name) + VALUES ($1, $2) + RETURNING id, name, user_id`, + [user.userId, name.trim()] + ); + + res.status(201).json({ roadmap: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +// PUT /api/roadmaps/:id - Update roadmap +router.put("/:id", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const { id } = req.params; + const { name } = req.body; + + if (!name || !name.trim()) { + return res.status(400).json({ message: "Roadmap name is required" }); + } + + try { + const result = await pool.query( + `UPDATE roadmaps + SET name = $1 + WHERE id = $2 AND user_id = $3 + RETURNING id, name, user_id`, + [name.trim(), id, user.userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ message: "Roadmap not found" }); + } + + res.json({ roadmap: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +// DELETE /api/roadmaps/:id - Delete roadmap +router.delete("/:id", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const { id } = req.params; + + try { + const result = await pool.query( + `DELETE FROM roadmaps + WHERE id = $1 AND user_id = $2 + RETURNING id`, + [id, user.userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ message: "Roadmap not found" }); + } + + res.json({ message: "Roadmap deleted successfully" }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +// GET /api/roadmaps/:id/items - Get roadmap modules with details +router.get("/:id/items", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const { id } = req.params; + + try { + // First verify the roadmap belongs to the user + const roadmapCheck = await pool.query( + `SELECT id FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + + if (roadmapCheck.rows.length === 0) { + return res.status(404).json({ message: "Roadmap not found" }); + } + + // Get roadmap items with module details + const result = await pool.query( + `SELECT + ri.position, + ri.module_id, + mod.title as module_title, + mod.description, + mod.module_type, + mod.file_url, + c.name as course_name, + c.id as course_id, + CASE + WHEN e.id IS NOT NULL THEN 'enrolled' + ELSE 'not_enrolled' + END as enrollment_status, + COALESCE(ms.status, 'not_started') as module_status + FROM roadmap_items ri + JOIN modules mod ON mod.id = ri.module_id + JOIN courses c ON c.id = mod.course_id + LEFT JOIN enrollments e ON e.course_id = c.id AND e.user_id = $2 + LEFT JOIN module_status ms ON ms.module_id = mod.id AND ms.enrollment_id = e.id + WHERE ri.roadmap_id = $1 + ORDER BY ri.position ASC`, + [id, user.userId] + ); + + res.json({ items: result.rows }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +// POST /api/roadmaps/:id/items - Add module to roadmap +router.post("/:id/items", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const { id } = req.params; + const { module_id } = req.body; + + if (!module_id) { + return res.status(400).json({ message: "module_id is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Verify roadmap belongs to user + const roadmapCheck = await client.query( + `SELECT id FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + + if (roadmapCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Roadmap not found" }); + } + + // Check if module is already in roadmap + const existingCheck = await client.query( + `SELECT 1 FROM roadmap_items WHERE roadmap_id = $1 AND module_id = $2`, + [id, module_id] + ); + + if (existingCheck.rows.length > 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Module already in roadmap" }); + } + + // Get next position + const positionResult = await client.query( + `SELECT COALESCE(MAX(position), 0) + 1 as next_position + FROM roadmap_items WHERE roadmap_id = $1`, + [id] + ); + + const nextPosition = positionResult.rows[0].next_position; + + // Get course ID from module and auto-enroll user + const courseIds = await getCoursesFromModules(client, [module_id]); + const enrolledCourses = await ensureUserEnrolledInCourses(client, user.userId, courseIds); + + // Add module to roadmap + await client.query( + `INSERT INTO roadmap_items (roadmap_id, module_id, position) + VALUES ($1, $2, $3)`, + [id, module_id, nextPosition] + ); + + await client.query("COMMIT"); + + res.status(201).json({ + message: "Module added to roadmap", + position: nextPosition, + enrolledCourses: enrolledCourses.length + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// PUT /api/roadmaps/:id/items/:moduleId - Update item position +router.put("/:id/items/:moduleId", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const { id, moduleId } = req.params; + const { position } = req.body; + + if (position === undefined || position < 1) { + return res.status(400).json({ message: "Valid position is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Verify roadmap belongs to user + const roadmapCheck = await client.query( + `SELECT id FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + + if (roadmapCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Roadmap not found" }); + } + + // Update position + const result = await client.query( + `UPDATE roadmap_items + SET position = $1 + WHERE roadmap_id = $2 AND module_id = $3 + RETURNING position`, + [position, id, moduleId] + ); + + if (result.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Roadmap item not found" }); + } + + await client.query("COMMIT"); + + res.json({ message: "Position updated", position: result.rows[0].position }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// DELETE /api/roadmaps/:id/items/:moduleId - Remove module from roadmap (does not unenroll from course) +router.delete("/:id/items/:moduleId", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const { id, moduleId } = req.params; + + try { + // Verify roadmap belongs to user and remove item + // Note: We do NOT auto-unenroll from courses as user may be taking them independently + const result = await pool.query( + `DELETE FROM roadmap_items + WHERE roadmap_id = $1 AND module_id = $2 + AND EXISTS ( + SELECT 1 FROM roadmaps + WHERE id = $1 AND user_id = $3 + ) + RETURNING module_id`, + [id, moduleId, user.userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ message: "Roadmap item not found" }); + } + + res.json({ message: "Module removed from roadmap" }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +// POST /api/roadmaps/generate - Auto-generate roadmap based on user's onboarding tags +router.post("/generate", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const { name } = req.body; + if (!name || !name.trim()) { + return res.status(400).json({ message: "Roadmap name is required" }); + } + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. Create the roadmap + const roadmapResult = await client.query( + `INSERT INTO roadmaps (user_id, name) + VALUES ($1, $2) + RETURNING id, name, user_id`, + [user.userId, name.trim()] + ); + + const roadmap = roadmapResult.rows[0]; + + // 2. Get user's tags from onboarding responses + const userTagsResult = await client.query( + `SELECT DISTINCT oqo.tag_id + FROM onboarding_responses or_table + JOIN onboarding_question_options oqo ON oqo.id = or_table.option_id + WHERE or_table.user_id = $1 AND oqo.tag_id IS NOT NULL`, + [user.userId] + ); + + const userTagIds = userTagsResult.rows.map(row => row.tag_id); + let modulesAdded = 0; + let enrolledCourses = []; + + if (userTagIds.length > 0) { + // 3. Get recommended modules based on user's tags + const modulesResult = await client.query( + `SELECT DISTINCT + mod.id, + COUNT(DISTINCT mt.tag_id) as matching_tags, + RANDOM() as random_score + FROM modules mod + JOIN courses c ON c.id = mod.course_id + JOIN module_tags mt ON mt.module_id = mod.id + WHERE c.organisation_id = $1 + AND mt.tag_id = ANY($2) + GROUP BY mod.id + ORDER BY matching_tags DESC, random_score + LIMIT 10`, // Limit to top 10 modules + [organisationId, userTagIds] + ); + + if (modulesResult.rows.length > 0) { + const moduleIds = modulesResult.rows.map(row => row.id); + + // 4. Get courses from these modules and auto-enroll user + const courseIds = await getCoursesFromModules(client, moduleIds); + enrolledCourses = await ensureUserEnrolledInCourses(client, user.userId, courseIds); + + // 5. Add modules to roadmap + for (let i = 0; i < modulesResult.rows.length; i++) { + const module = modulesResult.rows[i]; + await client.query( + `INSERT INTO roadmap_items (roadmap_id, module_id, position) + VALUES ($1, $2, $3)`, + [roadmap.id, module.id, i + 1] + ); + } + + modulesAdded = modulesResult.rows.length; + } + } + + await client.query("COMMIT"); + + res.status(201).json({ + roadmap, + modulesAdded, + enrolledCourses: enrolledCourses.length + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/server.js b/server.js index 83b5518..5139b11 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,8 @@ const courseRoutes = require("./routes/courses"); const userRoutes = require("./routes/users"); const reportsRoutes = require("./routes/reports"); const onboardingRoutes = require("./routes/onboarding"); +const roadmapRoutes = require("./routes/roadmaps"); +const materialRoutes = require("./routes/materials"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; @@ -46,6 +48,8 @@ app.use("/api/courses", courseRoutes); app.use("/api/users", userRoutes); app.use("/api/reports", reportsRoutes); app.use("/api/onboarding", onboardingRoutes); +app.use("/api/roadmaps", roadmapRoutes); +app.use("/api/materials", materialRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); From c7dbe02ce6a9f49e390b29780dd1fb76529ff529 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 13:44:56 +0800 Subject: [PATCH 07/32] Add endpoints for member settings --- database/schema.sql | 11 ++ routes/users.js | 325 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) diff --git a/database/schema.sql b/database/schema.sql index 7a3cc62..78ad18f 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -296,5 +296,16 @@ CREATE TABLE roadmap_items ( PRIMARY KEY(user_id, option_id) ); +-- USER SKILLS +CREATE TABLE user_skills ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + skill_id INTEGER NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + level VARCHAR(50) NOT NULL DEFAULT 'beginner', -- 'beginner', 'intermediate', 'advanced', 'expert' + acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, skill_id) +); + COMMIT; \ No newline at end of file diff --git a/routes/users.js b/routes/users.js index d2d4665..cde907d 100644 --- a/routes/users.js +++ b/routes/users.js @@ -1,7 +1,18 @@ const express = require("express"); +const bcrypt = require("bcrypt"); const pool = require("../database/db"); const router = express.Router(); +function setAuthCookie(res, payload) { + res.cookie("auth", JSON.stringify(payload), { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, + path: "/", + }); +} + router.get("/", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -84,4 +95,318 @@ router.delete("/", async (req, res) => { } }); +router.put("/profile", 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 { firstname, lastname, email } = req.body; + if (!firstname || !lastname || !email) { + return res.status(400).json({ message: "Missing required fields" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const emailCheck = await client.query( + "SELECT id FROM users WHERE email = $1 AND id != $2", + [email, session.userId] + ); + if (emailCheck.rows.length > 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Email already in use" }); + } + + const updateResult = await client.query( + "UPDATE users SET firstname = $1, lastname = $2, email = $3 WHERE id = $4 RETURNING id, firstname, lastname, email", + [firstname, lastname, email, session.userId] + ); + + if (updateResult.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "User not found" }); + } + + const updatedUser = updateResult.rows[0]; + + const updatedSession = { + ...session, + firstname: updatedUser.firstname, + lastname: updatedUser.lastname, + email: updatedUser.email, + }; + + setAuthCookie(res, updatedSession); + + await client.query("COMMIT"); + return res.json({ + message: "Profile updated successfully", + user: updatedSession, + }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error updating profile:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.put("/password", 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 { currentPassword, newPassword } = req.body; + if (!currentPassword || !newPassword) { + return res.status(400).json({ message: "Missing required fields" }); + } + + if (newPassword.length < 8) { + return res + .status(400) + .json({ message: "New password must be at least 8 characters long" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const userResult = await client.query( + "SELECT password_hash FROM users WHERE id = $1", + [session.userId] + ); + + if (userResult.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "User not found" }); + } + + const currentPasswordHash = userResult.rows[0].password_hash; + + const isCurrentPasswordValid = await bcrypt.compare( + currentPassword, + currentPasswordHash + ); + if (!isCurrentPasswordValid) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Current password is incorrect" }); + } + + const newPasswordHash = await bcrypt.hash(newPassword, 10); + + await client.query("UPDATE users SET password_hash = $1 WHERE id = $2", [ + newPasswordHash, + session.userId, + ]); + + await client.query("COMMIT"); + return res.json({ message: "Password updated successfully" }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error updating password:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.get("/skills", 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 client = await pool.connect(); + try { + const userSkillsResult = await client.query( + `SELECT us.id, us.skill_id, s.name as skill_name, us.level + FROM user_skills us + JOIN skills s ON us.skill_id = s.id + WHERE us.user_id = $1 + ORDER BY s.name`, + [session.userId] + ); + + const allSkillsResult = await client.query( + "SELECT id, name FROM skills ORDER BY name" + ); + + return res.json({ + userSkills: userSkillsResult.rows, + availableSkills: allSkillsResult.rows, + }); + } catch (error) { + console.error("Error fetching skills:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/skills", 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 { skill_id, level } = req.body; + if (!skill_id || !level) { + return res.status(400).json({ message: "Missing required fields" }); + } + + const validLevels = ["beginner", "intermediate", "advanced", "expert"]; + if (!validLevels.includes(level)) { + return res.status(400).json({ message: "Invalid skill level" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const skillCheck = await client.query( + "SELECT id FROM skills WHERE id = $1", + [skill_id] + ); + if (skillCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Skill not found" }); + } + + const existingSkill = await client.query( + "SELECT id FROM user_skills WHERE user_id = $1 AND skill_id = $2", + [session.userId, skill_id] + ); + if (existingSkill.rows.length > 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Skill already added" }); + } + + await client.query( + "INSERT INTO user_skills (user_id, skill_id, level) VALUES ($1, $2, $3)", + [session.userId, skill_id, level] + ); + + await client.query("COMMIT"); + return res.json({ message: "Skill added successfully" }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error adding skill:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.put("/skills", 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 { skill_id, level } = req.body; + if (!skill_id || !level) { + return res.status(400).json({ message: "Missing required fields" }); + } + + const validLevels = ["beginner", "intermediate", "advanced", "expert"]; + if (!validLevels.includes(level)) { + return res.status(400).json({ message: "Invalid skill level" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const updateResult = await client.query( + "UPDATE user_skills SET level = $1, updated_at = NOW() WHERE user_id = $2 AND skill_id = $3", + [level, session.userId, skill_id] + ); + + if (updateResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Skill not found" }); + } + + await client.query("COMMIT"); + return res.json({ message: "Skill level updated successfully" }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error updating skill:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.delete("/skills", 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 { skill_id } = req.body; + if (!skill_id) { + return res.status(400).json({ message: "Missing skill ID" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const deleteResult = await client.query( + "DELETE FROM user_skills WHERE user_id = $1 AND skill_id = $2", + [session.userId, skill_id] + ); + + if (deleteResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Skill not found" }); + } + + await client.query("COMMIT"); + return res.json({ message: "Skill removed successfully" }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error removing skill:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + + module.exports = router; From a514b63ee89eb705a14980593fe11c7b4dc1c3d6 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 17:54:15 +0800 Subject: [PATCH 08/32] Make a more complicated roadmap creation --- .../add_channel_level_onboarding.sql | 15 + database/migrations/user_preferences.sql | 34 ++ database/schema.sql | 99 ++-- routes/courses.js | 544 +++++++++++++----- routes/materials.js | 245 ++++++-- routes/onboarding.js | 183 +++++- routes/reports.js | 88 ++- routes/roadmaps-helpers.js | 122 ++++ routes/roadmaps.js | 221 +++---- routes/users.js | 310 ++++++++++ 10 files changed, 1506 insertions(+), 355 deletions(-) create mode 100644 database/migrations/add_channel_level_onboarding.sql create mode 100644 database/migrations/user_preferences.sql create mode 100644 routes/roadmaps-helpers.js diff --git a/database/migrations/add_channel_level_onboarding.sql b/database/migrations/add_channel_level_onboarding.sql new file mode 100644 index 0000000..060d0b5 --- /dev/null +++ b/database/migrations/add_channel_level_onboarding.sql @@ -0,0 +1,15 @@ +-- Migration to add channel_id and level_id to onboarding_question_options table +-- This enables channel and level preferences in the onboarding system + +BEGIN; + +-- Add channel_id and level_id columns to onboarding_question_options table +ALTER TABLE onboarding_question_options +ADD COLUMN channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, +ADD COLUMN level_id INTEGER REFERENCES levels(id) ON DELETE CASCADE; + +-- Add indexes for better performance +CREATE INDEX idx_onboarding_question_options_channel_id ON onboarding_question_options(channel_id); +CREATE INDEX idx_onboarding_question_options_level_id ON onboarding_question_options(level_id); + +COMMIT; \ No newline at end of file diff --git a/database/migrations/user_preferences.sql b/database/migrations/user_preferences.sql new file mode 100644 index 0000000..13a98af --- /dev/null +++ b/database/migrations/user_preferences.sql @@ -0,0 +1,34 @@ +-- Migration: Add user channel and level preferences tables +-- Purpose: Allow users to set their preferred channels and levels for personalized recommendations + +-- Table for user channel preferences +CREATE TABLE user_channels ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + preference_rank INTEGER NOT NULL DEFAULT 1, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, channel_id) +); + +-- Table for user level preferences +CREATE TABLE user_levels ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + level_id INTEGER NOT NULL REFERENCES levels(id) ON DELETE CASCADE, + preference_rank INTEGER NOT NULL DEFAULT 1, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, level_id) +); + +-- Indexes for better query performance +CREATE INDEX idx_user_channels_user_id ON user_channels(user_id); +CREATE INDEX idx_user_channels_channel_id ON user_channels(channel_id); +CREATE INDEX idx_user_levels_user_id ON user_levels(user_id); +CREATE INDEX idx_user_levels_level_id ON user_levels(level_id); + +-- Comments for documentation +COMMENT ON TABLE user_channels IS 'Stores user preferences for learning channels/topics'; +COMMENT ON TABLE user_levels IS 'Stores user preferences for difficulty levels'; +COMMENT ON COLUMN user_channels.preference_rank IS 'Ranking of channel preference (1 = highest priority)'; +COMMENT ON COLUMN user_levels.preference_rank IS 'Ranking of level preference (1 = highest priority)'; \ No newline at end of file diff --git a/database/schema.sql b/database/schema.sql index 78ad18f..20ec734 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -111,8 +111,12 @@ CREATE TABLE materials ( ); CREATE TABLE skills ( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL UNIQUE + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + organisation_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(name, organisation_id) ); CREATE TABLE material_skills ( @@ -175,52 +179,41 @@ CREATE TABLE quiz_answers ( ); --- 8. TAGS -CREATE TABLE tags ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) NOT NULL, +-- 8. CHANNELS, LEVELS & SKILLS +-- Channels (topics) for courses +CREATE TABLE channels ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, organisation_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, - UNIQUE (name, organisation_id) -); - -CREATE TABLE course_tags ( - course_id INTEGER NOT NULL - REFERENCES courses(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL - REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY(course_id, tag_id) -); - -CREATE TABLE module_tags ( - module_id INTEGER NOT NULL - REFERENCES modules(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL - REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY(module_id, tag_id) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(name, organisation_id) ); -CREATE TABLE revision_tags ( - revision_id INTEGER NOT NULL - REFERENCES revisions(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL - REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY(revision_id, tag_id) +-- Levels (difficulty) for courses +CREATE TABLE levels ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + organisation_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(name, organisation_id) ); -CREATE TABLE quiz_tags ( - quiz_id INTEGER NOT NULL - REFERENCES quizzes(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL - REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY(quiz_id, tag_id) +-- Course-channel-level associations +CREATE TABLE course_channels ( + course_id INTEGER NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + level_id INTEGER NOT NULL REFERENCES levels(id) ON DELETE CASCADE, + PRIMARY KEY(course_id, channel_id, level_id) ); -CREATE TABLE question_tags ( - question_id INTEGER NOT NULL - REFERENCES questions(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL - REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY(question_id, tag_id) +-- Module-skills associations +CREATE TABLE module_skills ( + module_id INTEGER NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + skill_id INTEGER NOT NULL REFERENCES skills(id) ON DELETE CASCADE, + PRIMARY KEY(module_id, skill_id) ); @@ -286,7 +279,9 @@ CREATE TABLE roadmap_items ( id SERIAL PRIMARY KEY, question_id INTEGER NOT NULL REFERENCES onboarding_questions(id) ON DELETE CASCADE, option_text TEXT NOT NULL, - tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE + skill_id INTEGER REFERENCES skills(id) ON DELETE CASCADE, + channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, + level_id INTEGER REFERENCES levels(id) ON DELETE CASCADE, ); -- Table to store user responses @@ -307,5 +302,25 @@ CREATE TABLE user_skills ( UNIQUE(user_id, skill_id) ); +-- Table for user channel preferences +CREATE TABLE user_channels ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + preference_rank INTEGER NOT NULL DEFAULT 1, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, channel_id) +); + +-- Table for user level preferences +CREATE TABLE user_levels ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + level_id INTEGER NOT NULL REFERENCES levels(id) ON DELETE CASCADE, + preference_rank INTEGER NOT NULL DEFAULT 1, + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(user_id, level_id) +); + COMMIT; \ No newline at end of file diff --git a/routes/courses.js b/routes/courses.js index 2065ff2..b853795 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -31,10 +31,14 @@ router.post("/", async (req, res) => { } const courseName = req.body.courseName; const courseDescription = req.body.description || ""; - const courseTags = req.body.tags || []; + const channelId = req.body.channelId; + const levelId = req.body.levelId; if (!courseName) { return res.status(400).json({ message: "courseName is required" }); } + if (!channelId || !levelId) { + return res.status(400).json({ message: "channelId and levelId are required" }); + } const client = await pool.connect(); try { @@ -51,20 +55,13 @@ router.post("/", async (req, res) => { throw new Error("Failed to create course"); } - if (courseTags.length) { - const courseId = courseRes.rows[0].id; - for (const t of courseTags) { - if (typeof t !== "number") { - throw new Error("Invalid tag ID"); - } - await client.query( - `INSERT INTO course_tags (course_id, tag_id) - VALUES ($1, $2) + const courseId = courseRes.rows[0].id; + await client.query( + `INSERT INTO course_channels (course_id, channel_id, level_id) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`, - [courseId, t] - ); - } - } + [courseId, channelId, levelId] + ); await client.query("COMMIT"); return res.status(201).json({ success: true }); @@ -102,20 +99,22 @@ router.get("/", async (req, res) => { c.id, c.name, c.description, - -- aggregate tags into an array of { id, name } - COALESCE( - JSON_AGG( - JSON_BUILD_OBJECT('id', t.id, 'name', t.name) - ) FILTER (WHERE t.id IS NOT NULL), - '[]' - ) AS tags + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level FROM courses c - LEFT JOIN course_tags ct - ON ct.course_id = c.id - LEFT JOIN tags t - ON t.id = ct.tag_id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN channels ch ON ch.id = cc.channel_id + LEFT JOIN levels l ON l.id = cc.level_id WHERE c.organisation_id = $1 - GROUP BY c.id, c.name, c.description ORDER BY c.name `, [organisationId] @@ -163,19 +162,37 @@ router.post("/get-course", async (req, res) => { return res.status(404).json({ message: "Course not found" }); } - const tags = await client.query( - `SELECT t.id, t.name FROM course_tags ct - JOIN tags t ON ct.tag_id = t.id - WHERE ct.course_id = $1`, + const channelLevelRes = await client.query( + `SELECT + ch.id as channel_id, ch.name as channel_name, ch.description as channel_description, + l.id as level_id, l.name as level_name, l.description as level_description, l.sort_order + FROM course_channels cc + JOIN channels ch ON cc.channel_id = ch.id + JOIN levels l ON cc.level_id = l.id + WHERE cc.course_id = $1`, [courseId] ); + const channelLevel = channelLevelRes.rows[0] || null; + const channel = channelLevel ? { + id: channelLevel.channel_id, + name: channelLevel.channel_name, + description: channelLevel.channel_description + } : null; + const level = channelLevel ? { + id: channelLevel.level_id, + name: channelLevel.level_name, + description: channelLevel.level_description, + sort_order: channelLevel.sort_order + } : null; + await client.query("COMMIT"); return res.status(200).json({ id: courseRes.rows[0].id, name: courseRes.rows[0].name, description: courseRes.rows[0].description, - tags: tags.rows || [], + channel, + level, }); } catch (err) { await client.query("ROLLBACK"); @@ -245,8 +262,9 @@ router.put("/", async (req, res) => { const courseId = req.body.courseId; const courseName = req.body.courseName; const courseDescription = req.body.description || ""; - const courseTags = req.body.tags || []; - const updateTags = req.body.updateTags || false; + const channelId = req.body.channelId; + const levelId = req.body.levelId; + const updateChannelLevel = req.body.updateChannelLevel || false; if (!courseId || !courseName) { return res .status(400) @@ -272,25 +290,17 @@ router.put("/", async (req, res) => { // const courseId = courseRes.rows[0].id; - if (updateTags) { - await client.query(`DELETE FROM course_tags WHERE course_id = $1`, [ + if (updateChannelLevel && channelId && levelId) { + await client.query(`DELETE FROM course_channels WHERE course_id = $1`, [ courseId, ]); - if (courseTags.length) { - const courseId = courseRes.rows[0].id; - for (const t of courseTags) { - if (typeof t !== "number") { - throw new Error("Invalid tag ID"); - } - await client.query( - `INSERT INTO course_tags (course_id, tag_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING`, - [courseId, t] - ); - } - } + await client.query( + `INSERT INTO course_channels (course_id, channel_id, level_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING`, + [courseId, channelId, levelId] + ); } await client.query("COMMIT"); @@ -338,15 +348,15 @@ router.post("/get-modules", async (req, res) => { m.position, COALESCE( JSON_AGG( - JSON_BUILD_OBJECT('id', t.id, 'name', t.name) - ) FILTER (WHERE t.id IS NOT NULL), + JSON_BUILD_OBJECT('id', s.id, 'name', s.name, 'description', s.description) + ) FILTER (WHERE s.id IS NOT NULL), '[]' - ) AS tags + ) AS skills FROM modules m - LEFT JOIN module_tags mt - ON mt.module_id = m.id - LEFT JOIN tags t - ON t.id = mt.tag_id + LEFT JOIN module_skills ms + ON ms.module_id = m.id + LEFT JOIN skills s + ON s.id = ms.skill_id WHERE m.course_id = $1 GROUP BY m.id ORDER BY m.position @@ -379,7 +389,7 @@ router.post("/add-module", upload.single("file"), async (req, res) => { } const { courseId, name, type, description = "", questions } = req.body; - let moduleTags = req.body.moduleTags || []; + let moduleSkills = req.body.moduleSkills || []; if (!courseId || !name || !type) { return res .status(400) @@ -396,8 +406,8 @@ router.post("/add-module", upload.single("file"), async (req, res) => { .json({ message: "file is required for non-quiz modules" }); } - if (typeof moduleTags === "string") { - moduleTags = JSON.parse(moduleTags); + if (typeof moduleSkills === "string") { + moduleSkills = JSON.parse(moduleSkills); } const client = await pool.connect(); @@ -426,16 +436,16 @@ router.post("/add-module", upload.single("file"), async (req, res) => { const module_id = moduleRes.rows[0].id; - if (moduleTags && moduleTags.length) { - for (const t of moduleTags) { - if (typeof t.id !== "number") { - throw new Error("Invalid tag ID"); + if (moduleSkills && moduleSkills.length) { + for (const s of moduleSkills) { + if (typeof s.id !== "number") { + throw new Error("Invalid skill ID"); } await client.query( - `INSERT INTO module_tags (module_id, tag_id) + `INSERT INTO module_skills (module_id, skill_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, - [module_id, t.id] + [module_id, s.id] ); } } @@ -585,11 +595,11 @@ router.post("/get-module", async (req, res) => { return res.status(404).json({ message: "Module not found" }); } - const moduleTagsRes = await client.query( - `SELECT tag_id, t.name AS tag_name - FROM module_tags mt - JOIN tags t ON mt.tag_id = t.id - WHERE mt.module_id = $1`, + const moduleSkillsRes = await client.query( + `SELECT skill_id, s.name AS skill_name, s.description AS skill_description + FROM module_skills ms + JOIN skills s ON ms.skill_id = s.id + WHERE ms.module_id = $1`, [moduleId] ); @@ -661,9 +671,10 @@ router.post("/get-module", async (req, res) => { } } - module.tags = moduleTagsRes.rows.map((r) => ({ - id: r.tag_id, - name: r.tag_name, + module.skills = moduleSkillsRes.rows.map((r) => ({ + id: r.skill_id, + name: r.skill_name, + description: r.skill_description, })); await client.query("COMMIT"); @@ -697,10 +708,10 @@ router.put("/update-module", upload.single("file"), async (req, res) => { description = "", type, questions, - updateTags = false, + updateSkills = false, } = req.body; - let moduleTags = req.body.moduleTags || []; + let moduleSkills = req.body.moduleSkills || []; if (!moduleId || !name) { return res @@ -708,8 +719,8 @@ router.put("/update-module", upload.single("file"), async (req, res) => { .json({ message: "moduleId, name and type are required" }); } - if (typeof moduleTags === "string") { - moduleTags = JSON.parse(moduleTags); + if (typeof moduleSkills === "string") { + moduleSkills = JSON.parse(moduleSkills); } const file = req.file; // may be undefined for quiz @@ -739,22 +750,22 @@ router.put("/update-module", upload.single("file"), async (req, res) => { ); } - if (updateTags) { + if (updateSkills) { await client.query( - `DELETE FROM module_tags + `DELETE FROM module_skills WHERE module_id = $1`, [moduleId] ); - if (moduleTags && moduleTags.length) { - for (const t of moduleTags) { - if (typeof t.id !== "number") { - throw new Error("Invalid tag ID"); + if (moduleSkills && moduleSkills.length) { + for (const s of moduleSkills) { + if (typeof s.id !== "number") { + throw new Error("Invalid skill ID"); } await client.query( - `INSERT INTO module_tags (module_id, tag_id) + `INSERT INTO module_skills (module_id, skill_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, - [moduleId, t.id] + [moduleId, s.id] ); } } @@ -944,7 +955,7 @@ router.get("/all-user-courses", async (req, res) => { try { await client.query("BEGIN"); - // 1) “Enrolled” courses (status = 'enrolled') + // 1) "Enrolled" courses (status = 'enrolled') const enrolledRes = await client.query( ` SELECT @@ -955,14 +966,26 @@ router.get("/all-user-courses", async (req, res) => { COUNT(ms.id) FILTER (WHERE ms.status = 'completed') AS completed_modules, ( - SELECT COALESCE( - JSON_AGG(JSON_BUILD_OBJECT('id', t2.id, 'name', t2.name)), - '[]' + SELECT JSON_BUILD_OBJECT( + 'id', ch2.id, + 'name', ch2.name, + 'description', ch2.description + ) + FROM course_channels cc2 + JOIN channels ch2 ON ch2.id = cc2.channel_id + WHERE cc2.course_id = c.id + ) AS channel, + ( + SELECT JSON_BUILD_OBJECT( + 'id', l2.id, + 'name', l2.name, + 'description', l2.description, + 'sort_order', l2.sort_order ) - FROM course_tags ct2 - JOIN tags t2 ON t2.id = ct2.tag_id - WHERE ct2.course_id = c.id - ) AS tags + FROM course_channels cc2 + JOIN levels l2 ON l2.id = cc2.level_id + WHERE cc2.course_id = c.id + ) AS level FROM courses c 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 @@ -975,7 +998,7 @@ GROUP BY c.id, c.name, c.description; [userId] ); - // 2) “Completed” courses (status = 'completed') + // 2) "Completed" courses (status = 'completed') const completedRes = await client.query( ` SELECT @@ -986,14 +1009,26 @@ GROUP BY c.id, c.name, c.description; COUNT(ms.id) FILTER (WHERE ms.status = 'completed') AS completed_modules, ( - SELECT COALESCE( - JSON_AGG(JSON_BUILD_OBJECT('id', t2.id, 'name', t2.name)), - '[]' + SELECT JSON_BUILD_OBJECT( + 'id', ch2.id, + 'name', ch2.name, + 'description', ch2.description + ) + FROM course_channels cc2 + JOIN channels ch2 ON ch2.id = cc2.channel_id + WHERE cc2.course_id = c.id + ) AS channel, + ( + SELECT JSON_BUILD_OBJECT( + 'id', l2.id, + 'name', l2.name, + 'description', l2.description, + 'sort_order', l2.sort_order ) - FROM course_tags ct2 - JOIN tags t2 ON t2.id = ct2.tag_id - WHERE ct2.course_id = c.id - ) AS tags + FROM course_channels cc2 + JOIN levels l2 ON l2.id = cc2.level_id + WHERE cc2.course_id = c.id + ) AS level 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 @@ -1009,24 +1044,31 @@ GROUP BY c.id, c.name, c.description; const otherRes = await client.query( ` SELECT c.id, c.name, c.description, - COALESCE( - JSON_AGG( - JSON_BUILD_OBJECT('id', t.id, 'name', t.name) - ) FILTER (WHERE t.id IS NOT NULL), - '[]' - ) AS tags + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level FROM courses c - LEFT JOIN course_tags ct - ON ct.course_id = c.id - LEFT JOIN tags t - ON t.id = ct.tag_id + LEFT JOIN course_channels cc + ON cc.course_id = c.id + LEFT JOIN channels ch + ON ch.id = cc.channel_id + LEFT JOIN levels l + ON l.id = cc.level_id WHERE c.organisation_id = $1 AND c.id NOT IN ( SELECT course_id FROM enrollments WHERE user_id = $2 ) - GROUP BY c.id, c.name, c.description + GROUP BY c.id, c.name, c.description, ch.id, ch.name, ch.description, l.id, l.name, l.description, l.sort_order `, [organisationId, userId] ); @@ -1798,7 +1840,39 @@ router.post("/mark-module-completed", async (req, res) => { } }); -router.post("/add-tags", async (req, res) => { +// CHANNELS ENDPOINTS +router.get("/channels", 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 organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const client = await pool.connect(); + try { + const { rows } = await client.query( + `SELECT id, name, description FROM channels WHERE organisation_id = $1 ORDER BY name`, + [organisationId] + ); + res.json(rows); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/add-channel", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -1809,7 +1883,6 @@ router.post("/add-tags", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } - const userId = session.userId; const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -1818,37 +1891,193 @@ router.post("/add-tags", async (req, res) => { if (!organisationId) { return res.status(400).json({ message: "Organization required" }); } - const { tags } = req.body; - if (!Array.isArray(tags)) { - return res.status(400).json({ message: "tags[] are required" }); + + const { name, description } = req.body; + if (!name) { + return res.status(400).json({ message: "name is required" }); } const client = await pool.connect(); try { await client.query("BEGIN"); - for (const tag of tags) { - if (!tag.name) { - continue; // skip if tag has no name - } - await client.query( - `INSERT INTO tags (name, organisation_id) - VALUES ($1, $2) - ON CONFLICT (name, organisation_id) DO NOTHING`, - [tag.name, organisationId] - ); + await client.query( + `INSERT INTO channels (name, description, organisation_id) + VALUES ($1, $2, $3)`, + [name, description || '', organisationId] + ); + await client.query("COMMIT"); + return res.status(201).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + if (err.code === "23505") { + return res.status(400).json({ message: "Channel name already exists" }); } + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.delete("/delete-channel", 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 organisationId = session.organisation?.id; + const isAdmin = session.organisation?.role === "admin"; + if (!isAdmin) { + return res.status(403).json({ message: "Forbidden" }); + } + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + const { channelId } = req.body; + if (!channelId) { + return res.status(400).json({ message: "channelId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`DELETE FROM channels WHERE id = $1 AND organisation_id = $2`, [channelId, organisationId]); + await client.query("COMMIT"); + return res.status(200).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// LEVELS ENDPOINTS +router.get("/levels", 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 organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const client = await pool.connect(); + try { + const { rows } = await client.query( + `SELECT id, name, description, sort_order FROM levels WHERE organisation_id = $1 ORDER BY sort_order, name`, + [organisationId] + ); + res.json(rows); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/add-level", 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 organisationId = session.organisation?.id; + const isAdmin = session.organisation?.role === "admin"; + if (!isAdmin) { + return res.status(403).json({ message: "Forbidden" }); + } + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const { name, description, sort_order } = req.body; + if (!name) { + return res.status(400).json({ message: "name is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + `INSERT INTO levels (name, description, sort_order, organisation_id) + VALUES ($1, $2, $3, $4)`, + [name, description || '', sort_order || 0, organisationId] + ); await client.query("COMMIT"); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); console.error(err); + if (err.code === "23505") { + return res.status(400).json({ message: "Level name already exists" }); + } return res.status(500).json({ message: "Server error" }); } finally { client.release(); } }); -router.get("/tags", async (req, res) => { +router.delete("/delete-level", 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 organisationId = session.organisation?.id; + const isAdmin = session.organisation?.role === "admin"; + if (!isAdmin) { + return res.status(403).json({ message: "Forbidden" }); + } + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + const { levelId } = req.body; + if (!levelId) { + return res.status(400).json({ message: "levelId is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query(`DELETE FROM levels WHERE id = $1 AND organisation_id = $2`, [levelId, organisationId]); + await client.query("COMMIT"); + return res.status(200).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// SKILLS ENDPOINTS +router.get("/skills", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -1867,7 +2096,7 @@ router.get("/tags", async (req, res) => { const client = await pool.connect(); try { const { rows } = await client.query( - `SELECT id, name FROM tags WHERE organisation_id = $1 ORDER BY name`, + `SELECT id, name, description FROM skills WHERE organisation_id = $1 ORDER BY name`, [organisationId] ); res.json(rows); @@ -1879,7 +2108,54 @@ router.get("/tags", async (req, res) => { } }); -router.delete("/delete-tag", async (req, res) => { +router.post("/add-skill", 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 organisationId = session.organisation?.id; + const isAdmin = session.organisation?.role === "admin"; + if (!isAdmin) { + return res.status(403).json({ message: "Forbidden" }); + } + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const { name, description } = req.body; + if (!name) { + return res.status(400).json({ message: "name is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + `INSERT INTO skills (name, description, organisation_id) + VALUES ($1, $2, $3)`, + [name, description || '', organisationId] + ); + await client.query("COMMIT"); + return res.status(201).json({ success: true }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + if (err.code === "23505") { + return res.status(400).json({ message: "Skill name already exists" }); + } + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.delete("/delete-skill", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -1898,15 +2174,15 @@ router.delete("/delete-tag", async (req, res) => { if (!organisationId) { return res.status(400).json({ message: "Organization required" }); } - const { tagId } = req.body; - if (!tagId) { - return res.status(400).json({ message: "tagId is required" }); + const { skillId } = req.body; + if (!skillId) { + return res.status(400).json({ message: "skillId is required" }); } const client = await pool.connect(); try { await client.query("BEGIN"); - await client.query(`DELETE FROM tags WHERE id = $1 AND organisation_id = $2`, [tagId, organisationId]); + await client.query(`DELETE FROM skills WHERE id = $1 AND organisation_id = $2`, [skillId, organisationId]); await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) { diff --git a/routes/materials.js b/routes/materials.js index 3955a46..839cc6d 100644 --- a/routes/materials.js +++ b/routes/materials.js @@ -1,6 +1,7 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); +const { getUserPreferences } = require("./roadmaps-helpers"); // Helper function to parse auth cookie function getAuthUser(req) { @@ -25,11 +26,11 @@ router.get("/", async (req, res) => { return res.status(400).json({ message: "Organization required" }); } - const { tag_ids } = req.query; // Comma-separated tag IDs + const { skill_ids } = req.query; // Comma-separated skill IDs try { let query = ` - SELECT DISTINCT + SELECT mod.id, mod.title as module_title, mod.description, @@ -37,25 +38,42 @@ router.get("/", async (req, res) => { mod.file_url, c.name as course_name, c.id as course_id, - ARRAY_AGG(DISTINCT t.name) as tags + ARRAY_AGG(DISTINCT s.name) as skills, + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level FROM modules mod JOIN courses c ON c.id = mod.course_id - LEFT JOIN module_tags mt ON mt.module_id = mod.id - LEFT JOIN tags t ON t.id = mt.tag_id + LEFT JOIN module_skills ms ON ms.module_id = mod.id + LEFT JOIN skills s ON s.id = ms.skill_id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN channels ch ON ch.id = cc.channel_id + LEFT JOIN levels l ON l.id = cc.level_id WHERE c.organisation_id = $1`; - + const params = [organisationId]; - - // If tag_ids are provided, filter by those tags - if (tag_ids) { - const tagIdArray = tag_ids.split(',').map(id => parseInt(id)).filter(id => !isNaN(id)); - if (tagIdArray.length > 0) { - query += ` AND mt.tag_id = ANY($2)`; - params.push(tagIdArray); + + // If skill_ids are provided, filter by those skills + if (skill_ids) { + const skillIdArray = skill_ids + .split(",") + .map((id) => parseInt(id)) + .filter((id) => !isNaN(id)); + if (skillIdArray.length > 0) { + query += ` AND ms.skill_id = ANY($2)`; + params.push(skillIdArray); } } - - query += ` GROUP BY mod.id, mod.title, mod.description, mod.module_type, mod.file_url, c.name, c.id + + query += ` GROUP BY mod.id, mod.title, mod.description, mod.module_type, mod.file_url, c.name, c.id, ch.id, ch.name, ch.description, l.id, l.name, l.description, l.sort_order ORDER BY c.name, mod.title`; const result = await pool.query(query, params); @@ -67,7 +85,117 @@ router.get("/", async (req, res) => { } }); -// GET /api/materials/by-user-tags - Get modules recommended based on user's onboarding tags +// GET /api/materials/by-user-skills - Get modules recommended based on user's skills and preferences +router.get("/by-user-skills", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const client = await pool.connect(); + try { + // Get user's skills from onboarding responses + const userSkillsResult = await client.query( + `SELECT DISTINCT oqo.skill_id + FROM onboarding_responses or_table + JOIN onboarding_question_options oqo ON oqo.id = or_table.option_id + WHERE or_table.user_id = $1 AND oqo.skill_id IS NOT NULL`, + [user.userId] + ); + + const userSkillIds = userSkillsResult.rows.map((row) => row.skill_id); + + if (userSkillIds.length === 0) { + return res.json({ materials: [] }); + } + + // Get user's channel and level preferences (combines member settings and onboarding) + const userPreferences = await getUserPreferences(client, user.userId); + + // Get modules that match user's skills with enhanced scoring based on member preferences + const result = await client.query( + `SELECT DISTINCT + mod.id, + mod.title as module_title, + mod.description, + mod.module_type, + mod.file_url, + c.name as course_name, + c.id as course_id, + ARRAY_AGG(DISTINCT s.name) as skills, + COUNT(DISTINCT ms.skill_id) as matching_skills, + CASE + WHEN cc.channel_id = ANY($3) THEN + CASE + WHEN cc.channel_id = ANY($4) THEN 5 -- Member setting preference (highest priority) + ELSE 3 -- Onboarding preference + END + WHEN cc.channel_id IS NOT NULL THEN 1 + ELSE 0 + END as channel_match, + CASE + WHEN cc.level_id = ANY($5) THEN + CASE + WHEN cc.level_id = ANY($6) THEN 5 -- Member setting preference (highest priority) + ELSE 3 -- Onboarding preference + END + WHEN cc.level_id IS NOT NULL THEN 1 + ELSE 0 + END as level_match, + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level + FROM modules mod + JOIN courses c ON c.id = mod.course_id + JOIN module_skills ms ON ms.module_id = mod.id + JOIN skills s ON s.id = ms.skill_id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN channels ch ON ch.id = cc.channel_id + LEFT JOIN levels l ON l.id = cc.level_id + WHERE c.organisation_id = $1 + AND ms.skill_id = ANY($2) + GROUP BY mod.id, mod.title, mod.description, mod.module_type, mod.file_url, c.name, c.id, ch.id, ch.name, ch.description, l.id, l.name, l.description, l.sort_order, cc.channel_id, cc.level_id + ORDER BY matching_skills DESC, channel_match DESC, level_match DESC, c.name, mod.title`, + [ + organisationId, + userSkillIds, + userPreferences.channels.all, + userPreferences.channels.member, + userPreferences.levels.all, + userPreferences.levels.member, + ] + ); + + res.json({ + materials: result.rows, + userSkills: userSkillIds, + userPreferences: { + channels: userPreferences.channels, + levels: userPreferences.levels, + }, + }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// Backward compatibility: redirect old by-user-tags to new by-user-skills router.get("/by-user-tags", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -79,24 +207,28 @@ router.get("/by-user-tags", async (req, res) => { return res.status(400).json({ message: "Organization required" }); } + const client = await pool.connect(); try { - // Get user's tags from onboarding responses - const userTagsResult = await pool.query( - `SELECT DISTINCT oqo.tag_id + // Get user's skills from onboarding responses + const userSkillsResult = await client.query( + `SELECT DISTINCT oqo.skill_id FROM onboarding_responses or_table JOIN onboarding_question_options oqo ON oqo.id = or_table.option_id - WHERE or_table.user_id = $1 AND oqo.tag_id IS NOT NULL`, + WHERE or_table.user_id = $1 AND oqo.skill_id IS NOT NULL`, [user.userId] ); - const userTagIds = userTagsResult.rows.map(row => row.tag_id); - - if (userTagIds.length === 0) { + const userSkillIds = userSkillsResult.rows.map((row) => row.skill_id); + + if (userSkillIds.length === 0) { return res.json({ materials: [] }); } - // Get modules that match user's tags - const result = await pool.query( + // Get user's channel and level preferences (combines member settings and onboarding) + const userPreferences = await getUserPreferences(client, user.userId); + + // Get modules that match user's skills with enhanced scoring based on member preferences + const result = await client.query( `SELECT DISTINCT mod.id, mod.title as module_title, @@ -105,27 +237,68 @@ router.get("/by-user-tags", async (req, res) => { mod.file_url, c.name as course_name, c.id as course_id, - ARRAY_AGG(DISTINCT t.name) as tags, - COUNT(DISTINCT mt.tag_id) as matching_tags + ARRAY_AGG(DISTINCT s.name) as skills, + COUNT(DISTINCT ms.skill_id) as matching_skills, + CASE + WHEN cc.channel_id = ANY($3) THEN + CASE + WHEN cc.channel_id = ANY($4) THEN 5 -- Member setting preference (highest priority) + ELSE 3 -- Onboarding preference + END + WHEN cc.channel_id IS NOT NULL THEN 1 + ELSE 0 + END as channel_match, + CASE + WHEN cc.level_id = ANY($5) THEN + CASE + WHEN cc.level_id = ANY($6) THEN 5 -- Member setting preference (highest priority) + ELSE 3 -- Onboarding preference + END + WHEN cc.level_id IS NOT NULL THEN 1 + ELSE 0 + END as level_match, + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level FROM modules mod JOIN courses c ON c.id = mod.course_id - JOIN module_tags mt ON mt.module_id = mod.id - JOIN tags t ON t.id = mt.tag_id + JOIN module_skills ms ON ms.module_id = mod.id + JOIN skills s ON s.id = ms.skill_id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN channels ch ON ch.id = cc.channel_id + LEFT JOIN levels l ON l.id = cc.level_id WHERE c.organisation_id = $1 - AND mt.tag_id = ANY($2) - GROUP BY mod.id, mod.title, mod.description, mod.module_type, mod.file_url, c.name, c.id - ORDER BY matching_tags DESC, c.name, mod.title`, - [organisationId, userTagIds] + AND ms.skill_id = ANY($2) + GROUP BY mod.id, mod.title, mod.description, mod.module_type, mod.file_url, c.name, c.id, ch.id, ch.name, ch.description, l.id, l.name, l.description, l.sort_order, cc.channel_id, cc.level_id + ORDER BY matching_skills DESC, channel_match DESC, level_match DESC, c.name, mod.title`, + [ + organisationId, + userSkillIds, + userPreferences.channels.all, + userPreferences.channels.member, + userPreferences.levels.all, + userPreferences.levels.member, + ] ); - res.json({ + res.json({ materials: result.rows, - userTags: userTagIds + userTags: userSkillIds, // Keep old property name for backward compatibility }); } catch (err) { console.error(err); res.status(500).json({ message: "Server error" }); + } finally { + client.release(); } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/onboarding.js b/routes/onboarding.js index 021b8d5..97570d0 100644 --- a/routes/onboarding.js +++ b/routes/onboarding.js @@ -46,9 +46,13 @@ router.get("/questions", async (req, res) => { for (const question of questionsResult.rows) { const optionsResult = await pool.query( ` - SELECT oqo.id, oqo.option_text, oqo.tag_id, t.name as tag_name + SELECT oqo.id, oqo.option_text, oqo.skill_id, s.name as skill_name, s.description as skill_description, + oqo.channel_id, ch.name as channel_name, ch.description as channel_description, + oqo.level_id, l.name as level_name, l.description as level_description, l.sort_order FROM onboarding_question_options oqo - LEFT JOIN tags t ON t.id = oqo.tag_id + LEFT JOIN skills s ON s.id = oqo.skill_id + LEFT JOIN channels ch ON ch.id = oqo.channel_id + LEFT JOIN levels l ON l.id = oqo.level_id WHERE oqo.question_id = $1 ORDER BY oqo.id ASC `, @@ -118,7 +122,7 @@ router.post("/questions/:id/options", async (req, res) => { } const { id } = req.params; - const { option_text, tag_id } = req.body; + const { option_text, skill_id, channel_id, level_id } = req.body; if (!option_text) { return res.status(400).json({ message: "option_text is required" }); @@ -143,32 +147,62 @@ router.post("/questions/:id/options", async (req, res) => { return res.status(404).json({ message: "Question not found" }); } - let tagCheck = { rows: [] }; - if (tag_id) { - tagCheck = await client.query( - "SELECT id, name FROM tags WHERE id = $1 AND organisation_id = $2", - [tag_id, organisationId] + let skillCheck = { rows: [] }; + if (skill_id) { + skillCheck = await client.query( + "SELECT id, name, description FROM skills WHERE id = $1 AND organisation_id = $2", + [skill_id, organisationId] ); - if (tagCheck.rows.length === 0) { + if (skillCheck.rows.length === 0) { await client.query("ROLLBACK"); - return res.status(404).json({ message: "Tag not found" }); + return res.status(404).json({ message: "Skill not found" }); + } + } + + let channelCheck = { rows: [] }; + if (channel_id) { + channelCheck = await client.query( + "SELECT id, name, description FROM channels WHERE id = $1 AND organisation_id = $2", + [channel_id, organisationId] + ); + if (channelCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Channel not found" }); + } + } + + let levelCheck = { rows: [] }; + if (level_id) { + levelCheck = await client.query( + "SELECT id, name, description, sort_order FROM levels WHERE id = $1 AND organisation_id = $2", + [level_id, organisationId] + ); + if (levelCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Level not found" }); } } const result = await client.query( ` - INSERT INTO onboarding_question_options (question_id, option_text, tag_id) - VALUES ($1, $2, $3) - RETURNING id, option_text, tag_id + INSERT INTO onboarding_question_options (question_id, option_text, skill_id, channel_id, level_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, option_text, skill_id, channel_id, level_id `, - [id, option_text, tag_id || null] + [id, option_text, skill_id || null, channel_id || null, level_id || null] ); await client.query("COMMIT"); const option = { ...result.rows[0], - tag_name: tag_id ? tagCheck.rows[0].name : null, + skill_name: skill_id ? skillCheck.rows[0].name : null, + skill_description: skill_id ? skillCheck.rows[0].description : null, + channel_name: channel_id ? channelCheck.rows[0].name : null, + channel_description: channel_id ? channelCheck.rows[0].description : null, + level_name: level_id ? levelCheck.rows[0].name : null, + level_description: level_id ? levelCheck.rows[0].description : null, + level_sort_order: level_id ? levelCheck.rows[0].sort_order : null, }; res.status(201).json({ option }); @@ -324,9 +358,98 @@ router.post("/responses", async (req, res) => { [user.userId] ); + // Auto-generate first roadmap based on onboarding responses + try { + // Import helper functions (we'll need to add these as module-level functions) + const { getUserPreferences, getCoursesFromModules, ensureUserEnrolledInCourses } = require('./roadmaps-helpers'); + + // Get user preferences (skills, channels, levels) + const preferences = await getUserPreferences(client, user.userId); + + if (preferences.skills.length > 0) { + // Generate roadmap with modules based on user preferences + const modulesResult = await client.query( + `SELECT DISTINCT + mod.id, + COUNT(DISTINCT ms.skill_id) as matching_skills, + COALESCE( + CASE + WHEN cc.channel_id = ANY($3) THEN 5 + WHEN cc.channel_id = ANY($4) THEN 3 + WHEN cc.channel_id IS NOT NULL THEN 1 + ELSE 0 + END, 0) as channel_match, + COALESCE( + CASE + WHEN cc.level_id = ANY($5) THEN 5 + WHEN cc.level_id = ANY($6) THEN 3 + WHEN cc.level_id IS NOT NULL THEN 1 + ELSE 0 + END, 0) as level_match, + RANDOM() as random_score + FROM modules mod + JOIN courses c ON c.id = mod.course_id + JOIN module_skills ms ON ms.module_id = mod.id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN enrollments e ON e.course_id = c.id AND e.user_id = $7 + LEFT JOIN module_status mst ON mst.module_id = mod.id AND mst.enrollment_id = e.id + WHERE c.organisation_id = $1 + AND ms.skill_id = ANY($2) + AND (mst.status IS NULL OR mst.status IN ('not_started', 'in_progress')) + GROUP BY mod.id, cc.channel_id, cc.level_id + ORDER BY matching_skills DESC, channel_match DESC, level_match DESC, random_score + LIMIT 10`, + [ + user.organisation.id, + preferences.skills, + preferences.memberChannels, + preferences.onboardingChannels, + preferences.memberLevels, + preferences.onboardingLevels, + user.userId + ] + ); + + if (modulesResult.rows.length > 0) { + // Create the roadmap + const roadmapResult = await client.query( + "INSERT INTO roadmaps (user_id, name) VALUES ($1, $2) RETURNING id", + [user.userId, "My Learning Path"] + ); + + const roadmapId = roadmapResult.rows[0].id; + const moduleIds = modulesResult.rows.map(row => row.id); + + // Get courses for auto-enrollment + const courseIds = await getCoursesFromModules(client, moduleIds); + + // Auto-enroll user in courses + if (courseIds.length > 0) { + await ensureUserEnrolledInCourses(client, user.userId, courseIds); + } + + // Add modules to roadmap + for (let i = 0; i < moduleIds.length; i++) { + await client.query( + "INSERT INTO roadmap_items (roadmap_id, module_id, position) VALUES ($1, $2, $3)", + [roadmapId, moduleIds[i], i + 1] + ); + } + + console.log(`Auto-generated roadmap "${roadmapResult.rows[0].name}" with ${moduleIds.length} modules for user ${user.userId}`); + } + } + } catch (roadmapError) { + // Don't fail the onboarding if roadmap generation fails + console.error("Failed to auto-generate roadmap:", roadmapError); + } + await client.query("COMMIT"); - res.json({ message: "Responses submitted successfully" }); + res.json({ + message: "Responses submitted successfully", + roadmapGenerated: true + }); } catch (err) { await client.query("ROLLBACK"); console.error(err); @@ -352,14 +475,24 @@ router.get("/responses", async (req, res) => { SELECT or.option_id, oqo.option_text, - oqo.tag_id, - t.name as tag_name, + oqo.skill_id, + s.name as skill_name, + s.description as skill_description, + oqo.channel_id, + ch.name as channel_name, + ch.description as channel_description, + oqo.level_id, + l.name as level_name, + l.description as level_description, + l.sort_order as level_sort_order, oq.question_text, oq.id as question_id FROM onboarding_responses or JOIN onboarding_question_options oqo ON oqo.id = or.option_id JOIN onboarding_questions oq ON oq.id = oqo.question_id - LEFT JOIN tags t ON t.id = oqo.tag_id + LEFT JOIN skills s ON s.id = oqo.skill_id + LEFT JOIN channels ch ON ch.id = oqo.channel_id + LEFT JOIN levels l ON l.id = oqo.level_id WHERE or.user_id = $1 ORDER BY oq.position ASC `, @@ -369,8 +502,16 @@ router.get("/responses", async (req, res) => { const responses = result.rows.map((row) => ({ option_id: row.option_id, option_text: row.option_text, - tag_id: row.tag_id, - tag_name: row.tag_name, + skill_id: row.skill_id, + skill_name: row.skill_name, + skill_description: row.skill_description, + channel_id: row.channel_id, + channel_name: row.channel_name, + channel_description: row.channel_description, + level_id: row.level_id, + level_name: row.level_name, + level_description: row.level_description, + level_sort_order: row.level_sort_order, question_text: row.question_text, question_id: row.question_id, })); diff --git a/routes/reports.js b/routes/reports.js index 717d526..a51ea3b 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -23,9 +23,23 @@ router.get("/progress", async (req, res) => { // 1) Courses done const { rows: coursesDone } = await client.query( - `SELECT c.id, c.name, e.completed_at + `SELECT c.id, c.name, e.completed_at, + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level FROM enrollments e JOIN courses c ON c.id = e.course_id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN channels ch ON ch.id = cc.channel_id + LEFT JOIN levels l ON l.id = cc.level_id WHERE e.user_id = $1 AND e.status = 'completed' `, @@ -75,8 +89,8 @@ router.get("/progress", async (req, res) => { [userId] ); - // Strengths & weaknesses by tag - const { rows: tagPerf } = await client.query( + // Strengths & weaknesses by skill + const { rows: skillPerf } = await client.query( `WITH latest AS ( SELECT DISTINCT ON (qr.quiz_id) qr.id AS response_id, @@ -87,8 +101,8 @@ router.get("/progress", async (req, res) => { ), user_ans AS ( SELECT - mt.tag_id, - t.name AS tag_name, + ms.skill_id, + s.name AS skill_name, CASE WHEN qo.is_correct THEN 1 ELSE 0 END AS is_correct FROM latest l -- each answered option @@ -98,23 +112,23 @@ user_ans AS ( -- find the module that backs this quiz JOIN quizzes q ON q.id = l.quiz_id JOIN revisions r ON r.id = q.revision_id - JOIN module_tags mt ON mt.module_id = r.module_id - JOIN tags t ON t.id = mt.tag_id + JOIN module_skills ms ON ms.module_id = r.module_id + JOIN skills s ON s.id = ms.skill_id ) SELECT - tag_name, + skill_name, SUM(is_correct) AS correct, COUNT(*) AS total, ROUND(SUM(is_correct)::decimal * 100 / NULLIF(COUNT(*),0), 1) AS pct FROM user_ans -GROUP BY tag_name +GROUP BY skill_name `, [userId] ); - const strengths = tagPerf.filter((r) => r.pct >= 80); - const weaknesses = tagPerf.filter((r) => r.pct < 80); + const strengths = skillPerf.filter((r) => r.pct >= 80); + const weaknesses = skillPerf.filter((r) => r.pct < 80); await client.query("COMMIT"); return res.json({ @@ -166,12 +180,26 @@ router.get("/overview", requireAdmin, async (req, res) => { COUNT(DISTINCT m.id) FILTER (WHERE m.module_type = 'quiz') AS quizzes, COUNT(DISTINCT m.id) FILTER (WHERE m.module_type = 'pdf') AS pdfs, COUNT(DISTINCT m.id) FILTER (WHERE m.module_type = 'slide') AS slides, - COUNT(DISTINCT m.id) FILTER (WHERE m.module_type NOT IN ('video','quiz','pdf','slide')) AS others + COUNT(DISTINCT m.id) FILTER (WHERE m.module_type NOT IN ('video','quiz','pdf','slide')) AS others, + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level FROM courses c LEFT JOIN enrollments e ON e.course_id = c.id LEFT JOIN modules m ON m.course_id = c.id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN channels ch ON ch.id = cc.channel_id + LEFT JOIN levels l ON l.id = cc.level_id WHERE c.organisation_id = $1 - GROUP BY c.id, c.name + GROUP BY c.id, c.name, ch.id, ch.name, ch.description, l.id, l.name, l.description, l.sort_order ORDER BY c.name `, [orgId] @@ -198,9 +226,23 @@ router.get("/overview", requireAdmin, async (req, res) => { const uid = emp.id; const { rows: coursesDone } = await client.query( - `SELECT c.id, c.name, e.completed_at + `SELECT c.id, c.name, e.completed_at, + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level FROM enrollments e JOIN courses c ON c.id = e.course_id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN channels ch ON ch.id = cc.channel_id + LEFT JOIN levels l ON l.id = cc.level_id WHERE e.user_id = $1 AND e.status = 'completed'`, [uid] @@ -252,7 +294,7 @@ router.get("/overview", requireAdmin, async (req, res) => { [uid] ); - const { rows: tagPerf } = await client.query( + const { rows: skillPerf } = await client.query( ` WITH latest AS ( SELECT DISTINCT ON (qr.quiz_id) @@ -264,30 +306,30 @@ router.get("/overview", requireAdmin, async (req, res) => { ), user_ans AS ( SELECT - mt.tag_id, - t.name AS tag_name, + ms.skill_id, + s.name AS skill_name, CASE WHEN qo.is_correct THEN 1 ELSE 0 END AS is_correct FROM latest l JOIN quiz_answers qa ON qa.response_id = l.response_id JOIN question_options qo ON qo.id = qa.selected_option_id JOIN quizzes q ON q.id = l.quiz_id JOIN revisions r ON r.id = q.revision_id - JOIN module_tags mt ON mt.module_id = r.module_id - JOIN tags t ON t.id = mt.tag_id + JOIN module_skills ms ON ms.module_id = r.module_id + JOIN skills s ON s.id = ms.skill_id ) SELECT - tag_name, + skill_name, SUM(is_correct) AS correct, COUNT(*) AS total, ROUND(SUM(is_correct)::decimal * 100 / NULLIF(COUNT(*),0), 1) AS pct FROM user_ans - GROUP BY tag_name + GROUP BY skill_name `, [uid] ); - const strengths = tagPerf.filter((r) => r.pct >= 80); - const weaknesses = tagPerf.filter((r) => r.pct < 80); + const strengths = skillPerf.filter((r) => r.pct >= 80); + const weaknesses = skillPerf.filter((r) => r.pct < 80); return { id: emp.id, diff --git a/routes/roadmaps-helpers.js b/routes/roadmaps-helpers.js new file mode 100644 index 0000000..9ecbb0b --- /dev/null +++ b/routes/roadmaps-helpers.js @@ -0,0 +1,122 @@ +// Shared helper functions for roadmap operations + +async function getUserPreferences(client, userId) { + try { + // Get user's channel preferences from member settings (highest priority) + const memberChannelsResult = await client.query( + "SELECT channel_id FROM user_channels WHERE user_id = $1", + [userId] + ); + const memberChannels = memberChannelsResult.rows.map(row => row.channel_id); + + // Get user's level preferences from member settings (highest priority) + const memberLevelsResult = await client.query( + "SELECT level_id FROM user_levels WHERE user_id = $1", + [userId] + ); + const memberLevels = memberLevelsResult.rows.map(row => row.level_id); + + // Get user's skills from onboarding responses + const skillsResult = await client.query( + `SELECT DISTINCT oqo.skill_id + FROM onboarding_responses or_table + JOIN onboarding_question_options oqo ON oqo.id = or_table.option_id + WHERE or_table.user_id = $1 AND oqo.skill_id IS NOT NULL`, + [userId] + ); + const skills = skillsResult.rows.map(row => row.skill_id); + + // Get channel preferences from onboarding responses (fallback) + const onboardingChannelsResult = await client.query( + `SELECT DISTINCT oqo.channel_id + FROM onboarding_responses or_table + JOIN onboarding_question_options oqo ON oqo.id = or_table.option_id + WHERE or_table.user_id = $1 AND oqo.channel_id IS NOT NULL`, + [userId] + ); + const onboardingChannels = onboardingChannelsResult.rows.map(row => row.channel_id); + + // Get level preferences from onboarding responses (fallback) + const onboardingLevelsResult = await client.query( + `SELECT DISTINCT oqo.level_id + FROM onboarding_responses or_table + JOIN onboarding_question_options oqo ON oqo.id = or_table.option_id + WHERE or_table.user_id = $1 AND oqo.level_id IS NOT NULL`, + [userId] + ); + const onboardingLevels = onboardingLevelsResult.rows.map(row => row.level_id); + + return { + skills, + memberChannels, + memberLevels, + onboardingChannels, + onboardingLevels + }; + } catch (error) { + console.error('Error getting user preferences:', error); + return { + skills: [], + memberChannels: [], + memberLevels: [], + onboardingChannels: [], + onboardingLevels: [] + }; + } +} + +async function getCoursesFromModules(client, moduleIds) { + if (moduleIds.length === 0) return []; + + const result = await client.query( + "SELECT DISTINCT course_id FROM modules WHERE id = ANY($1)", + [moduleIds] + ); + return result.rows.map(row => row.course_id); +} + +async function ensureUserEnrolledInCourses(client, userId, courseIds) { + if (courseIds.length === 0) return; + + for (const courseId of courseIds) { + // Insert enrollment if it doesn't exist + await client.query( + `INSERT INTO enrollments (user_id, course_id, status) + VALUES ($1, $2, 'enrolled') + ON CONFLICT (user_id, course_id) DO NOTHING`, + [userId, courseId] + ); + + // Get all modules for this course and create module_status records + const modulesResult = await client.query( + "SELECT id FROM modules WHERE course_id = $1", + [courseId] + ); + + for (const moduleRow of modulesResult.rows) { + // Get the enrollment id + const enrollmentResult = await client.query( + "SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2", + [userId, courseId] + ); + + if (enrollmentResult.rows.length > 0) { + const enrollmentId = enrollmentResult.rows[0].id; + + // Create module_status if it doesn't exist + 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, moduleRow.id] + ); + } + } + } +} + +module.exports = { + getUserPreferences, + getCoursesFromModules, + ensureUserEnrolledInCourses +}; \ No newline at end of file diff --git a/routes/roadmaps.js b/routes/roadmaps.js index 638c0f1..983a06c 100644 --- a/routes/roadmaps.js +++ b/routes/roadmaps.js @@ -1,6 +1,11 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); +const { + getUserPreferences, + getCoursesFromModules, + ensureUserEnrolledInCourses, +} = require("./roadmaps-helpers"); // Helper function to parse auth cookie function getAuthUser(req) { @@ -13,72 +18,6 @@ function getAuthUser(req) { } } -// Helper function to get courses from module IDs -async function getCoursesFromModules(client, moduleIds) { - if (moduleIds.length === 0) return []; - - const result = await client.query( - `SELECT DISTINCT course_id FROM modules WHERE id = ANY($1)`, - [moduleIds] - ); - - return result.rows.map(row => row.course_id); -} - -// Helper function to auto-enroll user in courses -async function ensureUserEnrolledInCourses(client, userId, courseIds) { - const enrolledCourses = []; - - for (const courseId of courseIds) { - try { - // Check if already enrolled - const existingEnrollment = await client.query( - `SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2`, - [userId, courseId] - ); - - if (existingEnrollment.rows.length > 0) { - continue; // Skip if already enrolled - } - - // Create enrollment - const insertRes = await client.query( - `INSERT INTO enrollments (user_id, course_id, started_at) - VALUES ($1, $2, NOW()) - RETURNING id`, - [userId, courseId] - ); - - const enrollmentId = insertRes.rows[0].id; - - // Get all modules for this course - const modulesRes = await client.query( - `SELECT id FROM modules WHERE course_id = $1`, - [courseId] - ); - - // Create module_status records - 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] - ); - } - - enrolledCourses.push(courseId); - } catch (err) { - if (err.code !== "23505") { // Ignore duplicate enrollment errors - throw err; - } - } - } - - return enrolledCourses; -} - // GET /api/roadmaps - Get user's roadmaps router.get("/", async (req, res) => { const user = getAuthUser(req); @@ -138,7 +77,7 @@ router.put("/:id", async (req, res) => { const { id } = req.params; const { name } = req.body; - + if (!name || !name.trim()) { return res.status(400).json({ message: "Roadmap name is required" }); } @@ -226,12 +165,26 @@ router.get("/:id/items", async (req, res) => { WHEN e.id IS NOT NULL THEN 'enrolled' ELSE 'not_enrolled' END as enrollment_status, - COALESCE(ms.status, 'not_started') as module_status + COALESCE(ms.status, 'not_started') as module_status, + JSON_BUILD_OBJECT( + 'id', ch.id, + 'name', ch.name, + 'description', ch.description + ) AS channel, + JSON_BUILD_OBJECT( + 'id', l.id, + 'name', l.name, + 'description', l.description, + 'sort_order', l.sort_order + ) AS level FROM roadmap_items ri JOIN modules mod ON mod.id = ri.module_id JOIN courses c ON c.id = mod.course_id LEFT JOIN enrollments e ON e.course_id = c.id AND e.user_id = $2 LEFT JOIN module_status ms ON ms.module_id = mod.id AND ms.enrollment_id = e.id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN channels ch ON ch.id = cc.channel_id + LEFT JOIN levels l ON l.id = cc.level_id WHERE ri.roadmap_id = $1 ORDER BY ri.position ASC`, [id, user.userId] @@ -295,7 +248,11 @@ router.post("/:id/items", async (req, res) => { // Get course ID from module and auto-enroll user const courseIds = await getCoursesFromModules(client, [module_id]); - const enrolledCourses = await ensureUserEnrolledInCourses(client, user.userId, courseIds); + const enrolledCourses = await ensureUserEnrolledInCourses( + client, + user.userId, + courseIds + ); // Add module to roadmap await client.query( @@ -306,10 +263,10 @@ router.post("/:id/items", async (req, res) => { await client.query("COMMIT"); - res.status(201).json({ + res.status(201).json({ message: "Module added to roadmap", position: nextPosition, - enrolledCourses: enrolledCourses.length + enrolledCourses: enrolledCourses.length, }); } catch (err) { await client.query("ROLLBACK"); @@ -365,7 +322,10 @@ router.put("/:id/items/:moduleId", async (req, res) => { await client.query("COMMIT"); - res.json({ message: "Position updated", position: result.rows[0].position }); + res.json({ + message: "Position updated", + position: result.rows[0].position, + }); } catch (err) { await client.query("ROLLBACK"); console.error(err); @@ -409,7 +369,7 @@ router.delete("/:id/items/:moduleId", async (req, res) => { } }); -// POST /api/roadmaps/generate - Auto-generate roadmap based on user's onboarding tags +// POST /api/roadmaps/generate - Auto-generate roadmap based on user's onboarding skills router.post("/generate", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -440,45 +400,108 @@ router.post("/generate", async (req, res) => { const roadmap = roadmapResult.rows[0]; - // 2. Get user's tags from onboarding responses - const userTagsResult = await client.query( - `SELECT DISTINCT oqo.tag_id + // 2. Get user's skills from onboarding responses + const userSkillsResult = await client.query( + `SELECT DISTINCT oqo.skill_id FROM onboarding_responses or_table JOIN onboarding_question_options oqo ON oqo.id = or_table.option_id - WHERE or_table.user_id = $1 AND oqo.tag_id IS NOT NULL`, + WHERE or_table.user_id = $1 AND oqo.skill_id IS NOT NULL`, [user.userId] ); - const userTagIds = userTagsResult.rows.map(row => row.tag_id); + // 3. Get user's channel and level preferences (combines member settings and onboarding) + const userPreferences = await getUserPreferences(client, user.userId); + + const userSkillIds = userSkillsResult.rows.map((row) => row.skill_id); + const userChannelIds = userPreferences.channels?.all || []; + const userLevelIds = userPreferences.levels?.all || []; let modulesAdded = 0; let enrolledCourses = []; - if (userTagIds.length > 0) { - // 3. Get recommended modules based on user's tags - const modulesResult = await client.query( - `SELECT DISTINCT + if (userSkillIds.length > 0) { + // 4. Get recommended modules with enhanced scoring based on member preferences + // Exclude modules that the user has already completed + let query = `SELECT DISTINCT mod.id, - COUNT(DISTINCT mt.tag_id) as matching_tags, + COUNT(DISTINCT ms.skill_id) as matching_skills, + CASE + WHEN cc.channel_id = ANY($3) THEN + CASE + WHEN cc.channel_id = ANY($5) THEN 5 -- Member setting preference (highest priority) + ELSE 3 -- Onboarding preference + END + WHEN cc.channel_id IS NOT NULL THEN 1 + ELSE 0 + END as channel_match, + CASE + WHEN cc.level_id = ANY($4) THEN + CASE + WHEN cc.level_id = ANY($6) THEN 5 -- Member setting preference (highest priority) + ELSE 3 -- Onboarding preference + END + WHEN cc.level_id IS NOT NULL THEN 1 + ELSE 0 + END as level_match, + cc.channel_id, + cc.level_id, RANDOM() as random_score FROM modules mod JOIN courses c ON c.id = mod.course_id - JOIN module_tags mt ON mt.module_id = mod.id + JOIN module_skills ms ON ms.module_id = mod.id + LEFT JOIN course_channels cc ON cc.course_id = c.id + LEFT JOIN enrollments e ON e.course_id = c.id AND e.user_id = $7 + LEFT JOIN module_status mst ON mst.module_id = mod.id AND mst.enrollment_id = e.id WHERE c.organisation_id = $1 - AND mt.tag_id = ANY($2) - GROUP BY mod.id - ORDER BY matching_tags DESC, random_score - LIMIT 10`, // Limit to top 10 modules - [organisationId, userTagIds] - ); + AND ms.skill_id = ANY($2) + AND (mst.status IS NULL OR mst.status IN ('not_started', 'in_progress'))`; + + const params = [ + organisationId, + userSkillIds, + userChannelIds, + userLevelIds, + userPreferences.channels.member, + userPreferences.levels.member, + user.userId, + ]; + + // Add additional filtering for preferred channels and levels if they exist + if (userChannelIds.length > 0 || userLevelIds.length > 0) { + query += ` AND (`; + const conditions = []; + + if (userChannelIds.length > 0) { + conditions.push(`cc.channel_id = ANY($3)`); + } + + if (userLevelIds.length > 0) { + conditions.push(`cc.level_id = ANY($4)`); + } + + // Also include modules without specific channel/level assignments + conditions.push(`cc.channel_id IS NULL OR cc.level_id IS NULL`); + + query += conditions.join(" OR ") + ")"; + } + + query += ` GROUP BY mod.id, cc.channel_id, cc.level_id + ORDER BY matching_skills DESC, channel_match DESC, level_match DESC, random_score + LIMIT 10`; // Limit to top 10 modules + + const modulesResult = await client.query(query, params); if (modulesResult.rows.length > 0) { - const moduleIds = modulesResult.rows.map(row => row.id); - - // 4. Get courses from these modules and auto-enroll user + const moduleIds = modulesResult.rows.map((row) => row.id); + + // 5. Get courses from these modules and auto-enroll user const courseIds = await getCoursesFromModules(client, moduleIds); - enrolledCourses = await ensureUserEnrolledInCourses(client, user.userId, courseIds); + enrolledCourses = await ensureUserEnrolledInCourses( + client, + user.userId, + courseIds + ); - // 5. Add modules to roadmap + // 6. Add modules to roadmap for (let i = 0; i < modulesResult.rows.length; i++) { const module = modulesResult.rows[i]; await client.query( @@ -487,17 +510,17 @@ router.post("/generate", async (req, res) => { [roadmap.id, module.id, i + 1] ); } - + modulesAdded = modulesResult.rows.length; } } await client.query("COMMIT"); - res.status(201).json({ + res.status(201).json({ roadmap, modulesAdded, - enrolledCourses: enrolledCourses.length + enrolledCourses: enrolledCourses.length, }); } catch (err) { await client.query("ROLLBACK"); @@ -508,4 +531,4 @@ router.post("/generate", async (req, res) => { } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/routes/users.js b/routes/users.js index cde907d..120b14d 100644 --- a/routes/users.js +++ b/routes/users.js @@ -408,5 +408,315 @@ router.delete("/skills", async (req, res) => { } }); +// ============================================================================= +// USER PREFERENCES ENDPOINTS (Channels and Levels) +// ============================================================================= + +// GET /api/users/preferences - Get user's channel and level preferences along with available options +router.get("/preferences", 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; + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Get user's channel preferences + const userChannelsResult = await client.query( + `SELECT uc.id, uc.channel_id, c.name as channel_name, c.description as channel_description, uc.preference_rank + FROM user_channels uc + JOIN channels c ON c.id = uc.channel_id + WHERE uc.user_id = $1 + ORDER BY uc.preference_rank`, + [userId] + ); + + // Get user's level preferences + const userLevelsResult = await client.query( + `SELECT ul.id, ul.level_id, l.name as level_name, l.description as level_description, l.sort_order, ul.preference_rank + FROM user_levels ul + JOIN levels l ON l.id = ul.level_id + WHERE ul.user_id = $1 + ORDER BY ul.preference_rank`, + [userId] + ); + + // Get all available channels for the organization + const allChannelsResult = await client.query( + `SELECT id, name, description + FROM channels + WHERE organisation_id = $1 + ORDER BY name`, + [organisationId] + ); + + // Get all available levels for the organization + const allLevelsResult = await client.query( + `SELECT id, name, description, sort_order + FROM levels + WHERE organisation_id = $1 + ORDER BY sort_order, name`, + [organisationId] + ); + + await client.query("COMMIT"); + + return res.json({ + userChannels: userChannelsResult.rows, + userLevels: userLevelsResult.rows, + availableChannels: allChannelsResult.rows, + availableLevels: allLevelsResult.rows + }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error fetching user preferences:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// POST /api/users/preferences/channels - Add channel preference +router.post("/preferences/channels", 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; + const { channel_id } = req.body; + + if (!channel_id) { + return res.status(400).json({ message: "Channel ID is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Verify channel belongs to user's organization + const channelCheck = await client.query( + "SELECT id FROM channels WHERE id = $1 AND organisation_id = $2", + [channel_id, organisationId] + ); + + if (channelCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Invalid channel" }); + } + + // Check if preference already exists + const existingResult = await client.query( + "SELECT id FROM user_channels WHERE user_id = $1 AND channel_id = $2", + [userId, channel_id] + ); + + if (existingResult.rows.length > 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Channel preference already exists" }); + } + + // Get next preference rank + const rankResult = await client.query( + "SELECT COALESCE(MAX(preference_rank), 0) + 1 as next_rank FROM user_channels WHERE user_id = $1", + [userId] + ); + + const nextRank = rankResult.rows[0].next_rank; + + // Add channel preference + await client.query( + "INSERT INTO user_channels (user_id, channel_id, preference_rank) VALUES ($1, $2, $3)", + [userId, channel_id, nextRank] + ); + + await client.query("COMMIT"); + return res.json({ message: "Channel preference added successfully" }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error adding channel preference:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// POST /api/users/preferences/levels - Add level preference +router.post("/preferences/levels", 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; + const { level_id } = req.body; + + if (!level_id) { + return res.status(400).json({ message: "Level ID is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Verify level belongs to user's organization + const levelCheck = await client.query( + "SELECT id FROM levels WHERE id = $1 AND organisation_id = $2", + [level_id, organisationId] + ); + + if (levelCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Invalid level" }); + } + + // Check if preference already exists + const existingResult = await client.query( + "SELECT id FROM user_levels WHERE user_id = $1 AND level_id = $2", + [userId, level_id] + ); + + if (existingResult.rows.length > 0) { + await client.query("ROLLBACK"); + return res.status(400).json({ message: "Level preference already exists" }); + } + + // Get next preference rank + const rankResult = await client.query( + "SELECT COALESCE(MAX(preference_rank), 0) + 1 as next_rank FROM user_levels WHERE user_id = $1", + [userId] + ); + + const nextRank = rankResult.rows[0].next_rank; + + // Add level preference + await client.query( + "INSERT INTO user_levels (user_id, level_id, preference_rank) VALUES ($1, $2, $3)", + [userId, level_id, nextRank] + ); + + await client.query("COMMIT"); + return res.json({ message: "Level preference added successfully" }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error adding level preference:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// DELETE /api/users/preferences/channels - Remove channel preference +router.delete("/preferences/channels", 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 { channel_id } = req.body; + + if (!channel_id) { + return res.status(400).json({ message: "Channel ID is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Remove channel preference + const result = await client.query( + "DELETE FROM user_channels WHERE user_id = $1 AND channel_id = $2", + [userId, channel_id] + ); + + if (result.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Channel preference not found" }); + } + + await client.query("COMMIT"); + return res.json({ message: "Channel preference removed successfully" }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error removing channel preference:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +// DELETE /api/users/preferences/levels - Remove level preference +router.delete("/preferences/levels", 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 { level_id } = req.body; + + if (!level_id) { + return res.status(400).json({ message: "Level ID is required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // Remove level preference + const result = await client.query( + "DELETE FROM user_levels WHERE user_id = $1 AND level_id = $2", + [userId, level_id] + ); + + if (result.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Level preference not found" }); + } + + await client.query("COMMIT"); + return res.json({ message: "Level preference removed successfully" }); + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error removing level preference:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + module.exports = router; From 6c007f64bfec17660985d72835f52d9341dabeaa Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Wed, 9 Jul 2025 18:00:21 +0800 Subject: [PATCH 09/32] Fix roadmap issues and clean code --- .../add_channel_level_onboarding.sql | 15 ---- database/migrations/user_preferences.sql | 34 ------- routes/courses.js | 61 +++++++------ routes/materials.js | 15 +--- routes/onboarding.js | 90 ++++++++++--------- routes/orgs.js | 9 -- routes/reports.js | 4 - routes/roadmaps-helpers.js | 82 +++++++++++------ routes/roadmaps.js | 89 +++++++----------- routes/users.js | 34 ++----- 10 files changed, 177 insertions(+), 256 deletions(-) delete mode 100644 database/migrations/add_channel_level_onboarding.sql delete mode 100644 database/migrations/user_preferences.sql diff --git a/database/migrations/add_channel_level_onboarding.sql b/database/migrations/add_channel_level_onboarding.sql deleted file mode 100644 index 060d0b5..0000000 --- a/database/migrations/add_channel_level_onboarding.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Migration to add channel_id and level_id to onboarding_question_options table --- This enables channel and level preferences in the onboarding system - -BEGIN; - --- Add channel_id and level_id columns to onboarding_question_options table -ALTER TABLE onboarding_question_options -ADD COLUMN channel_id INTEGER REFERENCES channels(id) ON DELETE CASCADE, -ADD COLUMN level_id INTEGER REFERENCES levels(id) ON DELETE CASCADE; - --- Add indexes for better performance -CREATE INDEX idx_onboarding_question_options_channel_id ON onboarding_question_options(channel_id); -CREATE INDEX idx_onboarding_question_options_level_id ON onboarding_question_options(level_id); - -COMMIT; \ No newline at end of file diff --git a/database/migrations/user_preferences.sql b/database/migrations/user_preferences.sql deleted file mode 100644 index 13a98af..0000000 --- a/database/migrations/user_preferences.sql +++ /dev/null @@ -1,34 +0,0 @@ --- Migration: Add user channel and level preferences tables --- Purpose: Allow users to set their preferred channels and levels for personalized recommendations - --- Table for user channel preferences -CREATE TABLE user_channels ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE, - preference_rank INTEGER NOT NULL DEFAULT 1, - added_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(user_id, channel_id) -); - --- Table for user level preferences -CREATE TABLE user_levels ( - id SERIAL PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - level_id INTEGER NOT NULL REFERENCES levels(id) ON DELETE CASCADE, - preference_rank INTEGER NOT NULL DEFAULT 1, - added_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE(user_id, level_id) -); - --- Indexes for better query performance -CREATE INDEX idx_user_channels_user_id ON user_channels(user_id); -CREATE INDEX idx_user_channels_channel_id ON user_channels(channel_id); -CREATE INDEX idx_user_levels_user_id ON user_levels(user_id); -CREATE INDEX idx_user_levels_level_id ON user_levels(level_id); - --- Comments for documentation -COMMENT ON TABLE user_channels IS 'Stores user preferences for learning channels/topics'; -COMMENT ON TABLE user_levels IS 'Stores user preferences for difficulty levels'; -COMMENT ON COLUMN user_channels.preference_rank IS 'Ranking of channel preference (1 = highest priority)'; -COMMENT ON COLUMN user_levels.preference_rank IS 'Ranking of level preference (1 = highest priority)'; \ No newline at end of file diff --git a/routes/courses.js b/routes/courses.js index b853795..a45c4d3 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -1,4 +1,3 @@ -// routes/courses.js const express = require("express"); const pool = require("../database/db"); const router = express.Router(); @@ -37,7 +36,9 @@ router.post("/", async (req, res) => { return res.status(400).json({ message: "courseName is required" }); } if (!channelId || !levelId) { - return res.status(400).json({ message: "channelId and levelId are required" }); + return res + .status(400) + .json({ message: "channelId and levelId are required" }); } const client = await pool.connect(); @@ -174,17 +175,21 @@ router.post("/get-course", async (req, res) => { ); const channelLevel = channelLevelRes.rows[0] || null; - const channel = channelLevel ? { - id: channelLevel.channel_id, - name: channelLevel.channel_name, - description: channelLevel.channel_description - } : null; - const level = channelLevel ? { - id: channelLevel.level_id, - name: channelLevel.level_name, - description: channelLevel.level_description, - sort_order: channelLevel.sort_order - } : null; + const channel = channelLevel + ? { + id: channelLevel.channel_id, + name: channelLevel.channel_name, + description: channelLevel.channel_description, + } + : null; + const level = channelLevel + ? { + id: channelLevel.level_id, + name: channelLevel.level_name, + description: channelLevel.level_description, + sort_order: channelLevel.sort_order, + } + : null; await client.query("COMMIT"); return res.status(200).json({ @@ -288,8 +293,6 @@ router.put("/", async (req, res) => { throw new Error("Failed to update course"); } - // const courseId = courseRes.rows[0].id; - if (updateChannelLevel && channelId && levelId) { await client.query(`DELETE FROM course_channels WHERE course_id = $1`, [ courseId, @@ -468,7 +471,6 @@ router.post("/add-module", upload.single("file"), async (req, res) => { } if (type === "quiz") { - // revision for this module const revRes = await client.query( `INSERT INTO revisions (module_id, revision_number) VALUES ($1, @@ -479,7 +481,6 @@ router.post("/add-module", upload.single("file"), async (req, res) => { ); const revisionId = revRes.rows[0].id; - // 4) Create the quiz record const quizRes = await client.query( `INSERT INTO quizzes (revision_id, title, quiz_type) VALUES ($1,$2,$3) @@ -488,7 +489,6 @@ router.post("/add-module", upload.single("file"), async (req, res) => { ); const quizId = quizRes.rows[0].id; - // 5) Insert questions + options const qs = JSON.parse(questions); for (let i = 0; i < qs.length; i++) { const { question_text, question_type, options } = qs[i]; @@ -606,7 +606,6 @@ router.post("/get-module", async (req, res) => { const module = moduleRes.rows[0]; if (module.module_type === "quiz") { - // 2a) get the latest revision const revRes = await client.query( `SELECT id FROM revisions @@ -955,7 +954,6 @@ router.get("/all-user-courses", async (req, res) => { try { await client.query("BEGIN"); - // 1) "Enrolled" courses (status = 'enrolled') const enrolledRes = await client.query( ` SELECT @@ -998,7 +996,6 @@ GROUP BY c.id, c.name, c.description; [userId] ); - // 2) "Completed" courses (status = 'completed') const completedRes = await client.query( ` SELECT @@ -1040,7 +1037,6 @@ 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, @@ -1903,7 +1899,7 @@ router.post("/add-channel", async (req, res) => { await client.query( `INSERT INTO channels (name, description, organisation_id) VALUES ($1, $2, $3)`, - [name, description || '', organisationId] + [name, description || "", organisationId] ); await client.query("COMMIT"); return res.status(201).json({ success: true }); @@ -1946,7 +1942,10 @@ router.delete("/delete-channel", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); - await client.query(`DELETE FROM channels WHERE id = $1 AND organisation_id = $2`, [channelId, organisationId]); + await client.query( + `DELETE FROM channels WHERE id = $1 AND organisation_id = $2`, + [channelId, organisationId] + ); await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) { @@ -2021,7 +2020,7 @@ router.post("/add-level", async (req, res) => { await client.query( `INSERT INTO levels (name, description, sort_order, organisation_id) VALUES ($1, $2, $3, $4)`, - [name, description || '', sort_order || 0, organisationId] + [name, description || "", sort_order || 0, organisationId] ); await client.query("COMMIT"); return res.status(201).json({ success: true }); @@ -2064,7 +2063,10 @@ router.delete("/delete-level", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); - await client.query(`DELETE FROM levels WHERE id = $1 AND organisation_id = $2`, [levelId, organisationId]); + await client.query( + `DELETE FROM levels WHERE id = $1 AND organisation_id = $2`, + [levelId, organisationId] + ); await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) { @@ -2139,7 +2141,7 @@ router.post("/add-skill", async (req, res) => { await client.query( `INSERT INTO skills (name, description, organisation_id) VALUES ($1, $2, $3)`, - [name, description || '', organisationId] + [name, description || "", organisationId] ); await client.query("COMMIT"); return res.status(201).json({ success: true }); @@ -2182,7 +2184,10 @@ router.delete("/delete-skill", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); - await client.query(`DELETE FROM skills WHERE id = $1 AND organisation_id = $2`, [skillId, organisationId]); + await client.query( + `DELETE FROM skills WHERE id = $1 AND organisation_id = $2`, + [skillId, organisationId] + ); await client.query("COMMIT"); return res.status(200).json({ success: true }); } catch (err) { diff --git a/routes/materials.js b/routes/materials.js index 839cc6d..8d717d2 100644 --- a/routes/materials.js +++ b/routes/materials.js @@ -3,7 +3,6 @@ const pool = require("../database/db"); const router = express.Router(); const { getUserPreferences } = require("./roadmaps-helpers"); -// Helper function to parse auth cookie function getAuthUser(req) { const { auth } = req.cookies; if (!auth) return null; @@ -14,7 +13,6 @@ function getAuthUser(req) { } } -// GET /api/materials - Get all modules for the user's organization router.get("/", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -26,7 +24,7 @@ router.get("/", async (req, res) => { return res.status(400).json({ message: "Organization required" }); } - const { skill_ids } = req.query; // Comma-separated skill IDs + const { skill_ids } = req.query; try { let query = ` @@ -61,7 +59,6 @@ router.get("/", async (req, res) => { const params = [organisationId]; - // If skill_ids are provided, filter by those skills if (skill_ids) { const skillIdArray = skill_ids .split(",") @@ -85,7 +82,6 @@ router.get("/", async (req, res) => { } }); -// GET /api/materials/by-user-skills - Get modules recommended based on user's skills and preferences router.get("/by-user-skills", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -99,7 +95,6 @@ router.get("/by-user-skills", async (req, res) => { const client = await pool.connect(); try { - // Get user's skills from onboarding responses const userSkillsResult = await client.query( `SELECT DISTINCT oqo.skill_id FROM onboarding_responses or_table @@ -114,10 +109,8 @@ router.get("/by-user-skills", async (req, res) => { return res.json({ materials: [] }); } - // Get user's channel and level preferences (combines member settings and onboarding) const userPreferences = await getUserPreferences(client, user.userId); - // Get modules that match user's skills with enhanced scoring based on member preferences const result = await client.query( `SELECT DISTINCT mod.id, @@ -195,7 +188,6 @@ router.get("/by-user-skills", async (req, res) => { } }); -// Backward compatibility: redirect old by-user-tags to new by-user-skills router.get("/by-user-tags", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -209,7 +201,6 @@ router.get("/by-user-tags", async (req, res) => { const client = await pool.connect(); try { - // Get user's skills from onboarding responses const userSkillsResult = await client.query( `SELECT DISTINCT oqo.skill_id FROM onboarding_responses or_table @@ -224,10 +215,8 @@ router.get("/by-user-tags", async (req, res) => { return res.json({ materials: [] }); } - // Get user's channel and level preferences (combines member settings and onboarding) const userPreferences = await getUserPreferences(client, user.userId); - // Get modules that match user's skills with enhanced scoring based on member preferences const result = await client.query( `SELECT DISTINCT mod.id, @@ -291,7 +280,7 @@ router.get("/by-user-tags", async (req, res) => { res.json({ materials: result.rows, - userTags: userSkillIds, // Keep old property name for backward compatibility + userTags: userSkillIds, }); } catch (err) { console.error(err); diff --git a/routes/onboarding.js b/routes/onboarding.js index 97570d0..8f01842 100644 --- a/routes/onboarding.js +++ b/routes/onboarding.js @@ -233,7 +233,6 @@ router.delete("/questions/:id", async (req, res) => { } try { - // Check if question has any options before deletion const optionCheck = await pool.query( "SELECT COUNT(*) as option_count FROM onboarding_question_options WHERE question_id = $1", [id] @@ -358,77 +357,85 @@ router.post("/responses", async (req, res) => { [user.userId] ); - // Auto-generate first roadmap based on onboarding responses try { - // Import helper functions (we'll need to add these as module-level functions) - const { getUserPreferences, getCoursesFromModules, ensureUserEnrolledInCourses } = require('./roadmaps-helpers'); - - // Get user preferences (skills, channels, levels) + const { + getUserPreferences, + getCoursesFromModules, + ensureUserEnrolledInCourses, + } = require("./roadmaps-helpers"); + const preferences = await getUserPreferences(client, user.userId); - - if (preferences.skills.length > 0) { - // Generate roadmap with modules based on user preferences - const modulesResult = await client.query( - `SELECT DISTINCT + + const hasPreferences = + preferences.skills.length > 0 || + preferences.memberChannels.length > 0 || + preferences.onboardingChannels.length > 0 || + preferences.memberLevels.length > 0 || + preferences.onboardingLevels.length > 0; + + if (hasPreferences) { + let moduleQuery = `SELECT DISTINCT mod.id, COUNT(DISTINCT ms.skill_id) as matching_skills, COALESCE( CASE - WHEN cc.channel_id = ANY($3) THEN 5 - WHEN cc.channel_id = ANY($4) THEN 3 + WHEN cc.channel_id = ANY($2) THEN 5 + WHEN cc.channel_id = ANY($3) THEN 3 WHEN cc.channel_id IS NOT NULL THEN 1 ELSE 0 END, 0) as channel_match, COALESCE( CASE - WHEN cc.level_id = ANY($5) THEN 5 - WHEN cc.level_id = ANY($6) THEN 3 + WHEN cc.level_id = ANY($4) THEN 5 + WHEN cc.level_id = ANY($5) THEN 3 WHEN cc.level_id IS NOT NULL THEN 1 ELSE 0 END, 0) as level_match, RANDOM() as random_score FROM modules mod JOIN courses c ON c.id = mod.course_id - JOIN module_skills ms ON ms.module_id = mod.id + LEFT JOIN module_skills ms ON ms.module_id = mod.id LEFT JOIN course_channels cc ON cc.course_id = c.id - LEFT JOIN enrollments e ON e.course_id = c.id AND e.user_id = $7 + LEFT JOIN enrollments e ON e.course_id = c.id AND e.user_id = $6 LEFT JOIN module_status mst ON mst.module_id = mod.id AND mst.enrollment_id = e.id WHERE c.organisation_id = $1 - AND ms.skill_id = ANY($2) - AND (mst.status IS NULL OR mst.status IN ('not_started', 'in_progress')) - GROUP BY mod.id, cc.channel_id, cc.level_id + AND (mst.status IS NULL OR mst.status IN ('not_started', 'in_progress'))`; + + let moduleParams = [ + user.organisation.id, + preferences.memberChannels, + preferences.onboardingChannels, + preferences.memberLevels, + preferences.onboardingLevels, + user.userId, + ]; + + if (preferences.skills.length > 0) { + moduleQuery += ` AND ms.skill_id = ANY($7)`; + moduleParams.push(preferences.skills); + } + + moduleQuery += ` GROUP BY mod.id, cc.channel_id, cc.level_id ORDER BY matching_skills DESC, channel_match DESC, level_match DESC, random_score - LIMIT 10`, - [ - user.organisation.id, - preferences.skills, - preferences.memberChannels, - preferences.onboardingChannels, - preferences.memberLevels, - preferences.onboardingLevels, - user.userId - ] - ); + LIMIT 10`; + + const modulesResult = await client.query(moduleQuery, moduleParams); if (modulesResult.rows.length > 0) { - // Create the roadmap const roadmapResult = await client.query( "INSERT INTO roadmaps (user_id, name) VALUES ($1, $2) RETURNING id", [user.userId, "My Learning Path"] ); const roadmapId = roadmapResult.rows[0].id; - const moduleIds = modulesResult.rows.map(row => row.id); + const moduleIds = modulesResult.rows.map((row) => row.id); - // Get courses for auto-enrollment const courseIds = await getCoursesFromModules(client, moduleIds); - - // Auto-enroll user in courses + if (courseIds.length > 0) { await ensureUserEnrolledInCourses(client, user.userId, courseIds); } - // Add modules to roadmap for (let i = 0; i < moduleIds.length; i++) { await client.query( "INSERT INTO roadmap_items (roadmap_id, module_id, position) VALUES ($1, $2, $3)", @@ -436,19 +443,20 @@ router.post("/responses", async (req, res) => { ); } - console.log(`Auto-generated roadmap "${roadmapResult.rows[0].name}" with ${moduleIds.length} modules for user ${user.userId}`); + console.log( + `Auto-generated roadmap "${roadmapResult.rows[0].name}" with ${moduleIds.length} modules for user ${user.userId}` + ); } } } catch (roadmapError) { - // Don't fail the onboarding if roadmap generation fails console.error("Failed to auto-generate roadmap:", roadmapError); } await client.query("COMMIT"); - res.json({ + res.json({ message: "Responses submitted successfully", - roadmapGenerated: true + roadmapGenerated: true, }); } catch (err) { await client.query("ROLLBACK"); diff --git a/routes/orgs.js b/routes/orgs.js index 1ec4bbb..bea3d46 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -1,4 +1,3 @@ -// routes/orgs.js const express = require("express"); const pool = require("../database/db"); const router = express.Router(); @@ -35,7 +34,6 @@ router.post("/", async (req, res) => { try { await client.query("BEGIN"); - // 1) Insert into organisations const orgRes = await client.query( `INSERT INTO organisations (organisation_name, admin_user_id) VALUES ($1, $2) @@ -44,7 +42,6 @@ router.post("/", async (req, res) => { ); const org = orgRes.rows[0]; - // 2) Link user → new org as admin await client.query( `INSERT INTO organisation_users (user_id, organisation_id, role) VALUES ($1, $2, 'admin')`, @@ -56,7 +53,6 @@ router.post("/", async (req, res) => { } catch (err) { await client.query("ROLLBACK"); console.error(err); - // unique violation on org name if (err.code === "23505") { if ( err.constraint === "organisations_organisation_name_admin_user_id_key" @@ -95,7 +91,6 @@ router.post("/addemployee", async (req, res) => { try { await client.query("BEGIN"); - // 1) See if org exists const orgRes = await client.query( `SELECT id, organisation_name FROM organisations WHERE current_invitation_id = $1`, [inviteCode] @@ -106,7 +101,6 @@ router.post("/addemployee", async (req, res) => { const org = orgRes.rows[0]; const organisationId = org.id; - // 2) Link user → new org as employee await client.query( `INSERT INTO organisation_users (user_id, organisation_id, role) VALUES ($1, $2, 'employee')`, @@ -134,7 +128,6 @@ router.post("/addemployee", async (req, res) => { } }); -// Get the single organization (and role) for the current user router.get("/my", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -164,7 +157,6 @@ router.get("/my", async (req, res) => { return res.json({ organisation: null }); } - // exactly one row guaranteed by PK on user_id return res.json({ organisation: result.rows[0] }); } catch (err) { console.error(err); @@ -199,7 +191,6 @@ router.get("/settings", async (req, res) => { return res.status(400).json({ message: "Organization not found" }); } - // exactly one row guaranteed by PK on user_id return res.json({ organisation: result.rows[0] }); } catch (err) { console.error(err); diff --git a/routes/reports.js b/routes/reports.js index a51ea3b..6263a3a 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -21,7 +21,6 @@ router.get("/progress", async (req, res) => { try { await client.query("BEGIN"); - // 1) Courses done const { rows: coursesDone } = await client.query( `SELECT c.id, c.name, e.completed_at, JSON_BUILD_OBJECT( @@ -46,7 +45,6 @@ router.get("/progress", async (req, res) => { [userId] ); - // 2) Modules done const { rows: modCount } = await client.query( `SELECT COUNT(*) AS modules_done FROM module_status ms @@ -58,7 +56,6 @@ router.get("/progress", async (req, res) => { ); const modulesDone = parseInt(modCount[0].modules_done, 10); - // 3) Quiz results (latest per quiz) const { rows: quizResults } = await client.query( `WITH latest AS ( SELECT DISTINCT ON (qr.quiz_id) @@ -89,7 +86,6 @@ router.get("/progress", async (req, res) => { [userId] ); - // Strengths & weaknesses by skill const { rows: skillPerf } = await client.query( `WITH latest AS ( SELECT DISTINCT ON (qr.quiz_id) diff --git a/routes/roadmaps-helpers.js b/routes/roadmaps-helpers.js index 9ecbb0b..3ac9132 100644 --- a/routes/roadmaps-helpers.js +++ b/routes/roadmaps-helpers.js @@ -1,22 +1,19 @@ -// Shared helper functions for roadmap operations - async function getUserPreferences(client, userId) { try { - // Get user's channel preferences from member settings (highest priority) const memberChannelsResult = await client.query( "SELECT channel_id FROM user_channels WHERE user_id = $1", [userId] ); - const memberChannels = memberChannelsResult.rows.map(row => row.channel_id); + const memberChannels = memberChannelsResult.rows.map( + (row) => row.channel_id + ); - // Get user's level preferences from member settings (highest priority) const memberLevelsResult = await client.query( "SELECT level_id FROM user_levels WHERE user_id = $1", [userId] ); - const memberLevels = memberLevelsResult.rows.map(row => row.level_id); + const memberLevels = memberLevelsResult.rows.map((row) => row.level_id); - // Get user's skills from onboarding responses const skillsResult = await client.query( `SELECT DISTINCT oqo.skill_id FROM onboarding_responses or_table @@ -24,9 +21,8 @@ async function getUserPreferences(client, userId) { WHERE or_table.user_id = $1 AND oqo.skill_id IS NOT NULL`, [userId] ); - const skills = skillsResult.rows.map(row => row.skill_id); + const skills = skillsResult.rows.map((row) => row.skill_id); - // Get channel preferences from onboarding responses (fallback) const onboardingChannelsResult = await client.query( `SELECT DISTINCT oqo.channel_id FROM onboarding_responses or_table @@ -34,9 +30,10 @@ async function getUserPreferences(client, userId) { WHERE or_table.user_id = $1 AND oqo.channel_id IS NOT NULL`, [userId] ); - const onboardingChannels = onboardingChannelsResult.rows.map(row => row.channel_id); + const onboardingChannels = onboardingChannelsResult.rows.map( + (row) => row.channel_id + ); - // Get level preferences from onboarding responses (fallback) const onboardingLevelsResult = await client.query( `SELECT DISTINCT oqo.level_id FROM onboarding_responses or_table @@ -44,66 +41,91 @@ async function getUserPreferences(client, userId) { WHERE or_table.user_id = $1 AND oqo.level_id IS NOT NULL`, [userId] ); - const onboardingLevels = onboardingLevelsResult.rows.map(row => row.level_id); + const onboardingLevels = onboardingLevelsResult.rows.map( + (row) => row.level_id + ); return { skills, memberChannels, memberLevels, onboardingChannels, - onboardingLevels + onboardingLevels, + channels: { + all: [...memberChannels, ...onboardingChannels], + member: memberChannels, + onboarding: onboardingChannels, + }, + levels: { + all: [...memberLevels, ...onboardingLevels], + member: memberLevels, + onboarding: onboardingLevels, + }, }; } catch (error) { - console.error('Error getting user preferences:', error); + console.error("Error getting user preferences:", error); return { skills: [], memberChannels: [], memberLevels: [], onboardingChannels: [], - onboardingLevels: [] + onboardingLevels: [], + channels: { + all: [], + member: [], + onboarding: [], + }, + levels: { + all: [], + member: [], + onboarding: [], + }, }; } } async function getCoursesFromModules(client, moduleIds) { if (moduleIds.length === 0) return []; - + const result = await client.query( "SELECT DISTINCT course_id FROM modules WHERE id = ANY($1)", [moduleIds] ); - return result.rows.map(row => row.course_id); + return result.rows.map((row) => row.course_id); } async function ensureUserEnrolledInCourses(client, userId, courseIds) { - if (courseIds.length === 0) return; + if (courseIds.length === 0) return []; + + const enrolledCourses = []; for (const courseId of courseIds) { - // Insert enrollment if it doesn't exist - await client.query( + const enrollmentResult = await client.query( `INSERT INTO enrollments (user_id, course_id, status) VALUES ($1, $2, 'enrolled') - ON CONFLICT (user_id, course_id) DO NOTHING`, + ON CONFLICT (user_id, course_id) DO NOTHING + RETURNING id`, [userId, courseId] ); - // Get all modules for this course and create module_status records + if (enrollmentResult.rows.length > 0) { + enrolledCourses.push(courseId); + } + const modulesResult = await client.query( "SELECT id FROM modules WHERE course_id = $1", [courseId] ); for (const moduleRow of modulesResult.rows) { - // Get the enrollment id - const enrollmentResult = await client.query( + const enrollmentIdResult = await client.query( "SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2", [userId, courseId] ); - if (enrollmentResult.rows.length > 0) { - const enrollmentId = enrollmentResult.rows[0].id; + if (enrollmentIdResult.rows.length > 0) { + const enrollmentId = enrollmentIdResult.rows[0].id; - // Create module_status if it doesn't exist await client.query( `INSERT INTO module_status (enrollment_id, module_id, status) VALUES ($1, $2, 'not_started') @@ -113,10 +135,12 @@ async function ensureUserEnrolledInCourses(client, userId, courseIds) { } } } + + return enrolledCourses; } module.exports = { getUserPreferences, getCoursesFromModules, - ensureUserEnrolledInCourses -}; \ No newline at end of file + ensureUserEnrolledInCourses, +}; diff --git a/routes/roadmaps.js b/routes/roadmaps.js index 983a06c..13d3a35 100644 --- a/routes/roadmaps.js +++ b/routes/roadmaps.js @@ -7,7 +7,6 @@ const { ensureUserEnrolledInCourses, } = require("./roadmaps-helpers"); -// Helper function to parse auth cookie function getAuthUser(req) { const { auth } = req.cookies; if (!auth) return null; @@ -18,7 +17,6 @@ function getAuthUser(req) { } } -// GET /api/roadmaps - Get user's roadmaps router.get("/", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -41,7 +39,6 @@ router.get("/", async (req, res) => { } }); -// POST /api/roadmaps - Create new roadmap router.post("/", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -68,7 +65,6 @@ router.post("/", async (req, res) => { } }); -// PUT /api/roadmaps/:id - Update roadmap router.put("/:id", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -102,7 +98,6 @@ router.put("/:id", async (req, res) => { } }); -// DELETE /api/roadmaps/:id - Delete roadmap router.delete("/:id", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -130,7 +125,6 @@ router.delete("/:id", async (req, res) => { } }); -// GET /api/roadmaps/:id/items - Get roadmap modules with details router.get("/:id/items", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -140,7 +134,6 @@ router.get("/:id/items", async (req, res) => { const { id } = req.params; try { - // First verify the roadmap belongs to the user const roadmapCheck = await pool.query( `SELECT id FROM roadmaps WHERE id = $1 AND user_id = $2`, [id, user.userId] @@ -150,7 +143,6 @@ router.get("/:id/items", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } - // Get roadmap items with module details const result = await pool.query( `SELECT ri.position, @@ -197,7 +189,6 @@ router.get("/:id/items", async (req, res) => { } }); -// POST /api/roadmaps/:id/items - Add module to roadmap router.post("/:id/items", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -215,7 +206,6 @@ router.post("/:id/items", async (req, res) => { try { await client.query("BEGIN"); - // Verify roadmap belongs to user const roadmapCheck = await client.query( `SELECT id FROM roadmaps WHERE id = $1 AND user_id = $2`, [id, user.userId] @@ -226,7 +216,6 @@ router.post("/:id/items", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } - // Check if module is already in roadmap const existingCheck = await client.query( `SELECT 1 FROM roadmap_items WHERE roadmap_id = $1 AND module_id = $2`, [id, module_id] @@ -237,7 +226,6 @@ router.post("/:id/items", async (req, res) => { return res.status(400).json({ message: "Module already in roadmap" }); } - // Get next position const positionResult = await client.query( `SELECT COALESCE(MAX(position), 0) + 1 as next_position FROM roadmap_items WHERE roadmap_id = $1`, @@ -246,7 +234,6 @@ router.post("/:id/items", async (req, res) => { const nextPosition = positionResult.rows[0].next_position; - // Get course ID from module and auto-enroll user const courseIds = await getCoursesFromModules(client, [module_id]); const enrolledCourses = await ensureUserEnrolledInCourses( client, @@ -254,7 +241,6 @@ router.post("/:id/items", async (req, res) => { courseIds ); - // Add module to roadmap await client.query( `INSERT INTO roadmap_items (roadmap_id, module_id, position) VALUES ($1, $2, $3)`, @@ -277,7 +263,6 @@ router.post("/:id/items", async (req, res) => { } }); -// PUT /api/roadmaps/:id/items/:moduleId - Update item position router.put("/:id/items/:moduleId", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -295,7 +280,6 @@ router.put("/:id/items/:moduleId", async (req, res) => { try { await client.query("BEGIN"); - // Verify roadmap belongs to user const roadmapCheck = await client.query( `SELECT id FROM roadmaps WHERE id = $1 AND user_id = $2`, [id, user.userId] @@ -306,7 +290,6 @@ router.put("/:id/items/:moduleId", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } - // Update position const result = await client.query( `UPDATE roadmap_items SET position = $1 @@ -335,7 +318,6 @@ router.put("/:id/items/:moduleId", async (req, res) => { } }); -// DELETE /api/roadmaps/:id/items/:moduleId - Remove module from roadmap (does not unenroll from course) router.delete("/:id/items/:moduleId", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -345,8 +327,6 @@ router.delete("/:id/items/:moduleId", async (req, res) => { const { id, moduleId } = req.params; try { - // Verify roadmap belongs to user and remove item - // Note: We do NOT auto-unenroll from courses as user may be taking them independently const result = await pool.query( `DELETE FROM roadmap_items WHERE roadmap_id = $1 AND module_id = $2 @@ -369,7 +349,6 @@ router.delete("/:id/items/:moduleId", async (req, res) => { } }); -// POST /api/roadmaps/generate - Auto-generate roadmap based on user's onboarding skills router.post("/generate", async (req, res) => { const user = getAuthUser(req); if (!user || !user.isLoggedIn) { @@ -390,7 +369,6 @@ router.post("/generate", async (req, res) => { try { await client.query("BEGIN"); - // 1. Create the roadmap const roadmapResult = await client.query( `INSERT INTO roadmaps (user_id, name) VALUES ($1, $2) @@ -400,7 +378,6 @@ router.post("/generate", async (req, res) => { const roadmap = roadmapResult.rows[0]; - // 2. Get user's skills from onboarding responses const userSkillsResult = await client.query( `SELECT DISTINCT oqo.skill_id FROM onboarding_responses or_table @@ -409,7 +386,6 @@ router.post("/generate", async (req, res) => { [user.userId] ); - // 3. Get user's channel and level preferences (combines member settings and onboarding) const userPreferences = await getUserPreferences(client, user.userId); const userSkillIds = userSkillsResult.rows.map((row) => row.skill_id); @@ -418,25 +394,28 @@ router.post("/generate", async (req, res) => { let modulesAdded = 0; let enrolledCourses = []; - if (userSkillIds.length > 0) { - // 4. Get recommended modules with enhanced scoring based on member preferences - // Exclude modules that the user has already completed + const hasPreferences = + userSkillIds.length > 0 || + userChannelIds.length > 0 || + userLevelIds.length > 0; + + if (hasPreferences) { let query = `SELECT DISTINCT mod.id, - COUNT(DISTINCT ms.skill_id) as matching_skills, + COALESCE(COUNT(DISTINCT ms.skill_id), 0) as matching_skills, CASE - WHEN cc.channel_id = ANY($3) THEN + WHEN cc.channel_id = ANY($2) THEN CASE - WHEN cc.channel_id = ANY($5) THEN 5 -- Member setting preference (highest priority) + WHEN cc.channel_id = ANY($4) THEN 5 -- Member setting preference (highest priority) ELSE 3 -- Onboarding preference END WHEN cc.channel_id IS NOT NULL THEN 1 ELSE 0 END as channel_match, CASE - WHEN cc.level_id = ANY($4) THEN + WHEN cc.level_id = ANY($3) THEN CASE - WHEN cc.level_id = ANY($6) THEN 5 -- Member setting preference (highest priority) + WHEN cc.level_id = ANY($5) THEN 5 -- Member setting preference (highest priority) ELSE 3 -- Onboarding preference END WHEN cc.level_id IS NOT NULL THEN 1 @@ -447,17 +426,15 @@ router.post("/generate", async (req, res) => { RANDOM() as random_score FROM modules mod JOIN courses c ON c.id = mod.course_id - JOIN module_skills ms ON ms.module_id = mod.id + LEFT JOIN module_skills ms ON ms.module_id = mod.id LEFT JOIN course_channels cc ON cc.course_id = c.id - LEFT JOIN enrollments e ON e.course_id = c.id AND e.user_id = $7 + LEFT JOIN enrollments e ON e.course_id = c.id AND e.user_id = $6 LEFT JOIN module_status mst ON mst.module_id = mod.id AND mst.enrollment_id = e.id WHERE c.organisation_id = $1 - AND ms.skill_id = ANY($2) AND (mst.status IS NULL OR mst.status IN ('not_started', 'in_progress'))`; - const params = [ + let params = [ organisationId, - userSkillIds, userChannelIds, userLevelIds, userPreferences.channels.member, @@ -465,35 +442,36 @@ router.post("/generate", async (req, res) => { user.userId, ]; - // Add additional filtering for preferred channels and levels if they exist - if (userChannelIds.length > 0 || userLevelIds.length > 0) { - query += ` AND (`; - const conditions = []; - - if (userChannelIds.length > 0) { - conditions.push(`cc.channel_id = ANY($3)`); - } - - if (userLevelIds.length > 0) { - conditions.push(`cc.level_id = ANY($4)`); - } - - // Also include modules without specific channel/level assignments - conditions.push(`cc.channel_id IS NULL OR cc.level_id IS NULL`); - - query += conditions.join(" OR ") + ")"; + if (userSkillIds.length > 0) { + query += ` AND ms.skill_id = ANY($7)`; + params.push(userSkillIds); } query += ` GROUP BY mod.id, cc.channel_id, cc.level_id ORDER BY matching_skills DESC, channel_match DESC, level_match DESC, random_score LIMIT 10`; // Limit to top 10 modules + console.log("Generated Query:", query); + console.log("Query Parameters:", params); + const modulesResult = await client.query(query, params); + console.log("Query Results:", modulesResult.rows.length, "modules found"); + console.log( + "Module Details:", + modulesResult.rows.map((row) => ({ + id: row.id, + matching_skills: row.matching_skills, + channel_match: row.channel_match, + level_match: row.level_match, + channel_id: row.channel_id, + level_id: row.level_id, + })) + ); + if (modulesResult.rows.length > 0) { const moduleIds = modulesResult.rows.map((row) => row.id); - // 5. Get courses from these modules and auto-enroll user const courseIds = await getCoursesFromModules(client, moduleIds); enrolledCourses = await ensureUserEnrolledInCourses( client, @@ -501,7 +479,6 @@ router.post("/generate", async (req, res) => { courseIds ); - // 6. Add modules to roadmap for (let i = 0; i < modulesResult.rows.length; i++) { const module = modulesResult.rows[i]; await client.query( diff --git a/routes/users.js b/routes/users.js index 120b14d..c0df150 100644 --- a/routes/users.js +++ b/routes/users.js @@ -408,11 +408,6 @@ router.delete("/skills", async (req, res) => { } }); -// ============================================================================= -// USER PREFERENCES ENDPOINTS (Channels and Levels) -// ============================================================================= - -// GET /api/users/preferences - Get user's channel and level preferences along with available options router.get("/preferences", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -431,7 +426,6 @@ router.get("/preferences", async (req, res) => { try { await client.query("BEGIN"); - // Get user's channel preferences const userChannelsResult = await client.query( `SELECT uc.id, uc.channel_id, c.name as channel_name, c.description as channel_description, uc.preference_rank FROM user_channels uc @@ -441,7 +435,6 @@ router.get("/preferences", async (req, res) => { [userId] ); - // Get user's level preferences const userLevelsResult = await client.query( `SELECT ul.id, ul.level_id, l.name as level_name, l.description as level_description, l.sort_order, ul.preference_rank FROM user_levels ul @@ -451,7 +444,6 @@ router.get("/preferences", async (req, res) => { [userId] ); - // Get all available channels for the organization const allChannelsResult = await client.query( `SELECT id, name, description FROM channels @@ -460,7 +452,6 @@ router.get("/preferences", async (req, res) => { [organisationId] ); - // Get all available levels for the organization const allLevelsResult = await client.query( `SELECT id, name, description, sort_order FROM levels @@ -475,7 +466,7 @@ router.get("/preferences", async (req, res) => { userChannels: userChannelsResult.rows, userLevels: userLevelsResult.rows, availableChannels: allChannelsResult.rows, - availableLevels: allLevelsResult.rows + availableLevels: allLevelsResult.rows, }); } catch (error) { await client.query("ROLLBACK"); @@ -486,7 +477,6 @@ router.get("/preferences", async (req, res) => { } }); -// POST /api/users/preferences/channels - Add channel preference router.post("/preferences/channels", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -510,7 +500,6 @@ router.post("/preferences/channels", async (req, res) => { try { await client.query("BEGIN"); - // Verify channel belongs to user's organization const channelCheck = await client.query( "SELECT id FROM channels WHERE id = $1 AND organisation_id = $2", [channel_id, organisationId] @@ -521,7 +510,6 @@ router.post("/preferences/channels", async (req, res) => { return res.status(400).json({ message: "Invalid channel" }); } - // Check if preference already exists const existingResult = await client.query( "SELECT id FROM user_channels WHERE user_id = $1 AND channel_id = $2", [userId, channel_id] @@ -529,10 +517,11 @@ router.post("/preferences/channels", async (req, res) => { if (existingResult.rows.length > 0) { await client.query("ROLLBACK"); - return res.status(400).json({ message: "Channel preference already exists" }); + return res + .status(400) + .json({ message: "Channel preference already exists" }); } - // Get next preference rank const rankResult = await client.query( "SELECT COALESCE(MAX(preference_rank), 0) + 1 as next_rank FROM user_channels WHERE user_id = $1", [userId] @@ -540,7 +529,6 @@ router.post("/preferences/channels", async (req, res) => { const nextRank = rankResult.rows[0].next_rank; - // Add channel preference await client.query( "INSERT INTO user_channels (user_id, channel_id, preference_rank) VALUES ($1, $2, $3)", [userId, channel_id, nextRank] @@ -557,7 +545,6 @@ router.post("/preferences/channels", async (req, res) => { } }); -// POST /api/users/preferences/levels - Add level preference router.post("/preferences/levels", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -581,7 +568,6 @@ router.post("/preferences/levels", async (req, res) => { try { await client.query("BEGIN"); - // Verify level belongs to user's organization const levelCheck = await client.query( "SELECT id FROM levels WHERE id = $1 AND organisation_id = $2", [level_id, organisationId] @@ -592,7 +578,6 @@ router.post("/preferences/levels", async (req, res) => { return res.status(400).json({ message: "Invalid level" }); } - // Check if preference already exists const existingResult = await client.query( "SELECT id FROM user_levels WHERE user_id = $1 AND level_id = $2", [userId, level_id] @@ -600,10 +585,11 @@ router.post("/preferences/levels", async (req, res) => { if (existingResult.rows.length > 0) { await client.query("ROLLBACK"); - return res.status(400).json({ message: "Level preference already exists" }); + return res + .status(400) + .json({ message: "Level preference already exists" }); } - // Get next preference rank const rankResult = await client.query( "SELECT COALESCE(MAX(preference_rank), 0) + 1 as next_rank FROM user_levels WHERE user_id = $1", [userId] @@ -611,7 +597,6 @@ router.post("/preferences/levels", async (req, res) => { const nextRank = rankResult.rows[0].next_rank; - // Add level preference await client.query( "INSERT INTO user_levels (user_id, level_id, preference_rank) VALUES ($1, $2, $3)", [userId, level_id, nextRank] @@ -628,7 +613,6 @@ router.post("/preferences/levels", async (req, res) => { } }); -// DELETE /api/users/preferences/channels - Remove channel preference router.delete("/preferences/channels", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -651,7 +635,6 @@ router.delete("/preferences/channels", async (req, res) => { try { await client.query("BEGIN"); - // Remove channel preference const result = await client.query( "DELETE FROM user_channels WHERE user_id = $1 AND channel_id = $2", [userId, channel_id] @@ -673,7 +656,6 @@ router.delete("/preferences/channels", async (req, res) => { } }); -// DELETE /api/users/preferences/levels - Remove level preference router.delete("/preferences/levels", async (req, res) => { const { auth } = req.cookies; if (!auth) return res.status(401).json({ message: "Not authenticated" }); @@ -696,7 +678,6 @@ router.delete("/preferences/levels", async (req, res) => { try { await client.query("BEGIN"); - // Remove level preference const result = await client.query( "DELETE FROM user_levels WHERE user_id = $1 AND level_id = $2", [userId, level_id] @@ -718,5 +699,4 @@ router.delete("/preferences/levels", async (req, res) => { } }); - module.exports = router; From 5ce866b5372c9b6e28bbd8c65d1935e44c1e9155 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 12:14:59 +0800 Subject: [PATCH 10/32] add new table for user activity --- database/schema.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/database/schema.sql b/database/schema.sql index 20ec734..347a246 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -322,5 +322,15 @@ CREATE TABLE user_levels ( UNIQUE(user_id, level_id) ); +CREATE TABLE activity_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organisation_id INTEGER REFERENCES organisations(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + metadata JSONB DEFAULT '{}' NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + + COMMIT; \ No newline at end of file From 867fc234a27fdee197e8fcee46e39548e3b022f1 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 12:26:39 +0800 Subject: [PATCH 11/32] Update logging for authentication --- routes/activityLogger.js | 12 ++++++++++++ routes/auth.js | 21 ++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 routes/activityLogger.js diff --git a/routes/activityLogger.js b/routes/activityLogger.js new file mode 100644 index 0000000..815d1e7 --- /dev/null +++ b/routes/activityLogger.js @@ -0,0 +1,12 @@ +const pool = require("../database/db"); + +async function logActivity({ userId, organisationId, action, metadata = {} }) { + const sql = ` + INSERT INTO activity_logs + (user_id, organisation_id, action, metadata) + VALUES ($1, $2, $3, $4) + `; + await pool.query(sql, [userId, organisationId, action, metadata]); +} + +module.exports = logActivity; diff --git a/routes/auth.js b/routes/auth.js index 0d601f9..abe7aba 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -2,6 +2,7 @@ const express = require("express"); const bcrypt = require("bcrypt"); const pool = require("../database/db"); const router = express.Router(); +const logActivity = require("./activityLogger"); function setAuthCookie(res, payload) { res.cookie("auth", JSON.stringify(payload), { @@ -81,6 +82,12 @@ router.post("/login", async (req, res) => { hasCompletedOnboarding: u.has_completed_onboarding, organisation, }); + await logActivity({ + userId: u.id, + organisationId: organisation ? organisation.id : null, + action: "login", + metadata: { email }, + }); return res.json({ success: true }); } catch (err) { console.error(err); @@ -88,8 +95,14 @@ router.post("/login", async (req, res) => { } }); -router.post("/logout", (req, res) => { +router.post("/logout", async (req, res) => { + const session = JSON.parse(req.cookies.auth || "{}"); res.clearCookie("auth", { path: "/" }).json({ success: true }); + await logActivity({ + userId: session.userId, + organisationId: session.organisation ? session.organisation.id : null, + action: "logout", + }); }); router.get("/me", (req, res) => { @@ -161,6 +174,12 @@ router.post("/complete-onboarding", async (req, res) => { organisation: organisation, }); + await logActivity({ + userId: user.userId, + organisationId: organisation ? organisation.id : null, + action: "complete_onboarding", + }); + res.json({ success: true }); } catch (err) { console.error(err); From b8722b08c5365ac7ca8db3f14781074f343a8fb1 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 12:44:51 +0800 Subject: [PATCH 12/32] Update logging for courses --- routes/courses.js | 113 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index a45c4d3..8b19467 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -3,6 +3,7 @@ const pool = require("../database/db"); const router = express.Router(); const multer = require("multer"); const path = require("path"); +const logActivity = require("./activityLogger"); const storage = multer.diskStorage({ destination: "uploads/", @@ -65,6 +66,12 @@ router.post("/", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "create_course", + metadata: { courseId }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -238,6 +245,12 @@ router.delete("/", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation.id, + action: "delete_course", + metadata: { courseId }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -307,6 +320,12 @@ router.put("/", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation.id, + action: "edit_course", + metadata: { courseId }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -513,6 +532,12 @@ router.post("/add-module", upload.single("file"), async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation.id, + action: "add_module", + metadata: { moduleId }, + }); return res.status(201).json({ module_id, }); @@ -554,6 +579,12 @@ router.delete("/delete-module", async (req, res) => { ]); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation.id, + action: "delete_module", + metadata: { moduleId }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -923,6 +954,12 @@ router.put("/update-module", upload.single("file"), async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation.id, + action: "edit_module", + metadata: { moduleId }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -1132,6 +1169,12 @@ router.post("/enroll-course", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "enroll_course", + metadata: { courseId }, + }); return res.status(201).json({ success: true, enrollment: insertRes.rows[0], @@ -1207,6 +1250,12 @@ router.post("/unenroll-course", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "unenroll_course", + metadata: { courseId }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -1269,6 +1318,12 @@ router.post("/complete-course", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "complete_course", + metadata: { courseId }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -1310,6 +1365,12 @@ router.post("/uncomplete-course", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "uncomplete_course", + metadata: { courseId }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -1758,6 +1819,12 @@ router.post("/mark-module-started", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "start_module", + metadata: { moduleId }, + }); return res.status(200).json({ status: "in_progress" }); } catch (err) { await client.query("ROLLBACK"); @@ -1826,6 +1893,12 @@ router.post("/mark-module-completed", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "complete_module", + metadata: { moduleId }, + }); return res.status(200).json({ status: "in_progress" }); } catch (err) { await client.query("ROLLBACK"); @@ -1902,6 +1975,12 @@ router.post("/add-channel", async (req, res) => { [name, description || "", organisationId] ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "add_channel", + metadata: { channelId: channel.id, name: channel.name }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -1947,6 +2026,12 @@ router.delete("/delete-channel", async (req, res) => { [channelId, organisationId] ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "delete_channel", + metadata: { channelId }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -2023,6 +2108,16 @@ router.post("/add-level", async (req, res) => { [name, description || "", sort_order || 0, organisationId] ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "add_level", + metadata: { + levelId: level.id, + name: level.name, + sortOrder: level.sort_order, + }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -2068,6 +2163,12 @@ router.delete("/delete-level", async (req, res) => { [levelId, organisationId] ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "delete_level", + metadata: { levelId }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -2144,6 +2245,12 @@ router.post("/add-skill", async (req, res) => { [name, description || "", organisationId] ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "add_skill", + metadata: { skillId: skill.id, name: skill.name }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -2189,6 +2296,12 @@ router.delete("/delete-skill", async (req, res) => { [skillId, organisationId] ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "delete_skill", + metadata: { skillId }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); From 362cf8ef451dacc422533adaaaef43fcd50f520f Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 12:54:37 +0800 Subject: [PATCH 13/32] Update logging for onboarding --- routes/onboarding.js | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/routes/onboarding.js b/routes/onboarding.js index 8f01842..9c0728a 100644 --- a/routes/onboarding.js +++ b/routes/onboarding.js @@ -104,6 +104,18 @@ router.post("/questions", async (req, res) => { [question_text, position, organisationId] ); + // commit + if (result.rows.length === 0) { + return res.status(500).json({ message: "Failed to create question" }); + } + + await logActivity({ + userId: user.userId, + organisationId, + action: "add_onboarding_question", + metadata: { questionId: result.rows[0].id }, + }); + res.status(201).json({ question: result.rows[0] }); } catch (err) { console.error(err); @@ -205,6 +217,16 @@ router.post("/questions/:id/options", async (req, res) => { level_sort_order: level_id ? levelCheck.rows[0].sort_order : null, }; + await logActivity({ + userId: user.userId, + organisationId: user.organisation.id, + action: "add_onboarding_option", + metadata: { + questionId: id, + optionId: result.rows[0].id, + }, + }); + res.status(201).json({ option }); } catch (err) { await client.query("ROLLBACK"); @@ -255,6 +277,13 @@ router.delete("/questions/:id", async (req, res) => { return res.status(404).json({ message: "Question not found" }); } + await logActivity({ + userId: user.userId, + organisationId, + action: "delete_onboarding_question", + metadata: { questionId: result.rows[0].id }, + }); + res.json({ message: "Question deleted successfully" }); } catch (err) { console.error(err); @@ -316,6 +345,13 @@ router.delete("/options/:optionId", async (req, res) => { [optionId] ); + await logActivity({ + userId: user.userId, + organisationId, + action: "delete_onboarding_option", + metadata: { optionId: result.rows[0].id }, + }); + res.json({ message: "Option deleted successfully" }); } catch (err) { console.error(err); @@ -454,6 +490,13 @@ router.post("/responses", async (req, res) => { await client.query("COMMIT"); + await logActivity({ + userId: user.userId, + organisationId: user.organisation.id, + action: "submit_onboarding_responses", + metadata: { optionIds: option_ids }, + }); + res.json({ message: "Responses submitted successfully", roadmapGenerated: true, From 8c3f6ef13630ef09be1640522ca0c7a4451b889e Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 12:58:33 +0800 Subject: [PATCH 14/32] Update logging for orgs --- routes/orgs.js | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/routes/orgs.js b/routes/orgs.js index bea3d46..fa7243d 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -2,6 +2,7 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); const crypto = require("crypto"); +const logActivity = require("./activityLogger"); function setAuthCookie(res, payload) { res.cookie("auth", JSON.stringify(payload), { @@ -49,6 +50,12 @@ router.post("/", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId: org.id, + action: "create_organisation", + metadata: { organisationId: org.id }, + }); return res.status(201).json({ organisation: { ...org, role: "admin" } }); } catch (err) { await client.query("ROLLBACK"); @@ -117,6 +124,12 @@ router.post("/addemployee", async (req, res) => { role: "employee", }, }); + await logActivity({ + userId, + organisationId, + action: "add_employee", + metadata: { organisationId }, + }); return res.status(201).json({ organisation: { ...org, role: "employee" } }); } catch (err) { @@ -265,6 +278,17 @@ router.post("/settings", async (req, res) => { organisation: neworganisation, }); + await logActivity({ + userId, + organisationId: organisation_id, + action: "update_organisation_settings", + metadata: { + organisationId: organisation_id, + ai_enabled, + description, + }, + }); + return res.json({ organisation: updateRes.rows[0] }); } catch (err) { await client.query("ROLLBACK"); @@ -316,6 +340,12 @@ router.get("/generate-invite-code", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "generate_invite_code", + metadata: { organisationId, inviteCode }, + }); return res.json({ inviteCode: updateRes.rows[0].current_invitation_id }); } catch (err) { await client.query("ROLLBACK"); From 7fff30a2de158ecdbc915de5f261939e6e334c51 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:02:42 +0800 Subject: [PATCH 15/32] Update logging for roadmap --- routes/roadmaps.js | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/routes/roadmaps.js b/routes/roadmaps.js index 13d3a35..180727d 100644 --- a/routes/roadmaps.js +++ b/routes/roadmaps.js @@ -1,6 +1,7 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); +const logActivity = require("./activityLogger"); const { getUserPreferences, getCoursesFromModules, @@ -58,6 +59,13 @@ router.post("/", async (req, res) => { [user.userId, name.trim()] ); + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "create_roadmap", + metadata: { roadmapId: roadmap.id }, + }); + res.status(201).json({ roadmap: result.rows[0] }); } catch (err) { console.error(err); @@ -91,6 +99,13 @@ router.put("/:id", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "edit_roadmap", + metadata: { roadmapId: updated.id, newName: updated.name }, + }); + res.json({ roadmap: result.rows[0] }); } catch (err) { console.error(err); @@ -118,6 +133,13 @@ router.delete("/:id", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "delete_roadmap", + metadata: { roadmapId: id }, + }); + res.json({ message: "Roadmap deleted successfully" }); } catch (err) { console.error(err); @@ -249,6 +271,13 @@ router.post("/:id/items", async (req, res) => { await client.query("COMMIT"); + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "add_roadmap_item", + metadata: { roadmapId: id, moduleId: module_id, position: nextPosition }, + }); + res.status(201).json({ message: "Module added to roadmap", position: nextPosition, @@ -305,6 +334,13 @@ router.put("/:id/items/:moduleId", async (req, res) => { await client.query("COMMIT"); + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "move_roadmap_item", + metadata: { roadmapId: id, moduleId, newPosition: position }, + }); + res.json({ message: "Position updated", position: result.rows[0].position, @@ -342,6 +378,13 @@ router.delete("/:id/items/:moduleId", async (req, res) => { return res.status(404).json({ message: "Roadmap item not found" }); } + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "remove_roadmap_item", + metadata: { roadmapId: id, moduleId }, + }); + res.json({ message: "Module removed from roadmap" }); } catch (err) { console.error(err); @@ -494,6 +537,13 @@ router.post("/generate", async (req, res) => { await client.query("COMMIT"); + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "generate_roadmap", + metadata: { roadmapId: roadmap.id, modulesAdded, enrolledCourses }, + }); + res.status(201).json({ roadmap, modulesAdded, From eed31647bdef589aa574abb4f5e15f81fec65ad3 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:06:18 +0800 Subject: [PATCH 16/32] Update logging for users --- routes/users.js | 64 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/routes/users.js b/routes/users.js index c0df150..80be535 100644 --- a/routes/users.js +++ b/routes/users.js @@ -2,6 +2,7 @@ const express = require("express"); const bcrypt = require("bcrypt"); const pool = require("../database/db"); const router = express.Router(); +const logActivity = require("./activityLogger"); function setAuthCookie(res, payload) { res.cookie("auth", JSON.stringify(payload), { @@ -83,6 +84,12 @@ router.delete("/", async (req, res) => { return res.status(404).json({ message: "User not found" }); } await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "delete_user", + metadata: { deletedUserId }, + }); return res.status(201).json({ message: "User deleted successfully", }); @@ -146,6 +153,12 @@ router.put("/profile", async (req, res) => { setAuthCookie(res, updatedSession); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "update_profile", + metadata: { firstname, lastname, email }, + }); return res.json({ message: "Profile updated successfully", user: updatedSession, @@ -214,6 +227,12 @@ router.put("/password", async (req, res) => { ]); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "update_password", + metadata: {}, + }); return res.json({ message: "Password updated successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -311,6 +330,13 @@ router.post("/skills", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "add_user_skill", + metadata: { skillId: skill_id, level }, + }); + return res.json({ message: "Skill added successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -357,6 +383,13 @@ router.put("/skills", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "update_user_skill", + metadata: { skillId: skill_id, newLevel: level }, + }); + return res.json({ message: "Skill level updated successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -398,6 +431,12 @@ router.delete("/skills", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "remove_user_skill", + metadata: { skillId: skill_id }, + }); return res.json({ message: "Skill removed successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -535,6 +574,12 @@ router.post("/preferences/channels", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "add_channel_preference", + metadata: { channelId: channel_id, rank: nextRank }, + }); return res.json({ message: "Channel preference added successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -603,6 +648,12 @@ router.post("/preferences/levels", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "add_level_preference", + metadata: { levelId: level_id, rank: nextRank }, + }); return res.json({ message: "Level preference added successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -646,6 +697,12 @@ router.delete("/preferences/channels", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "remove_channel_preference", + metadata: { channelId: channel_id }, + }); return res.json({ message: "Channel preference removed successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -689,6 +746,13 @@ router.delete("/preferences/levels", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "remove_level_preference", + metadata: { levelId: level_id }, + }); + return res.json({ message: "Level preference removed successfully" }); } catch (error) { await client.query("ROLLBACK"); From 97c9aa8ed4c0e562906b70f0cfa690249639323f Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:08:42 +0800 Subject: [PATCH 17/32] add basic endpoint for history --- routes/activity.js | 49 ++++++++++++++++++++++++++++++++++++++++++++++ server.js | 2 ++ 2 files changed, 51 insertions(+) create mode 100644 routes/activity.js diff --git a/routes/activity.js b/routes/activity.js new file mode 100644 index 0000000..282e0b3 --- /dev/null +++ b/routes/activity.js @@ -0,0 +1,49 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); + +function getAuthUser(req) { + const { auth } = req.cookies; + if (!auth) return null; + try { + return JSON.parse(auth); + } catch { + return null; + } +} + +router.get("/", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) { + return res.status(401).json({ message: "Not logged in" }); + } + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + const { rows } = await pool.query( + ` + SELECT + id, + user_id, + action, + metadata, + created_at + FROM activity_logs + WHERE organisation_id = $1 AND + user_id = $2 + ORDER BY created_at DESC + LIMIT 100 + `, + [organisationId, user.userId] + ); + res.json({ logs: rows }); + } catch (err) { + console.error("Error fetching activity logs:", err); + res.status(500).json({ message: "Server error" }); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index 5139b11..f01d7af 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ const reportsRoutes = require("./routes/reports"); const onboardingRoutes = require("./routes/onboarding"); const roadmapRoutes = require("./routes/roadmaps"); const materialRoutes = require("./routes/materials"); +const activityRoutes = require("./routes/activity"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; @@ -50,6 +51,7 @@ app.use("/api/reports", reportsRoutes); app.use("/api/onboarding", onboardingRoutes); app.use("/api/roadmaps", roadmapRoutes); app.use("/api/materials", materialRoutes); +app.use("/api/activity", activityRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); From 2f76f50570473154d7b0ff9e3d57b6527aeee715 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:23:26 +0800 Subject: [PATCH 18/32] Update to add missing fields --- routes/courses.js | 6 ++++++ routes/users.js | 2 ++ 2 files changed, 8 insertions(+) diff --git a/routes/courses.js b/routes/courses.js index 8b19467..bbc8173 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -1133,6 +1133,7 @@ router.post("/enroll-course", async (req, res) => { } const userId = session.userId; + const organisationId = session.organisation?.id; const { courseId } = req.body; if (!courseId) { return res.status(400).json({ message: "courseId is required" }); @@ -1206,6 +1207,7 @@ router.post("/unenroll-course", async (req, res) => { } const userId = session.userId; + const organisationId = session.organisation?.id; const { courseId } = req.body; if (!courseId) { return res.status(400).json({ message: "courseId is required" }); @@ -1279,6 +1281,7 @@ router.post("/complete-course", async (req, res) => { const userId = session.userId; const { courseId } = req.body; + const organisationId = session.organisation?.id; if (!courseId) { return res.status(400).json({ message: "courseId is required" }); } @@ -1346,6 +1349,7 @@ router.post("/uncomplete-course", async (req, res) => { } const userId = session.userId; + const organisationId = session.organisation?.id; const { courseId } = req.body; if (!courseId) { return res.status(400).json({ message: "courseId is required" }); @@ -1781,6 +1785,7 @@ router.post("/mark-module-started", async (req, res) => { const userId = session.userId; const moduleId = req.body.moduleId; + const organisationId = session.organisation?.id; if (!moduleId) { return res.status(400).json({ message: "moduleId is required" }); } @@ -1848,6 +1853,7 @@ router.post("/mark-module-completed", async (req, res) => { const userId = session.userId; const moduleId = req.body.moduleId; + const organisationId = session.organisation?.id; if (!moduleId) { return res.status(400).json({ message: "moduleId is required" }); } diff --git a/routes/users.js b/routes/users.js index 80be535..74293e3 100644 --- a/routes/users.js +++ b/routes/users.js @@ -676,6 +676,7 @@ router.delete("/preferences/channels", async (req, res) => { } const userId = session.userId; + const organisationId = session.organisation?.id; const { channel_id } = req.body; if (!channel_id) { @@ -726,6 +727,7 @@ router.delete("/preferences/levels", async (req, res) => { const userId = session.userId; const { level_id } = req.body; + const organisationId = session.organisation?.id; if (!level_id) { return res.status(400).json({ message: "Level ID is required" }); From fe7f45afbc24f9339ed0f6be9033148ab4cac30c Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:25:58 +0800 Subject: [PATCH 19/32] Update activity_logs table --- database/schema.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/database/schema.sql b/database/schema.sql index 347a246..cd3b4b8 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -328,7 +328,8 @@ CREATE TABLE activity_logs ( organisation_id INTEGER REFERENCES organisations(id) ON DELETE SET NULL, action VARCHAR(50) NOT NULL, metadata JSONB DEFAULT '{}' NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + display_metadata JSONB NOT NULL DEFAULT '{}' ); From 3c96d913cd729e24304e466228c3dbafaf4acf0f Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:26:17 +0800 Subject: [PATCH 20/32] Update backend response --- routes/activity.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/activity.js b/routes/activity.js index 282e0b3..c3cebbd 100644 --- a/routes/activity.js +++ b/routes/activity.js @@ -29,7 +29,7 @@ router.get("/", async (req, res) => { id, user_id, action, - metadata, + display_metadata as metadata, created_at FROM activity_logs WHERE organisation_id = $1 AND From df81f1aa5fa062780579a8c86810ecb401da1d25 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:28:02 +0800 Subject: [PATCH 21/32] Update activity logger --- routes/activityLogger.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/routes/activityLogger.js b/routes/activityLogger.js index 815d1e7..0390521 100644 --- a/routes/activityLogger.js +++ b/routes/activityLogger.js @@ -1,12 +1,24 @@ const pool = require("../database/db"); -async function logActivity({ userId, organisationId, action, metadata = {} }) { +async function logActivity({ + userId, + organisationId, + action, + metadata = {}, + displayMetadata = {}, +}) { const sql = ` INSERT INTO activity_logs - (user_id, organisation_id, action, metadata) - VALUES ($1, $2, $3, $4) + (user_id, organisation_id, action, metadata, displayMetadata) + VALUES ($1, $2, $3, $4, $5) `; - await pool.query(sql, [userId, organisationId, action, metadata]); + await pool.query(sql, [ + userId, + organisationId, + action, + metadata, + displayMetadata, + ]); } module.exports = logActivity; From b6dfb33b822026d6ca11556cf454788d872e3927 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:47:55 +0800 Subject: [PATCH 22/32] Add display meta for courses --- routes/auth.js | 1 + routes/courses.js | 126 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/routes/auth.js b/routes/auth.js index abe7aba..e778159 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -87,6 +87,7 @@ router.post("/login", async (req, res) => { organisationId: organisation ? organisation.id : null, action: "login", metadata: { email }, + displayMetadata: { email }, }); return res.json({ success: true }); } catch (err) { diff --git a/routes/courses.js b/routes/courses.js index bbc8173..9e47cbb 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -71,6 +71,7 @@ router.post("/", async (req, res) => { organisationId, action: "create_course", metadata: { courseId }, + displayMetadata: { "course name": courseName }, }); return res.status(201).json({ success: true }); } catch (err) { @@ -238,6 +239,18 @@ router.delete("/", async (req, res) => { try { await client.query("BEGIN"); + const courseRes = await client.query( + `SELECT c.id, c.name FROM courses c + WHERE c.id = $1`, + [courseId] + ); + if (!courseRes.rows.length) { + console.error("Course not found for ID:", courseId); + return res.status(404).json({ message: "Course not found" }); + } + + const courseName = courseRes.rows[0].name; + const _ = await client.query( `DELETE FROM courses c WHERE c.id = $1`, @@ -250,6 +263,7 @@ router.delete("/", async (req, res) => { organisationId: session.organisation.id, action: "delete_course", metadata: { courseId }, + displayMetadata: { "course name": courseName }, }); return res.status(201).json({ success: true }); } catch (err) { @@ -325,6 +339,7 @@ router.put("/", async (req, res) => { organisationId: session.organisation.id, action: "edit_course", metadata: { courseId }, + displayMetadata: { "course name": courseName }, }); return res.status(201).json({ success: true }); } catch (err) { @@ -537,6 +552,7 @@ router.post("/add-module", upload.single("file"), async (req, res) => { organisationId: session.organisation.id, action: "add_module", metadata: { moduleId }, + displayMetadata: { "module name": name }, }); return res.status(201).json({ module_id, @@ -574,6 +590,15 @@ router.delete("/delete-module", async (req, res) => { try { await client.query("BEGIN"); + const moduleRes = await client.query( + `SELECT id, title FROM modules WHERE id = $1`, + [moduleId] + ); + if (!moduleRes.rows.length) { + return res.status(404).json({ message: "Module not found" }); + } + const moduleTitle = moduleRes.rows[0].title; + const _ = await client.query(`DELETE FROM modules WHERE id = $1`, [ moduleId, ]); @@ -584,6 +609,7 @@ router.delete("/delete-module", async (req, res) => { organisationId: session.organisation.id, action: "delete_module", metadata: { moduleId }, + displayMetadata: { "module title": moduleTitle }, }); return res.status(201).json({ success: true }); } catch (err) { @@ -959,6 +985,7 @@ router.put("/update-module", upload.single("file"), async (req, res) => { organisationId: session.organisation.id, action: "edit_module", metadata: { moduleId }, + displayMetadata: { "module name": name }, }); return res.status(200).json({ success: true }); } catch (err) { @@ -1152,6 +1179,16 @@ router.post("/enroll-course", async (req, res) => { const enrollmentId = insertRes.rows[0].id; + const courseRes = await client.query( + `SELECT id, name FROM courses WHERE id = $1`, + [courseId] + ); + if (!courseRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Course not found" }); + } + const courseName = courseRes.rows[0].name; + const modulesRes = await client.query( `SELECT id FROM modules @@ -1175,6 +1212,7 @@ router.post("/enroll-course", async (req, res) => { organisationId, action: "enroll_course", metadata: { courseId }, + displayMetadata: { "course name": courseName }, }); return res.status(201).json({ success: true, @@ -1217,6 +1255,16 @@ router.post("/unenroll-course", async (req, res) => { try { await client.query("BEGIN"); + const courseRes = await client.query( + `SELECT id, name FROM courses WHERE id = $1`, + [courseId] + ); + if (!courseRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Course not found" }); + } + const courseName = courseRes.rows[0].name; + const delRes = await client.query( `DELETE FROM enrollments WHERE user_id = $1 @@ -1257,6 +1305,7 @@ router.post("/unenroll-course", async (req, res) => { organisationId, action: "unenroll_course", metadata: { courseId }, + displayMetadata: { "course name": courseName }, }); return res.status(200).json({ success: true }); } catch (err) { @@ -1302,6 +1351,16 @@ router.post("/complete-course", async (req, res) => { [courseId, userId] ); + const courseRes = await client.query( + `SELECT id, name FROM courses WHERE id = $1`, + [courseId] + ); + if (!courseRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Course not found" }); + } + const courseName = courseRes.rows[0].name; + const totalModules = modRes.rows[0].total_modules; const completedModules = modRes.rows[0].completed_modules; if (totalModules !== completedModules) { @@ -1326,6 +1385,7 @@ router.post("/complete-course", async (req, res) => { organisationId, action: "complete_course", metadata: { courseId }, + displayMetadata: { "course name": courseName }, }); return res.status(200).json({ success: true }); } catch (err) { @@ -1359,6 +1419,16 @@ router.post("/uncomplete-course", async (req, res) => { try { await client.query("BEGIN"); + const courseRes = await client.query( + `SELECT id, name FROM courses WHERE id = $1`, + [courseId] + ); + if (!courseRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Course not found" }); + } + const courseName = courseRes.rows[0].name; + await client.query( `UPDATE enrollments SET status = 'enrolled', @@ -1374,6 +1444,7 @@ router.post("/uncomplete-course", async (req, res) => { organisationId, action: "uncomplete_course", metadata: { courseId }, + displayMetadata: { "course name": courseName }, }); return res.status(200).json({ success: true }); } catch (err) { @@ -1809,6 +1880,16 @@ router.post("/mark-module-started", async (req, res) => { const enrollmentId = enrolmentRes.rows[0]?.id; + const moduleRes = await client.query( + `SELECT id, title FROM modules WHERE id = $1 AND course_id = $2`, + [moduleId, courseId] + ); + if (!moduleRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Module not found" }); + } + const moduleTitle = moduleRes.rows[0].title; + const statusRes = await client.query( `SELECT status FROM module_status WHERE enrollment_id = $1 AND module_id = $2`, @@ -1829,6 +1910,7 @@ router.post("/mark-module-started", async (req, res) => { organisationId, action: "start_module", metadata: { moduleId }, + displayMetadata: { "module title": moduleTitle }, }); return res.status(200).json({ status: "in_progress" }); } catch (err) { @@ -1877,6 +1959,16 @@ router.post("/mark-module-completed", async (req, res) => { const enrollmentId = enrolmentRes.rows[0]?.id; + const moduleRes = await client.query( + `SELECT id, title FROM modules WHERE id = $1 AND course_id = $2`, + [moduleId, courseId] + ); + if (!moduleRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Module not found" }); + } + const moduleTitle = moduleRes.rows[0].title; + const statusRes = await client.query( `SELECT status FROM module_status WHERE enrollment_id = $1 AND module_id = $2`, @@ -1904,6 +1996,7 @@ router.post("/mark-module-completed", async (req, res) => { organisationId, action: "complete_module", metadata: { moduleId }, + displayMetadata: { "module title": moduleTitle }, }); return res.status(200).json({ status: "in_progress" }); } catch (err) { @@ -1986,6 +2079,7 @@ router.post("/add-channel", async (req, res) => { organisationId, action: "add_channel", metadata: { channelId: channel.id, name: channel.name }, + displayMetadata: { "channel name": name }, }); return res.status(201).json({ success: true }); } catch (err) { @@ -2027,6 +2121,15 @@ router.delete("/delete-channel", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); + const channelRes = await client.query( + `SELECT id, name FROM channels WHERE id = $1 AND organisation_id = $2`, + [channelId, organisationId] + ); + if (!channelRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Channel not found" }); + } + const channelName = channelRes.rows[0].name; await client.query( `DELETE FROM channels WHERE id = $1 AND organisation_id = $2`, [channelId, organisationId] @@ -2037,6 +2140,7 @@ router.delete("/delete-channel", async (req, res) => { organisationId, action: "delete_channel", metadata: { channelId }, + displayMetadata: { "channel name": channelName }, }); return res.status(200).json({ success: true }); } catch (err) { @@ -2123,6 +2227,7 @@ router.post("/add-level", async (req, res) => { name: level.name, sortOrder: level.sort_order, }, + displayMetadata: { "level name": name }, }); return res.status(201).json({ success: true }); } catch (err) { @@ -2164,6 +2269,15 @@ router.delete("/delete-level", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); + const levelRes = await client.query( + `SELECT id, name FROM levels WHERE id = $1 AND organisation_id = $2`, + [levelId, organisationId] + ); + if (!levelRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Level not found" }); + } + const levelName = levelRes.rows[0].name; await client.query( `DELETE FROM levels WHERE id = $1 AND organisation_id = $2`, [levelId, organisationId] @@ -2174,6 +2288,7 @@ router.delete("/delete-level", async (req, res) => { organisationId, action: "delete_level", metadata: { levelId }, + displayMetadata: { "level name": levelName }, }); return res.status(200).json({ success: true }); } catch (err) { @@ -2256,6 +2371,7 @@ router.post("/add-skill", async (req, res) => { organisationId, action: "add_skill", metadata: { skillId: skill.id, name: skill.name }, + displayMetadata: { "skill name": name }, }); return res.status(201).json({ success: true }); } catch (err) { @@ -2297,6 +2413,15 @@ router.delete("/delete-skill", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); + const skillRes = await client.query( + `SELECT id, name FROM skills WHERE id = $1 AND organisation_id = $2`, + [skillId, organisationId] + ); + if (!skillRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Skill not found" }); + } + const skillName = skillRes.rows[0].name; await client.query( `DELETE FROM skills WHERE id = $1 AND organisation_id = $2`, [skillId, organisationId] @@ -2307,6 +2432,7 @@ router.delete("/delete-skill", async (req, res) => { organisationId, action: "delete_skill", metadata: { skillId }, + displayMetadata: { "skill name": skillName }, }); return res.status(200).json({ success: true }); } catch (err) { From 24aec24de29acd57809a9fd7c6185e037ce53921 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:50:45 +0800 Subject: [PATCH 23/32] Update display meta data for courses --- routes/activityLogger.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/activityLogger.js b/routes/activityLogger.js index 0390521..92fe5c0 100644 --- a/routes/activityLogger.js +++ b/routes/activityLogger.js @@ -9,7 +9,7 @@ async function logActivity({ }) { const sql = ` INSERT INTO activity_logs - (user_id, organisation_id, action, metadata, displayMetadata) + (user_id, organisation_id, action, metadata, display_metadata) VALUES ($1, $2, $3, $4, $5) `; await pool.query(sql, [ From 2084a5e85d7689d0e0c48715812c504a7a1458a6 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 13:55:37 +0800 Subject: [PATCH 24/32] Update display meta data for orgs --- routes/orgs.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/routes/orgs.js b/routes/orgs.js index fa7243d..8474828 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -55,6 +55,7 @@ router.post("/", async (req, res) => { organisationId: org.id, action: "create_organisation", metadata: { organisationId: org.id }, + displayMetadata: { "organisation name": organisationName }, }); return res.status(201).json({ organisation: { ...org, role: "admin" } }); } catch (err) { @@ -108,6 +109,13 @@ router.post("/addemployee", async (req, res) => { const org = orgRes.rows[0]; const organisationId = org.id; + const adminUserIdRes = await client.query( + `SELECT admin_user_id FROM organisations WHERE id = $1`, + [organisationId] + ); + + const adminUserId = adminUserIdRes.rows[0].admin_user_id; + await client.query( `INSERT INTO organisation_users (user_id, organisation_id, role) VALUES ($1, $2, 'employee')`, @@ -125,10 +133,11 @@ router.post("/addemployee", async (req, res) => { }, }); await logActivity({ - userId, + userId: adminUserId, organisationId, action: "add_employee", metadata: { organisationId }, + displayMetadata: { "organisation name": org.organisation_name }, }); return res.status(201).json({ organisation: { ...org, role: "employee" } }); From 596f48bda8c17c74d2fe2384c77abe6a5a2098c5 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 14:00:25 +0800 Subject: [PATCH 25/32] Update display meta data for roadmap --- routes/roadmaps.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/routes/roadmaps.js b/routes/roadmaps.js index 180727d..adaff39 100644 --- a/routes/roadmaps.js +++ b/routes/roadmaps.js @@ -64,6 +64,7 @@ router.post("/", async (req, res) => { organisationId: user.organisation?.id, action: "create_roadmap", metadata: { roadmapId: roadmap.id }, + displayMetadata: { "roadmap name": name.trim() }, }); res.status(201).json({ roadmap: result.rows[0] }); @@ -104,6 +105,7 @@ router.put("/:id", async (req, res) => { organisationId: user.organisation?.id, action: "edit_roadmap", metadata: { roadmapId: updated.id, newName: updated.name }, + displayMetadata: { "roadmap name": updated.name }, }); res.json({ roadmap: result.rows[0] }); @@ -122,6 +124,16 @@ router.delete("/:id", async (req, res) => { const { id } = req.params; try { + const roadMapRes = await pool.query( + `SELECT id, name FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + + if (roadMapRes.rows.length === 0) { + return res.status(404).json({ message: "Roadmap not found" }); + } + const roadmapName = roadMapRes.rows[0].name; + const result = await pool.query( `DELETE FROM roadmaps WHERE id = $1 AND user_id = $2 @@ -138,6 +150,7 @@ router.delete("/:id", async (req, res) => { organisationId: user.organisation?.id, action: "delete_roadmap", metadata: { roadmapId: id }, + displayMetadata: { "roadmap name": roadmapName }, }); res.json({ message: "Roadmap deleted successfully" }); @@ -269,6 +282,20 @@ router.post("/:id/items", async (req, res) => { [id, module_id, nextPosition] ); + const moduleNameRes = await pool.query( + `SELECT mod.title as module_title + FROM modules mod + WHERE mod.id = $1 + `, + [module_id] + ); + + if (moduleNameRes.rows.length === 0) { + return res.status(404).json({ message: "Module item not found" }); + } + + const moduleName = moduleNameRes.rows[0].module_title; + await client.query("COMMIT"); await logActivity({ @@ -276,6 +303,7 @@ router.post("/:id/items", async (req, res) => { organisationId: user.organisation?.id, action: "add_roadmap_item", metadata: { roadmapId: id, moduleId: module_id, position: nextPosition }, + displayMetadata: { "module name": moduleName }, }); res.status(201).json({ @@ -319,6 +347,20 @@ router.put("/:id/items/:moduleId", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } + const moduleNameRes = await pool.query( + `SELECT mod.title as module_title + FROM modules mod + WHERE mod.id = $1 + `, + [moduleId] + ); + + if (moduleNameRes.rows.length === 0) { + return res.status(404).json({ message: "Module item not found" }); + } + + const moduleName = moduleNameRes.rows[0].module_title; + const result = await client.query( `UPDATE roadmap_items SET position = $1 @@ -339,6 +381,7 @@ router.put("/:id/items/:moduleId", async (req, res) => { organisationId: user.organisation?.id, action: "move_roadmap_item", metadata: { roadmapId: id, moduleId, newPosition: position }, + displayMetadata: { "module name": moduleName }, }); res.json({ @@ -363,6 +406,19 @@ router.delete("/:id/items/:moduleId", async (req, res) => { const { id, moduleId } = req.params; try { + const moduleNameRes = await pool.query( + `SELECT mod.title as module_title + FROM modules mod + WHERE mod.id = $1 + `, + [moduleId] + ); + + if (moduleNameRes.rows.length === 0) { + return res.status(404).json({ message: "Module item not found" }); + } + + const moduleName = moduleNameRes.rows[0].module_title; const result = await pool.query( `DELETE FROM roadmap_items WHERE roadmap_id = $1 AND module_id = $2 @@ -383,6 +439,7 @@ router.delete("/:id/items/:moduleId", async (req, res) => { organisationId: user.organisation?.id, action: "remove_roadmap_item", metadata: { roadmapId: id, moduleId }, + displayMetadata: { "module name": moduleName }, }); res.json({ message: "Module removed from roadmap" }); @@ -542,6 +599,7 @@ router.post("/generate", async (req, res) => { organisationId: user.organisation?.id, action: "generate_roadmap", metadata: { roadmapId: roadmap.id, modulesAdded, enrolledCourses }, + displayMetadata: { "roadmap name": name.trim() }, }); res.status(201).json({ From b61703bf21ffcfd19a87a01794f9686546637e81 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 14:05:18 +0800 Subject: [PATCH 26/32] Update display meta data for users --- routes/users.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/routes/users.js b/routes/users.js index 74293e3..786fa05 100644 --- a/routes/users.js +++ b/routes/users.js @@ -74,6 +74,12 @@ router.delete("/", async (req, res) => { const client = await pool.connect(); try { + const adminUserIdRes = await client.query( + `SELECT admin_user_id FROM organisations + WHERE id = $1`, + [session.organisation?.id] + ); + const adminUserId = adminUserIdRes.rows[0]?.admin_user_id; const delRes = await client.query( `DELETE FROM users WHERE id = $1 @@ -85,7 +91,7 @@ router.delete("/", async (req, res) => { } await client.query("COMMIT"); await logActivity({ - userId: session.userId, + userId: adminUserId, organisationId: session.organisation?.id, action: "delete_user", metadata: { deletedUserId }, @@ -158,6 +164,7 @@ router.put("/profile", async (req, res) => { organisationId: session.organisation?.id, action: "update_profile", metadata: { firstname, lastname, email }, + displayMetadata: { firstname, lastname, email }, }); return res.json({ message: "Profile updated successfully", @@ -324,6 +331,12 @@ router.post("/skills", async (req, res) => { return res.status(400).json({ message: "Skill already added" }); } + const skillNameResult = await client.query( + "SELECT name FROM skills WHERE id = $1", + [skill_id] + ); + const skillName = skillNameResult.rows[0]?.name; + await client.query( "INSERT INTO user_skills (user_id, skill_id, level) VALUES ($1, $2, $3)", [session.userId, skill_id, level] @@ -335,6 +348,7 @@ router.post("/skills", async (req, res) => { organisationId: session.organisation?.id, action: "add_user_skill", metadata: { skillId: skill_id, level }, + displayMetadata: { "skill name": skillName, level }, }); return res.json({ message: "Skill added successfully" }); @@ -372,6 +386,12 @@ router.put("/skills", async (req, res) => { try { await client.query("BEGIN"); + const skillNameResult = await client.query( + "SELECT name FROM skills WHERE id = $1", + [skill_id] + ); + const skillName = skillNameResult.rows[0]?.name; + const updateResult = await client.query( "UPDATE user_skills SET level = $1, updated_at = NOW() WHERE user_id = $2 AND skill_id = $3", [level, session.userId, skill_id] @@ -388,6 +408,7 @@ router.put("/skills", async (req, res) => { organisationId: session.organisation?.id, action: "update_user_skill", metadata: { skillId: skill_id, newLevel: level }, + displayMetadata: { "skill name": skillName, newLevel: level }, }); return res.json({ message: "Skill level updated successfully" }); @@ -420,6 +441,12 @@ router.delete("/skills", async (req, res) => { try { await client.query("BEGIN"); + const skillNameResult = await client.query( + "SELECT name FROM skills WHERE id = $1", + [skill_id] + ); + const skillName = skillNameResult.rows[0]?.name; + const deleteResult = await client.query( "DELETE FROM user_skills WHERE user_id = $1 AND skill_id = $2", [session.userId, skill_id] @@ -436,6 +463,7 @@ router.delete("/skills", async (req, res) => { organisationId: session.organisation?.id, action: "remove_user_skill", metadata: { skillId: skill_id }, + displayMetadata: { "skill name": skillName }, }); return res.json({ message: "Skill removed successfully" }); } catch (error) { @@ -568,6 +596,12 @@ router.post("/preferences/channels", async (req, res) => { const nextRank = rankResult.rows[0].next_rank; + const channelNameResult = await client.query( + "SELECT name FROM channels WHERE id = $1", + [channel_id] + ); + const channelName = channelNameResult.rows[0]?.name; + await client.query( "INSERT INTO user_channels (user_id, channel_id, preference_rank) VALUES ($1, $2, $3)", [userId, channel_id, nextRank] @@ -579,6 +613,7 @@ router.post("/preferences/channels", async (req, res) => { organisationId, action: "add_channel_preference", metadata: { channelId: channel_id, rank: nextRank }, + displayMetadata: { "channel name": channelName }, }); return res.json({ message: "Channel preference added successfully" }); } catch (error) { @@ -642,6 +677,12 @@ router.post("/preferences/levels", async (req, res) => { const nextRank = rankResult.rows[0].next_rank; + const levelNameResult = await client.query( + "SELECT name FROM levels WHERE id = $1", + [level_id] + ); + const levelName = levelNameResult.rows[0]?.name; + await client.query( "INSERT INTO user_levels (user_id, level_id, preference_rank) VALUES ($1, $2, $3)", [userId, level_id, nextRank] @@ -653,6 +694,7 @@ router.post("/preferences/levels", async (req, res) => { organisationId, action: "add_level_preference", metadata: { levelId: level_id, rank: nextRank }, + displayMetadata: { "level name": levelName }, }); return res.json({ message: "Level preference added successfully" }); } catch (error) { @@ -687,6 +729,12 @@ router.delete("/preferences/channels", async (req, res) => { try { await client.query("BEGIN"); + const channelNameResult = await client.query( + "SELECT name FROM channels WHERE id = $1", + [channel_id] + ); + const channelName = channelNameResult.rows[0]?.name; + const result = await client.query( "DELETE FROM user_channels WHERE user_id = $1 AND channel_id = $2", [userId, channel_id] @@ -703,6 +751,7 @@ router.delete("/preferences/channels", async (req, res) => { organisationId, action: "remove_channel_preference", metadata: { channelId: channel_id }, + displayMetadata: { "channel name": channelName }, }); return res.json({ message: "Channel preference removed successfully" }); } catch (error) { @@ -737,6 +786,12 @@ router.delete("/preferences/levels", async (req, res) => { try { await client.query("BEGIN"); + const levelNameResult = await client.query( + "SELECT name FROM levels WHERE id = $1", + [level_id] + ); + const levelName = levelNameResult.rows[0]?.name; + const result = await client.query( "DELETE FROM user_levels WHERE user_id = $1 AND level_id = $2", [userId, level_id] @@ -753,6 +808,7 @@ router.delete("/preferences/levels", async (req, res) => { organisationId, action: "remove_level_preference", metadata: { levelId: level_id }, + displayMetadata: { "level name": levelName }, }); return res.json({ message: "Level preference removed successfully" }); From 29663d64cca20aa85df3177839bf2c18d550b811 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 14:28:04 +0800 Subject: [PATCH 27/32] Fix history display bugs --- routes/courses.js | 21 +++++++++++++++------ routes/onboarding.js | 1 + routes/orgs.js | 12 +++++++++++- routes/roadmaps.js | 39 +++++++++++++++++++++++++++++++++------ routes/users.js | 10 +++++++++- 5 files changed, 69 insertions(+), 14 deletions(-) diff --git a/routes/courses.js b/routes/courses.js index 9e47cbb..419c898 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -2051,6 +2051,8 @@ router.post("/add-channel", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2078,7 +2080,7 @@ router.post("/add-channel", async (req, res) => { userId, organisationId, action: "add_channel", - metadata: { channelId: channel.id, name: channel.name }, + metadata: { name: name }, displayMetadata: { "channel name": name }, }); return res.status(201).json({ success: true }); @@ -2105,6 +2107,8 @@ router.delete("/delete-channel", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2195,6 +2199,8 @@ router.post("/add-level", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2223,9 +2229,8 @@ router.post("/add-level", async (req, res) => { organisationId, action: "add_level", metadata: { - levelId: level.id, - name: level.name, - sortOrder: level.sort_order, + name: name, + sortOrder: sort_order, }, displayMetadata: { "level name": name }, }); @@ -2253,6 +2258,8 @@ router.delete("/delete-level", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2343,6 +2350,8 @@ router.post("/add-skill", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2370,7 +2379,7 @@ router.post("/add-skill", async (req, res) => { userId, organisationId, action: "add_skill", - metadata: { skillId: skill.id, name: skill.name }, + metadata: { name: name }, displayMetadata: { "skill name": name }, }); return res.status(201).json({ success: true }); @@ -2396,7 +2405,7 @@ router.delete("/delete-skill", async (req, res) => { } catch { return res.status(400).json({ message: "Invalid session data" }); } - + const userId = session.userId; const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { diff --git a/routes/onboarding.js b/routes/onboarding.js index 9c0728a..7ac7b7f 100644 --- a/routes/onboarding.js +++ b/routes/onboarding.js @@ -1,6 +1,7 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); +const logActivity = require("./activityLogger"); function getAuthUser(req) { const { auth } = req.cookies; diff --git a/routes/orgs.js b/routes/orgs.js index 8474828..7b53590 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -116,6 +116,12 @@ router.post("/addemployee", async (req, res) => { const adminUserId = adminUserIdRes.rows[0].admin_user_id; + const employeeNameRes = await client.query( + `SELECT firstname, lastname, email FROM users WHERE id = $1`, + [userId] + ); + const employeeName = employeeNameRes.rows[0]; + await client.query( `INSERT INTO organisation_users (user_id, organisation_id, role) VALUES ($1, $2, 'employee')`, @@ -137,7 +143,11 @@ router.post("/addemployee", async (req, res) => { organisationId, action: "add_employee", metadata: { organisationId }, - displayMetadata: { "organisation name": org.organisation_name }, + displayMetadata: { + "organisation name": org.organisation_name, + "employee name": `${employeeName.firstname} ${employeeName.lastname}`, + "employee email": employeeName.email, + }, }); return res.status(201).json({ organisation: { ...org, role: "employee" } }); diff --git a/routes/roadmaps.js b/routes/roadmaps.js index adaff39..584ecde 100644 --- a/routes/roadmaps.js +++ b/routes/roadmaps.js @@ -63,7 +63,7 @@ router.post("/", async (req, res) => { userId: user.userId, organisationId: user.organisation?.id, action: "create_roadmap", - metadata: { roadmapId: roadmap.id }, + metadata: { roadmapId: result.rows[0].id }, displayMetadata: { "roadmap name": name.trim() }, }); @@ -104,8 +104,8 @@ router.put("/:id", async (req, res) => { userId: user.userId, organisationId: user.organisation?.id, action: "edit_roadmap", - metadata: { roadmapId: updated.id, newName: updated.name }, - displayMetadata: { "roadmap name": updated.name }, + metadata: { roadmapId: result.id, newName: name.trim() }, + displayMetadata: { "roadmap name": name.trim() }, }); res.json({ roadmap: result.rows[0] }); @@ -269,6 +269,12 @@ router.post("/:id/items", async (req, res) => { const nextPosition = positionResult.rows[0].next_position; + const roadmapNameRes = await pool.query( + `SELECT name FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + const roadmapName = roadmapNameRes.rows[0]?.name; + const courseIds = await getCoursesFromModules(client, [module_id]); const enrolledCourses = await ensureUserEnrolledInCourses( client, @@ -303,7 +309,10 @@ router.post("/:id/items", async (req, res) => { organisationId: user.organisation?.id, action: "add_roadmap_item", metadata: { roadmapId: id, moduleId: module_id, position: nextPosition }, - displayMetadata: { "module name": moduleName }, + displayMetadata: { + "roadmap name": roadmapName, + "module name": moduleName, + }, }); res.status(201).json({ @@ -347,6 +356,12 @@ router.put("/:id/items/:moduleId", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } + const roadmapNameRes = await pool.query( + `SELECT name FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + const roadmapName = roadmapNameRes.rows[0]?.name; + const moduleNameRes = await pool.query( `SELECT mod.title as module_title FROM modules mod @@ -381,7 +396,10 @@ router.put("/:id/items/:moduleId", async (req, res) => { organisationId: user.organisation?.id, action: "move_roadmap_item", metadata: { roadmapId: id, moduleId, newPosition: position }, - displayMetadata: { "module name": moduleName }, + displayMetadata: { + "roadmap name": roadmapName, + "module name": moduleName, + }, }); res.json({ @@ -419,6 +437,12 @@ router.delete("/:id/items/:moduleId", async (req, res) => { } const moduleName = moduleNameRes.rows[0].module_title; + + const roadmapNameRes = await pool.query( + `SELECT name FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + const roadmapName = roadmapNameRes.rows[0]?.name; const result = await pool.query( `DELETE FROM roadmap_items WHERE roadmap_id = $1 AND module_id = $2 @@ -439,7 +463,10 @@ router.delete("/:id/items/:moduleId", async (req, res) => { organisationId: user.organisation?.id, action: "remove_roadmap_item", metadata: { roadmapId: id, moduleId }, - displayMetadata: { "module name": moduleName }, + displayMetadata: { + "roadmap name": roadmapName, + "module name": moduleName, + }, }); res.json({ message: "Module removed from roadmap" }); diff --git a/routes/users.js b/routes/users.js index 786fa05..3243118 100644 --- a/routes/users.js +++ b/routes/users.js @@ -80,6 +80,11 @@ router.delete("/", async (req, res) => { [session.organisation?.id] ); const adminUserId = adminUserIdRes.rows[0]?.admin_user_id; + const deleteUserNameRes = await client.query( + `SELECT firstname, lastname FROM users WHERE id = $1`, + [deleteUserId] + ); + const deleteUserName = deleteUserNameRes.rows[0]; const delRes = await client.query( `DELETE FROM users WHERE id = $1 @@ -94,7 +99,10 @@ router.delete("/", async (req, res) => { userId: adminUserId, organisationId: session.organisation?.id, action: "delete_user", - metadata: { deletedUserId }, + metadata: { deleteUserId }, + displayMetadata: { + "deleted user name": `${deleteUserName.firstname} ${deleteUserName.lastname}`, + }, }); return res.status(201).json({ message: "User deleted successfully", From 126951f4e1711d3add21fe847786ccab5aa3b7da Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 17:21:56 +0800 Subject: [PATCH 28/32] Add working admin dashboard endpoint --- routes/dashboard.js | 182 ++++++++++++++++++++++++++++++++++++++++++++ server.js | 2 + 2 files changed, 184 insertions(+) create mode 100644 routes/dashboard.js diff --git a/routes/dashboard.js b/routes/dashboard.js new file mode 100644 index 0000000..4353962 --- /dev/null +++ b/routes/dashboard.js @@ -0,0 +1,182 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); + +function getAuthUser(req) { + const { auth } = req.cookies; + if (!auth) return null; + try { + return JSON.parse(auth); + } catch { + return null; + } +} + +router.get("/user-dashboard", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn) + return res.status(401).json({ message: "Not logged in" }); + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const userId = user.userId; + + const { rows: currCourse } = await client.query( + `SELECT c.id, c.name + FROM enrollments e + JOIN courses c ON c.id = e.course_id + WHERE e.user_id = $1 + AND e.status = 'enrolled' + ORDER BY e.started_at DESC + LIMIT 1`, + [userId] + ); + + let currModule = null; + if (currCourse.length) { + const courseId = currCourse[0].id; + const { rows } = await client.query( + `SELECT m.id, m.title + FROM module_status ms + JOIN modules m ON m.id = ms.module_id + WHERE ms.enrollment_id = ( + SELECT id FROM enrollments + WHERE user_id = $1 + AND course_id = $2 + ) + AND ms.status = 'in_progress' + ORDER BY ms.started_at DESC + LIMIT 1`, + [userId, courseId] + ); + currModule = rows[0] || null; + } + + const { rows: rm } = await client.query( + `SELECT id FROM roadmaps + WHERE user_id = $1 + ORDER BY id DESC + LIMIT 1`, + [userId] + ); + let roadmapProgress = { completed: 0, total: 0 }; + if (rm.length) { + const roadmapId = rm[0].id; + const { rows } = await client.query( + `SELECT + COUNT(*) FILTER(WHERE ri.module_id IS NOT NULL) AS total, + COUNT(*) FILTER(WHERE ms.status = 'completed') AS completed + FROM roadmap_items ri + LEFT JOIN module_status ms + ON ms.module_id = ri.module_id + AND ms.enrollment_id = ( + SELECT id FROM enrollments + WHERE user_id = $1 + AND course_id = ( + SELECT course_id FROM modules WHERE id = ri.module_id + ) + ) + WHERE ri.roadmap_id = $2`, + [userId, roadmapId] + ); + roadmapProgress = rows[0]; + } + + let courseProgress = { completed: 0, total: 0 }; + if (currCourse.length) { + const courseId = currCourse[0].id; + const { rows } = await client.query( + `SELECT + COUNT(m.id) AS total, + COUNT(ms.id) FILTER(ms.status = 'completed') AS completed + FROM modules m + LEFT JOIN module_status ms + ON ms.module_id = m.id + AND ms.enrollment_id = ( + SELECT id FROM enrollments + WHERE user_id = $1 + AND course_id = $2 + ) + WHERE m.course_id = $2`, + [userId, currCourse[0].id] + ); + courseProgress = rows[0]; + } + + await client.query("COMMIT"); + res.json({ + welcome: `Welcome, ${user.firstname}!`, + currentCourse: currCourse[0] || null, + currentModule: currModule, + roadmapProgress, + courseProgress, + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.get("/admin-dashboard", async (req, res) => { + const user = getAuthUser(req); + if (!user || !user.isLoggedIn || user.organisation.role !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const orgId = user.organisation.id; + + const { rows: employees } = await client.query( + ` + SELECT + u.id, + u.firstname, + u.lastname, + COUNT(e.id) FILTER (WHERE e.status IN ('enrolled','completed')) AS "totalCourses", + COUNT(e.id) FILTER (WHERE e.status = 'completed') AS "completedCourses" + FROM users u + JOIN organisation_users ou + ON ou.user_id = u.id + LEFT JOIN enrollments e + ON e.user_id = u.id + WHERE ou.organisation_id = $1 + AND ou.role = 'employee' + GROUP BY u.id, u.firstname, u.lastname + ORDER BY u.lastname, u.firstname + `, + [orgId] + ); + + const { rows: enrollments } = await client.query( + `SELECT c.name AS courseName, + COUNT(e.id) AS enrolledCount + FROM courses c + LEFT JOIN enrollments e ON e.course_id = c.id + WHERE c.organisation_id = $1 + GROUP BY c.id, c.name + ORDER BY enrolledCount DESC`, + [orgId] + ); + + await client.query("COMMIT"); + res.json({ + welcome: `Welcome, Admin ${user.firstname}!`, + employees, + enrollments, + }); + } catch (err) { + await client.query("ROLLBACK"); + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +module.exports = router; diff --git a/server.js b/server.js index f01d7af..8dd8b42 100644 --- a/server.js +++ b/server.js @@ -9,6 +9,7 @@ const onboardingRoutes = require("./routes/onboarding"); const roadmapRoutes = require("./routes/roadmaps"); const materialRoutes = require("./routes/materials"); const activityRoutes = require("./routes/activity"); +const dashboardRoutes = require("./routes/dashboard"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; @@ -52,6 +53,7 @@ app.use("/api/onboarding", onboardingRoutes); app.use("/api/roadmaps", roadmapRoutes); app.use("/api/materials", materialRoutes); app.use("/api/activity", activityRoutes); +app.use("/api/dashboard", dashboardRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); From 7bb5b230c5f10b41227a8e568fe163c5f74f9e91 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 17:47:10 +0800 Subject: [PATCH 29/32] ass working user dashboard endpoint --- routes/dashboard.js | 134 +++++++++++++++++++++++--------------------- 1 file changed, 70 insertions(+), 64 deletions(-) diff --git a/routes/dashboard.js b/routes/dashboard.js index 4353962..eaf9225 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -14,103 +14,109 @@ function getAuthUser(req) { router.get("/user-dashboard", async (req, res) => { const user = getAuthUser(req); - if (!user || !user.isLoggedIn) + if (!user || !user.isLoggedIn) { return res.status(401).json({ message: "Not logged in" }); + } const client = await pool.connect(); try { await client.query("BEGIN"); const userId = user.userId; - const { rows: currCourse } = await client.query( + const { rows: currCourseArr } = await client.query( `SELECT c.id, c.name FROM enrollments e - JOIN courses c ON c.id = e.course_id + JOIN courses c ON c.id = e.course_id WHERE e.user_id = $1 - AND e.status = 'enrolled' - ORDER BY e.started_at DESC + AND e.status IN ('enrolled', 'in_progress') + ORDER BY e.started_at DESC NULLS LAST LIMIT 1`, [userId] ); + const currentCourse = currCourseArr[0] || null; - let currModule = null; - if (currCourse.length) { - const courseId = currCourse[0].id; - const { rows } = await client.query( + let currentModule = null; + if (currentCourse) { + const { rows: moduleArr } = await client.query( `SELECT m.id, m.title - FROM module_status ms - JOIN modules m ON m.id = ms.module_id + FROM modules m + JOIN module_status ms ON ms.module_id = m.id WHERE ms.enrollment_id = ( SELECT id FROM enrollments - WHERE user_id = $1 - AND course_id = $2 - ) + WHERE user_id = $1 AND course_id = $2 + LIMIT 1 + ) AND ms.status = 'in_progress' - ORDER BY ms.started_at DESC + ORDER BY ms.started_at DESC NULLS LAST LIMIT 1`, - [userId, courseId] + [userId, currentCourse.id] ); - currModule = rows[0] || null; + currentModule = moduleArr[0] || null; } - const { rows: rm } = await client.query( - `SELECT id FROM roadmaps - WHERE user_id = $1 - ORDER BY id DESC - LIMIT 1`, - [userId] - ); - let roadmapProgress = { completed: 0, total: 0 }; - if (rm.length) { - const roadmapId = rm[0].id; - const { rows } = await client.query( - `SELECT - COUNT(*) FILTER(WHERE ri.module_id IS NOT NULL) AS total, - COUNT(*) FILTER(WHERE ms.status = 'completed') AS completed - FROM roadmap_items ri - LEFT JOIN module_status ms - ON ms.module_id = ri.module_id - AND ms.enrollment_id = ( - SELECT id FROM enrollments - WHERE user_id = $1 - AND course_id = ( - SELECT course_id FROM modules WHERE id = ri.module_id - ) - ) - WHERE ri.roadmap_id = $2`, - [userId, roadmapId] + let nextToLearn = []; + if (currentCourse) { + const { rows: learnArr } = await client.query( + `SELECT m.id, m.title + FROM modules m + LEFT JOIN module_status ms + ON ms.module_id = m.id + AND ms.enrollment_id = ( + SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2 LIMIT 1 + ) + WHERE m.course_id = $2 + AND (ms.status IS NULL OR ms.status = 'not_started') + ORDER BY m.position ASC + LIMIT 2`, + [userId, currentCourse.id] + ); + nextToLearn = learnArr; + } + + let toRevise = []; + if (currentCourse) { + const { rows: reviseArr } = await client.query( + `SELECT m.id, m.title + FROM modules m + JOIN module_status ms + ON ms.module_id = m.id + AND ms.enrollment_id = ( + SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2 LIMIT 1 + ) + WHERE ms.status = 'completed' + ORDER BY ms.completed_at DESC NULLS LAST + LIMIT 1`, + [userId, currentCourse.id] ); - roadmapProgress = rows[0]; + toRevise = reviseArr; } - let courseProgress = { completed: 0, total: 0 }; - if (currCourse.length) { - const courseId = currCourse[0].id; + let summaryStats = { completedModules: 0, totalModules: 0 }; + if (currentCourse) { const { rows } = await client.query( `SELECT - COUNT(m.id) AS total, - COUNT(ms.id) FILTER(ms.status = 'completed') AS completed - FROM modules m - LEFT JOIN module_status ms - ON ms.module_id = m.id - AND ms.enrollment_id = ( - SELECT id FROM enrollments - WHERE user_id = $1 - AND course_id = $2 - ) - WHERE m.course_id = $2`, - [userId, currCourse[0].id] + COUNT(m.id) AS "totalModules", + COUNT(ms.id) FILTER (WHERE ms.status = 'completed') AS "completedModules" + FROM modules m + LEFT JOIN module_status ms + ON ms.module_id = m.id + AND ms.enrollment_id = ( + SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2 LIMIT 1 + ) + WHERE m.course_id = $2`, + [userId, currentCourse.id] ); - courseProgress = rows[0]; + summaryStats = rows[0]; } await client.query("COMMIT"); res.json({ welcome: `Welcome, ${user.firstname}!`, - currentCourse: currCourse[0] || null, - currentModule: currModule, - roadmapProgress, - courseProgress, + currentCourse, + currentModule, + nextToLearn, + toRevise, + summaryStats, }); } catch (err) { await client.query("ROLLBACK"); From ac3bfc869ef016ba7d198e1a3797c50efa46ee94 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 17:48:51 +0800 Subject: [PATCH 30/32] Update latest code logic --- routes/dashboard.js | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/routes/dashboard.js b/routes/dashboard.js index eaf9225..0dbd0fc 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -24,13 +24,23 @@ router.get("/user-dashboard", async (req, res) => { const userId = user.userId; const { rows: currCourseArr } = await client.query( - `SELECT c.id, c.name - FROM enrollments e - JOIN courses c ON c.id = e.course_id - WHERE e.user_id = $1 - AND e.status IN ('enrolled', 'in_progress') - ORDER BY e.started_at DESC NULLS LAST - LIMIT 1`, + ` + SELECT c.id, c.name + FROM enrollments e + JOIN courses c ON c.id = e.course_id + WHERE e.user_id = $1 + AND e.status IN ('enrolled', 'in_progress') + AND EXISTS ( + SELECT 1 FROM modules m + LEFT JOIN module_status ms + ON ms.module_id = m.id + AND ms.enrollment_id = e.id + WHERE m.course_id = c.id + AND (ms.status IS NULL OR ms.status != 'completed') + ) + ORDER BY e.started_at DESC NULLS LAST + LIMIT 1 + `, [userId] ); const currentCourse = currCourseArr[0] || null; From 4c5e18e65ae7c17fed68606902d45e92bd421fd1 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 17:56:24 +0800 Subject: [PATCH 31/32] Add global stats --- routes/dashboard.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/routes/dashboard.js b/routes/dashboard.js index 0dbd0fc..a4fc984 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -102,6 +102,7 @@ router.get("/user-dashboard", async (req, res) => { } let summaryStats = { completedModules: 0, totalModules: 0 }; + let globalStats = { completedModules: 0, totalModules: 0 }; if (currentCourse) { const { rows } = await client.query( `SELECT @@ -119,6 +120,19 @@ router.get("/user-dashboard", async (req, res) => { summaryStats = rows[0]; } + const { rows: globalRows } = await client.query( + `SELECT + COUNT(m.id) AS "totalModules", + COUNT(ms.id) FILTER (WHERE ms.status = 'completed') AS "completedModules" + FROM enrollments e +JOIN modules m ON m.course_id = e.course_id +LEFT JOIN module_status ms + ON ms.module_id = m.id AND ms.enrollment_id = e.id +WHERE e.user_id = $1`, + [userId] + ); + globalStats = globalRows[0]; + await client.query("COMMIT"); res.json({ welcome: `Welcome, ${user.firstname}!`, @@ -127,6 +141,7 @@ router.get("/user-dashboard", async (req, res) => { nextToLearn, toRevise, summaryStats, + globalStats, }); } catch (err) { await client.query("ROLLBACK"); From e057dc028b37e9dfa1e76be16402a15dbb212c62 Mon Sep 17 00:00:00 2001 From: lavanyagarg112 Date: Thu, 10 Jul 2025 18:16:11 +0800 Subject: [PATCH 32/32] Fix bug and clean code --- routes/courses.js | 2 +- routes/onboarding.js | 4 ---- routes/roadmaps.js | 16 ---------------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/routes/courses.js b/routes/courses.js index 419c898..ca33a7c 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -551,7 +551,7 @@ router.post("/add-module", upload.single("file"), async (req, res) => { userId: session.userId, organisationId: session.organisation.id, action: "add_module", - metadata: { moduleId }, + metadata: { module_id }, displayMetadata: { "module name": name }, }); return res.status(201).json({ diff --git a/routes/onboarding.js b/routes/onboarding.js index 7ac7b7f..0de8490 100644 --- a/routes/onboarding.js +++ b/routes/onboarding.js @@ -479,10 +479,6 @@ router.post("/responses", async (req, res) => { [roadmapId, moduleIds[i], i + 1] ); } - - console.log( - `Auto-generated roadmap "${roadmapResult.rows[0].name}" with ${moduleIds.length} modules for user ${user.userId}` - ); } } } catch (roadmapError) { diff --git a/routes/roadmaps.js b/routes/roadmaps.js index 584ecde..dc02ab1 100644 --- a/routes/roadmaps.js +++ b/routes/roadmaps.js @@ -578,24 +578,8 @@ router.post("/generate", async (req, res) => { ORDER BY matching_skills DESC, channel_match DESC, level_match DESC, random_score LIMIT 10`; // Limit to top 10 modules - console.log("Generated Query:", query); - console.log("Query Parameters:", params); - const modulesResult = await client.query(query, params); - console.log("Query Results:", modulesResult.rows.length, "modules found"); - console.log( - "Module Details:", - modulesResult.rows.map((row) => ({ - id: row.id, - matching_skills: row.matching_skills, - channel_match: row.channel_match, - level_match: row.level_match, - channel_id: row.channel_id, - level_id: row.level_id, - })) - ); - if (modulesResult.rows.length > 0) { const moduleIds = modulesResult.rows.map((row) => row.id);