diff --git a/database/schema.sql b/database/schema.sql index 943c800..cd3b4b8 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,50 +179,41 @@ CREATE TABLE quiz_answers ( ); --- 8. TAGS -CREATE TABLE tags ( - id SERIAL PRIMARY KEY, - name VARCHAR(50) NOT NULL UNIQUE -); - -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) +-- 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, + 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) ); @@ -263,10 +258,80 @@ 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) +); + +--- + + -- Table to store onboarding questions + CREATE TABLE onboarding_questions ( + id SERIAL PRIMARY KEY, + question_text TEXT NOT NULL, + 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 + 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, + 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 + 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) + ); + +-- 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) +); + +-- 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) ); -COMMIT; +-- 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) +); + +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(), + display_metadata JSONB NOT NULL DEFAULT '{}' +); + + + +COMMIT; \ No newline at end of file diff --git a/routes/activity.js b/routes/activity.js new file mode 100644 index 0000000..c3cebbd --- /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, + display_metadata as 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/routes/activityLogger.js b/routes/activityLogger.js new file mode 100644 index 0000000..92fe5c0 --- /dev/null +++ b/routes/activityLogger.js @@ -0,0 +1,24 @@ +const pool = require("../database/db"); + +async function logActivity({ + userId, + organisationId, + action, + metadata = {}, + displayMetadata = {}, +}) { + const sql = ` + INSERT INTO activity_logs + (user_id, organisation_id, action, metadata, display_metadata) + VALUES ($1, $2, $3, $4, $5) + `; + await pool.query(sql, [ + userId, + organisationId, + action, + metadata, + displayMetadata, + ]); +} + +module.exports = logActivity; diff --git a/routes/auth.js b/routes/auth.js index 3615166..e778159 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), { @@ -13,7 +14,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 +45,6 @@ router.post("/signup", async (req, res) => { } }); -// LOG IN → POST /api/login router.post("/login", async (req, res) => { const { email, password } = req.body; try { @@ -83,6 +82,13 @@ 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 }, + displayMetadata: { email }, + }); return res.json({ success: true }); } catch (err) { console.error(err); @@ -90,12 +96,16 @@ router.post("/login", async (req, res) => { } }); -// LOGOUT → POST /api/logout -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", + }); }); -// WHOAMI → GET /api/me router.get("/me", (req, res) => { const { auth } = req.cookies; if (!auth) return res.json({ isLoggedIn: false }); @@ -118,11 +128,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,13 +142,45 @@ router.post("/complete-onboarding", async (req, res) => { const organisation = mem.rows[0] || null; - // Regenerate auth cookie + if (organisation && organisation.role === "employee") { + const questionCheck = await pool.query( + `SELECT COUNT(*) as question_count FROM onboarding_questions WHERE organisation_id = $1`, + [organisation.id] + ); + + 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", + }); + } + } + } + + await pool.query( + `UPDATE users SET has_completed_onboarding = true WHERE id = $1`, + [user.userId] + ); + setAuthCookie(res, { ...user, hasCompletedOnboarding: true, organisation: organisation, }); + await logActivity({ + userId: user.userId, + organisationId: organisation ? organisation.id : null, + action: "complete_onboarding", + }); + res.json({ success: true }); } catch (err) { console.error(err); diff --git a/routes/courses.js b/routes/courses.js index 6ef818a..ca33a7c 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -1,9 +1,9 @@ -// routes/courses.js const express = require("express"); 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/", @@ -31,10 +31,16 @@ 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,22 +57,22 @@ 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"); + await logActivity({ + userId, + organisationId, + action: "create_course", + metadata: { courseId }, + displayMetadata: { "course name": courseName }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -102,20 +108,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 +171,41 @@ 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"); @@ -209,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`, @@ -216,6 +258,13 @@ router.delete("/", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation.id, + action: "delete_course", + metadata: { courseId }, + displayMetadata: { "course name": courseName }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -245,8 +294,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) @@ -270,30 +320,27 @@ router.put("/", async (req, res) => { throw new Error("Failed to update course"); } - // 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"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation.id, + action: "edit_course", + metadata: { courseId }, + displayMetadata: { "course name": courseName }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -338,15 +385,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 +426,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 +443,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 +473,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] ); } } @@ -458,7 +505,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, @@ -469,7 +515,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) @@ -478,7 +523,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]; @@ -503,6 +547,13 @@ 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: { module_id }, + displayMetadata: { "module name": name }, + }); return res.status(201).json({ module_id, }); @@ -539,11 +590,27 @@ 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, ]); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation.id, + action: "delete_module", + metadata: { moduleId }, + displayMetadata: { "module title": moduleTitle }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -585,18 +652,17 @@ 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] ); const module = moduleRes.rows[0]; if (module.module_type === "quiz") { - // 2a) get the latest revision const revRes = await client.query( `SELECT id FROM revisions @@ -661,9 +727,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 +764,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 +775,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 +806,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] ); } } @@ -913,6 +980,13 @@ 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 }, + displayMetadata: { "module name": name }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -944,7 +1018,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 @@ -955,14 +1028,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_tags ct2 - JOIN tags t2 ON t2.id = ct2.tag_id - WHERE ct2.course_id = c.id - ) AS tags + 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_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 +1060,6 @@ GROUP BY c.id, c.name, c.description; [userId] ); - // 2) “Completed” courses (status = 'completed') const completedRes = await client.query( ` SELECT @@ -986,14 +1070,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 @@ -1005,28 +1101,34 @@ 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, - 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] ); @@ -1058,6 +1160,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" }); @@ -1076,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 @@ -1094,6 +1207,13 @@ router.post("/enroll-course", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "enroll_course", + metadata: { courseId }, + displayMetadata: { "course name": courseName }, + }); return res.status(201).json({ success: true, enrollment: insertRes.rows[0], @@ -1125,6 +1245,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" }); @@ -1134,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 @@ -1169,6 +1300,13 @@ router.post("/unenroll-course", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "unenroll_course", + metadata: { courseId }, + displayMetadata: { "course name": courseName }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -1192,6 +1330,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" }); } @@ -1212,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) { @@ -1231,6 +1380,13 @@ router.post("/complete-course", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "complete_course", + metadata: { courseId }, + displayMetadata: { "course name": courseName }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -1253,6 +1409,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" }); @@ -1262,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', @@ -1272,6 +1439,13 @@ router.post("/uncomplete-course", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "uncomplete_course", + metadata: { courseId }, + displayMetadata: { "course name": courseName }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -1682,6 +1856,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" }); } @@ -1705,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`, @@ -1720,6 +1905,13 @@ router.post("/mark-module-started", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "start_module", + metadata: { moduleId }, + displayMetadata: { "module title": moduleTitle }, + }); return res.status(200).json({ status: "in_progress" }); } catch (err) { await client.query("ROLLBACK"); @@ -1743,6 +1935,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" }); } @@ -1766,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`, @@ -1788,6 +1991,13 @@ router.post("/mark-module-completed", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "complete_module", + metadata: { moduleId }, + displayMetadata: { "module title": moduleTitle }, + }); return res.status(200).json({ status: "in_progress" }); } catch (err) { await client.query("ROLLBACK"); @@ -1798,7 +2008,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" }); @@ -1810,45 +2052,283 @@ 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" }); } - const { tags } = req.body; - if (!Array.isArray(tags)) { - return res.status(400).json({ message: "tags[] are required" }); + 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"); - for (const tag of tags) { - if (!tag.name) { - continue; // skip if tag has no name - } - await client.query( - `INSERT INTO tags (name) - VALUES ($1) - ON CONFLICT (name) DO NOTHING`, - [tag.name] - ); + await client.query( + `INSERT INTO channels (name, description, organisation_id) + VALUES ($1, $2, $3)`, + [name, description || "", organisationId] + ); + await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "add_channel", + metadata: { name: name }, + displayMetadata: { "channel name": name }, + }); + 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 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 { channelId } = req.body; + if (!channelId) { + return res.status(400).json({ message: "channelId is required" }); + } + + 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] + ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "delete_channel", + metadata: { channelId }, + displayMetadata: { "channel name": channelName }, + }); + 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 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 { 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"); + await logActivity({ + userId, + organisationId, + action: "add_level", + metadata: { + name: name, + sortOrder: sort_order, + }, + displayMetadata: { "level name": name }, + }); 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 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 { levelId } = req.body; + if (!levelId) { + return res.status(400).json({ message: "levelId is required" }); + } + + 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] + ); + await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "delete_level", + metadata: { levelId }, + displayMetadata: { "level name": levelName }, + }); + 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" }); + + 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, description FROM skills WHERE organisation_id = $1 ORDER BY name`, + [organisationId] ); res.json(rows); } catch (err) { @@ -1859,7 +2339,7 @@ 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" }); @@ -1870,20 +2350,99 @@ router.delete("/delete-tag", 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) { + 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"); + await logActivity({ + userId, + organisationId, + action: "add_skill", + metadata: { name: name }, + displayMetadata: { "skill name": name }, + }); + 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" }); + + 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 isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { return res.status(403).json({ message: "Forbidden" }); } - const { tagId } = req.body; - if (!tagId) { - return res.status(400).json({ message: "tagId is required" }); + if (!organisationId) { + return res.status(400).json({ message: "Organization 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`, [tagId]); + 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] + ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "delete_skill", + metadata: { skillId }, + displayMetadata: { "skill name": skillName }, + }); return res.status(200).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); diff --git a/routes/dashboard.js b/routes/dashboard.js new file mode 100644 index 0000000..a4fc984 --- /dev/null +++ b/routes/dashboard.js @@ -0,0 +1,213 @@ +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: 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') + 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; + + let currentModule = null; + if (currentCourse) { + const { rows: moduleArr } = await client.query( + `SELECT m.id, m.title + 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 + LIMIT 1 + ) + AND ms.status = 'in_progress' + ORDER BY ms.started_at DESC NULLS LAST + LIMIT 1`, + [userId, currentCourse.id] + ); + currentModule = moduleArr[0] || null; + } + + 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] + ); + toRevise = reviseArr; + } + + let summaryStats = { completedModules: 0, totalModules: 0 }; + let globalStats = { completedModules: 0, totalModules: 0 }; + if (currentCourse) { + const { rows } = await client.query( + `SELECT + 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] + ); + 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}!`, + currentCourse, + currentModule, + nextToLearn, + toRevise, + summaryStats, + globalStats, + }); + } 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/routes/materials.js b/routes/materials.js new file mode 100644 index 0000000..8d717d2 --- /dev/null +++ b/routes/materials.js @@ -0,0 +1,293 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); +const { getUserPreferences } = require("./roadmaps-helpers"); + +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" }); + } + + const { skill_ids } = req.query; + + try { + let query = ` + SELECT + 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, + 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_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 (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, 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); + + res.json({ materials: result.rows }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +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 { + 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: [] }); + } + + const userPreferences = await getUserPreferences(client, user.userId); + + 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(); + } +}); + +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" }); + } + + const client = await pool.connect(); + try { + 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: [] }); + } + + const userPreferences = await getUserPreferences(client, user.userId); + + 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, + userTags: userSkillIds, + }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +module.exports = router; diff --git a/routes/onboarding.js b/routes/onboarding.js new file mode 100644 index 0000000..0de8490 --- /dev/null +++ b/routes/onboarding.js @@ -0,0 +1,574 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); +const logActivity = require("./activityLogger"); + +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" }); + } + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + 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) { + const optionsResult = await pool.query( + ` + 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 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 + `, + [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" }); + } + + 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, organisation_id) + VALUES ($1, $2, $3) + RETURNING id, question_text, position + `, + [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); + 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, skill_id, channel_id, level_id } = req.body; + + if (!option_text) { + return res.status(400).json({ message: "option_text is required" }); + } + + const client = await pool.connect(); + 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 AND organisation_id = $2", + [id, organisationId] + ); + if (questionCheck.rows.length === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Question not found" }); + } + + 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 (skillCheck.rows.length === 0) { + await client.query("ROLLBACK"); + 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, 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, skill_id || null, channel_id || null, level_id || null] + ); + + await client.query("COMMIT"); + + const option = { + ...result.rows[0], + 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, + }; + + 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"); + 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; + + const organisationId = user.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + try { + 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" }); + } + + 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); + res.status(500).json({ message: "Server error" }); + } +}); + +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] + ); + + 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); + 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" }); + } + 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] + ); + + try { + const { + getUserPreferences, + getCoursesFromModules, + ensureUserEnrolledInCourses, + } = require("./roadmaps-helpers"); + + const preferences = await getUserPreferences(client, user.userId); + + 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($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($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 + 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 = $6 + LEFT JOIN module_status mst ON mst.module_id = mod.id AND mst.enrollment_id = e.id + WHERE c.organisation_id = $1 + 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`; + + const modulesResult = await client.query(moduleQuery, moduleParams); + + if (modulesResult.rows.length > 0) { + 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 courseIds = await getCoursesFromModules(client, moduleIds); + + if (courseIds.length > 0) { + await ensureUserEnrolledInCourses(client, user.userId, courseIds); + } + + 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] + ); + } + } + } + } catch (roadmapError) { + console.error("Failed to auto-generate roadmap:", roadmapError); + } + + 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, + }); + } 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.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 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 + `, + [user.userId] + ); + + const responses = result.rows.map((row) => ({ + option_id: row.option_id, + option_text: row.option_text, + 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, + })); + + res.json({ responses }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +module.exports = router; diff --git a/routes/orgs.js b/routes/orgs.js index fe383d5..7b53590 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -1,8 +1,8 @@ -// routes/orgs.js 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), { @@ -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" }); @@ -36,7 +35,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) @@ -45,7 +43,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')`, @@ -53,11 +50,17 @@ router.post("/", async (req, res) => { ); await client.query("COMMIT"); + await logActivity({ + userId, + 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) { 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" @@ -96,7 +99,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] @@ -107,7 +109,19 @@ router.post("/addemployee", async (req, res) => { const org = orgRes.rows[0]; const organisationId = org.id; - // 2) Link user → new org as employee + const adminUserIdRes = await client.query( + `SELECT admin_user_id FROM organisations WHERE id = $1`, + [organisationId] + ); + + 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')`, @@ -115,6 +129,27 @@ router.post("/addemployee", async (req, res) => { ); await client.query("COMMIT"); + + setAuthCookie(res, { + ...session, + organisation: { + id: org.id, + organisationname: org.organisation_name, + role: "employee", + }, + }); + await logActivity({ + userId: adminUserId, + organisationId, + action: "add_employee", + metadata: { organisationId }, + 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" } }); } catch (err) { await client.query("ROLLBACK"); @@ -125,7 +160,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" }); @@ -155,7 +189,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); @@ -190,7 +223,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); @@ -265,6 +297,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 +359,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"); diff --git a/routes/reports.js b/routes/reports.js index 717d526..6263a3a 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -21,18 +21,30 @@ 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 + `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' `, [userId] ); - // 2) Modules done const { rows: modCount } = await client.query( `SELECT COUNT(*) AS modules_done FROM module_status ms @@ -44,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) @@ -75,8 +86,7 @@ router.get("/progress", async (req, res) => { [userId] ); - // Strengths & weaknesses by tag - const { rows: tagPerf } = await client.query( + const { rows: skillPerf } = await client.query( `WITH latest AS ( SELECT DISTINCT ON (qr.quiz_id) qr.id AS response_id, @@ -87,8 +97,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 +108,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 +176,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 +222,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 +290,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 +302,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..3ac9132 --- /dev/null +++ b/routes/roadmaps-helpers.js @@ -0,0 +1,146 @@ +async function getUserPreferences(client, userId) { + try { + 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 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 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); + + 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 + ); + + 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, + 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); + return { + skills: [], + memberChannels: [], + memberLevels: [], + onboardingChannels: [], + 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); +} + +async function ensureUserEnrolledInCourses(client, userId, courseIds) { + if (courseIds.length === 0) return []; + + const enrolledCourses = []; + + for (const courseId of courseIds) { + 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 + RETURNING id`, + [userId, courseId] + ); + + 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) { + const enrollmentIdResult = await client.query( + "SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2", + [userId, courseId] + ); + + if (enrollmentIdResult.rows.length > 0) { + const enrollmentId = enrollmentIdResult.rows[0].id; + + 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] + ); + } + } + } + + return enrolledCourses; +} + +module.exports = { + getUserPreferences, + getCoursesFromModules, + ensureUserEnrolledInCourses, +}; diff --git a/routes/roadmaps.js b/routes/roadmaps.js new file mode 100644 index 0000000..dc02ab1 --- /dev/null +++ b/routes/roadmaps.js @@ -0,0 +1,630 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); +const logActivity = require("./activityLogger"); +const { + getUserPreferences, + getCoursesFromModules, + ensureUserEnrolledInCourses, +} = require("./roadmaps-helpers"); + +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" }); + } + + 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" }); + } +}); + +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()] + ); + + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "create_roadmap", + metadata: { roadmapId: result.rows[0].id }, + displayMetadata: { "roadmap name": name.trim() }, + }); + + res.status(201).json({ roadmap: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +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" }); + } + + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "edit_roadmap", + metadata: { roadmapId: result.id, newName: name.trim() }, + displayMetadata: { "roadmap name": name.trim() }, + }); + + res.json({ roadmap: result.rows[0] }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +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 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 + RETURNING id`, + [id, user.userId] + ); + + if (result.rows.length === 0) { + return res.status(404).json({ message: "Roadmap not found" }); + } + + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "delete_roadmap", + metadata: { roadmapId: id }, + displayMetadata: { "roadmap name": roadmapName }, + }); + + res.json({ message: "Roadmap deleted successfully" }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +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 { + 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" }); + } + + 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, + 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] + ); + + res.json({ items: result.rows }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +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"); + + 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" }); + } + + 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" }); + } + + 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; + + 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, + user.userId, + courseIds + ); + + await client.query( + `INSERT INTO roadmap_items (roadmap_id, module_id, position) + VALUES ($1, $2, $3)`, + [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({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "add_roadmap_item", + metadata: { roadmapId: id, moduleId: module_id, position: nextPosition }, + displayMetadata: { + "roadmap name": roadmapName, + "module name": moduleName, + }, + }); + + 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(); + } +}); + +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"); + + 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" }); + } + + 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 + 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 + 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"); + + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "move_roadmap_item", + metadata: { roadmapId: id, moduleId, newPosition: position }, + displayMetadata: { + "roadmap name": roadmapName, + "module name": moduleName, + }, + }); + + 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(); + } +}); + +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 { + 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 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 + 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" }); + } + + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "remove_roadmap_item", + metadata: { roadmapId: id, moduleId }, + displayMetadata: { + "roadmap name": roadmapName, + "module name": moduleName, + }, + }); + + res.json({ message: "Module removed from roadmap" }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server error" }); + } +}); + +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"); + + 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]; + + 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 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 = []; + + const hasPreferences = + userSkillIds.length > 0 || + userChannelIds.length > 0 || + userLevelIds.length > 0; + + if (hasPreferences) { + let query = `SELECT DISTINCT + mod.id, + COALESCE(COUNT(DISTINCT ms.skill_id), 0) as matching_skills, + CASE + WHEN cc.channel_id = ANY($2) 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($3) THEN + CASE + 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 + 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 + 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 = $6 + LEFT JOIN module_status mst ON mst.module_id = mod.id AND mst.enrollment_id = e.id + WHERE c.organisation_id = $1 + AND (mst.status IS NULL OR mst.status IN ('not_started', 'in_progress'))`; + + let params = [ + organisationId, + userChannelIds, + userLevelIds, + userPreferences.channels.member, + userPreferences.levels.member, + user.userId, + ]; + + 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 + + const modulesResult = await client.query(query, params); + + if (modulesResult.rows.length > 0) { + const moduleIds = modulesResult.rows.map((row) => row.id); + + const courseIds = await getCoursesFromModules(client, moduleIds); + enrolledCourses = await ensureUserEnrolledInCourses( + client, + user.userId, + courseIds + ); + + 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"); + + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "generate_roadmap", + metadata: { roadmapId: roadmap.id, modulesAdded, enrolledCourses }, + displayMetadata: { "roadmap name": name.trim() }, + }); + + 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; diff --git a/routes/users.js b/routes/users.js index d2d4665..3243118 100644 --- a/routes/users.js +++ b/routes/users.js @@ -1,6 +1,18 @@ 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), { + 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; @@ -62,6 +74,17 @@ 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 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 @@ -72,6 +95,15 @@ router.delete("/", async (req, res) => { return res.status(404).json({ message: "User not found" }); } await client.query("COMMIT"); + await logActivity({ + userId: adminUserId, + organisationId: session.organisation?.id, + action: "delete_user", + metadata: { deleteUserId }, + displayMetadata: { + "deleted user name": `${deleteUserName.firstname} ${deleteUserName.lastname}`, + }, + }); return res.status(201).json({ message: "User deleted successfully", }); @@ -84,4 +116,717 @@ 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"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "update_profile", + metadata: { firstname, lastname, email }, + displayMetadata: { firstname, lastname, email }, + }); + 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"); + 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"); + 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" }); + } + + 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] + ); + + await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + 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" }); + } 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 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] + ); + + if (updateResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Skill not found" }); + } + + await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + 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" }); + } 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 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] + ); + + if (deleteResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Skill not found" }); + } + + await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + 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) { + await client.query("ROLLBACK"); + console.error("Error removing skill:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +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"); + + 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] + ); + + 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] + ); + + const allChannelsResult = await client.query( + `SELECT id, name, description + FROM channels + WHERE organisation_id = $1 + ORDER BY name`, + [organisationId] + ); + + 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(); + } +}); + +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"); + + 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" }); + } + + 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" }); + } + + 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; + + 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] + ); + + await client.query("COMMIT"); + await logActivity({ + userId, + 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) { + await client.query("ROLLBACK"); + console.error("Error adding channel preference:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +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"); + + 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" }); + } + + 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" }); + } + + 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; + + 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] + ); + + await client.query("COMMIT"); + await logActivity({ + userId, + 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) { + await client.query("ROLLBACK"); + console.error("Error adding level preference:", error); + return res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +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 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"); + + 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] + ); + + if (result.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Channel preference not found" }); + } + + await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "remove_channel_preference", + metadata: { channelId: channel_id }, + displayMetadata: { "channel name": channelName }, + }); + 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(); + } +}); + +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; + const organisationId = session.organisation?.id; + + if (!level_id) { + return res.status(400).json({ message: "Level ID is required" }); + } + + const client = await pool.connect(); + 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] + ); + + if (result.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Level preference not found" }); + } + + await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "remove_level_preference", + metadata: { levelId: level_id }, + displayMetadata: { "level name": levelName }, + }); + + 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; diff --git a/server.js b/server.js index 0a736ba..8dd8b42 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,11 @@ 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 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; @@ -19,7 +24,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 +31,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 +44,16 @@ 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.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}`);