diff --git a/database/schema.sql b/database/schema.sql index 20ec734..cd3b4b8 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -322,5 +322,16 @@ CREATE TABLE user_levels ( UNIQUE(user_id, level_id) ); +CREATE TABLE activity_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organisation_id INTEGER REFERENCES organisations(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + metadata JSONB DEFAULT '{}' NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + 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 0d601f9..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), { @@ -81,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); @@ -88,8 +96,14 @@ router.post("/login", async (req, res) => { } }); -router.post("/logout", (req, res) => { +router.post("/logout", async (req, res) => { + const session = JSON.parse(req.cookies.auth || "{}"); res.clearCookie("auth", { path: "/" }).json({ success: true }); + await logActivity({ + userId: session.userId, + organisationId: session.organisation ? session.organisation.id : null, + action: "logout", + }); }); router.get("/me", (req, res) => { @@ -161,6 +175,12 @@ router.post("/complete-onboarding", async (req, res) => { organisation: organisation, }); + await logActivity({ + userId: user.userId, + organisationId: organisation ? organisation.id : null, + action: "complete_onboarding", + }); + res.json({ success: true }); } catch (err) { console.error(err); diff --git a/routes/courses.js b/routes/courses.js index a45c4d3..419c898 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -3,6 +3,7 @@ const pool = require("../database/db"); const router = express.Router(); const multer = require("multer"); const path = require("path"); +const logActivity = require("./activityLogger"); const storage = multer.diskStorage({ destination: "uploads/", @@ -65,6 +66,13 @@ router.post("/", async (req, res) => { ); 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"); @@ -231,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`, @@ -238,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"); @@ -307,6 +334,13 @@ router.put("/", async (req, res) => { } 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"); @@ -513,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: { moduleId }, + displayMetadata: { "module name": name }, + }); return res.status(201).json({ module_id, }); @@ -549,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"); @@ -923,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"); @@ -1096,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" }); @@ -1114,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 @@ -1132,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], @@ -1163,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" }); @@ -1172,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 @@ -1207,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"); @@ -1230,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" }); } @@ -1250,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) { @@ -1269,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"); @@ -1291,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" }); @@ -1300,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', @@ -1310,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"); @@ -1720,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" }); } @@ -1743,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`, @@ -1758,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"); @@ -1781,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" }); } @@ -1804,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`, @@ -1826,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"); @@ -1879,6 +2051,8 @@ router.post("/add-channel", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -1902,6 +2076,13 @@ router.post("/add-channel", async (req, res) => { [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"); @@ -1926,6 +2107,8 @@ router.delete("/delete-channel", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -1942,11 +2125,27 @@ router.delete("/delete-channel", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); + const channelRes = await client.query( + `SELECT id, name FROM channels WHERE id = $1 AND organisation_id = $2`, + [channelId, organisationId] + ); + if (!channelRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Channel not found" }); + } + const channelName = channelRes.rows[0].name; await client.query( `DELETE FROM channels WHERE id = $1 AND organisation_id = $2`, [channelId, organisationId] ); 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"); @@ -2000,6 +2199,8 @@ router.post("/add-level", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2023,6 +2224,16 @@ router.post("/add-level", async (req, res) => { [name, description || "", sort_order || 0, organisationId] ); await client.query("COMMIT"); + await logActivity({ + userId, + organisationId, + action: "add_level", + metadata: { + name: name, + sortOrder: sort_order, + }, + displayMetadata: { "level name": name }, + }); return res.status(201).json({ success: true }); } catch (err) { await client.query("ROLLBACK"); @@ -2047,6 +2258,8 @@ router.delete("/delete-level", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2063,11 +2276,27 @@ router.delete("/delete-level", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); + const levelRes = await client.query( + `SELECT id, name FROM levels WHERE id = $1 AND organisation_id = $2`, + [levelId, organisationId] + ); + if (!levelRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Level not found" }); + } + const levelName = levelRes.rows[0].name; await client.query( `DELETE FROM levels WHERE id = $1 AND organisation_id = $2`, [levelId, organisationId] ); 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"); @@ -2121,6 +2350,8 @@ router.post("/add-skill", async (req, res) => { return res.status(400).json({ message: "Invalid session data" }); } + const userId = session.userId; + const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2144,6 +2375,13 @@ router.post("/add-skill", async (req, res) => { [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"); @@ -2167,7 +2405,7 @@ router.delete("/delete-skill", async (req, res) => { } catch { return res.status(400).json({ message: "Invalid session data" }); } - + const userId = session.userId; const organisationId = session.organisation?.id; const isAdmin = session.organisation?.role === "admin"; if (!isAdmin) { @@ -2184,11 +2422,27 @@ router.delete("/delete-skill", async (req, res) => { const client = await pool.connect(); try { await client.query("BEGIN"); + const skillRes = await client.query( + `SELECT id, name FROM skills WHERE id = $1 AND organisation_id = $2`, + [skillId, organisationId] + ); + if (!skillRes.rows.length) { + await client.query("ROLLBACK"); + return res.status(404).json({ message: "Skill not found" }); + } + const skillName = skillRes.rows[0].name; await client.query( `DELETE FROM skills WHERE id = $1 AND organisation_id = $2`, [skillId, organisationId] ); 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/onboarding.js b/routes/onboarding.js index 8f01842..7ac7b7f 100644 --- a/routes/onboarding.js +++ b/routes/onboarding.js @@ -1,6 +1,7 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); +const logActivity = require("./activityLogger"); function getAuthUser(req) { const { auth } = req.cookies; @@ -104,6 +105,18 @@ router.post("/questions", async (req, res) => { [question_text, position, organisationId] ); + // commit + if (result.rows.length === 0) { + return res.status(500).json({ message: "Failed to create question" }); + } + + await logActivity({ + userId: user.userId, + organisationId, + action: "add_onboarding_question", + metadata: { questionId: result.rows[0].id }, + }); + res.status(201).json({ question: result.rows[0] }); } catch (err) { console.error(err); @@ -205,6 +218,16 @@ router.post("/questions/:id/options", async (req, res) => { level_sort_order: level_id ? levelCheck.rows[0].sort_order : null, }; + await logActivity({ + userId: user.userId, + organisationId: user.organisation.id, + action: "add_onboarding_option", + metadata: { + questionId: id, + optionId: result.rows[0].id, + }, + }); + res.status(201).json({ option }); } catch (err) { await client.query("ROLLBACK"); @@ -255,6 +278,13 @@ router.delete("/questions/:id", async (req, res) => { return res.status(404).json({ message: "Question not found" }); } + await logActivity({ + userId: user.userId, + organisationId, + action: "delete_onboarding_question", + metadata: { questionId: result.rows[0].id }, + }); + res.json({ message: "Question deleted successfully" }); } catch (err) { console.error(err); @@ -316,6 +346,13 @@ router.delete("/options/:optionId", async (req, res) => { [optionId] ); + await logActivity({ + userId: user.userId, + organisationId, + action: "delete_onboarding_option", + metadata: { optionId: result.rows[0].id }, + }); + res.json({ message: "Option deleted successfully" }); } catch (err) { console.error(err); @@ -454,6 +491,13 @@ router.post("/responses", async (req, res) => { await client.query("COMMIT"); + await logActivity({ + userId: user.userId, + organisationId: user.organisation.id, + action: "submit_onboarding_responses", + metadata: { optionIds: option_ids }, + }); + res.json({ message: "Responses submitted successfully", roadmapGenerated: true, diff --git a/routes/orgs.js b/routes/orgs.js index bea3d46..7b53590 100644 --- a/routes/orgs.js +++ b/routes/orgs.js @@ -2,6 +2,7 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); const crypto = require("crypto"); +const logActivity = require("./activityLogger"); function setAuthCookie(res, payload) { res.cookie("auth", JSON.stringify(payload), { @@ -49,6 +50,13 @@ 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"); @@ -101,6 +109,19 @@ router.post("/addemployee", async (req, res) => { const org = orgRes.rows[0]; const organisationId = org.id; + const adminUserIdRes = await client.query( + `SELECT admin_user_id FROM organisations WHERE id = $1`, + [organisationId] + ); + + const adminUserId = adminUserIdRes.rows[0].admin_user_id; + + 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')`, @@ -117,6 +138,17 @@ router.post("/addemployee", async (req, res) => { 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) { @@ -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/roadmaps.js b/routes/roadmaps.js index 13d3a35..584ecde 100644 --- a/routes/roadmaps.js +++ b/routes/roadmaps.js @@ -1,6 +1,7 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); +const logActivity = require("./activityLogger"); const { getUserPreferences, getCoursesFromModules, @@ -58,6 +59,14 @@ router.post("/", async (req, res) => { [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); @@ -91,6 +100,14 @@ router.put("/:id", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "edit_roadmap", + metadata: { roadmapId: result.id, newName: name.trim() }, + displayMetadata: { "roadmap name": name.trim() }, + }); + res.json({ roadmap: result.rows[0] }); } catch (err) { console.error(err); @@ -107,6 +124,16 @@ router.delete("/:id", async (req, res) => { const { id } = req.params; try { + const roadMapRes = await pool.query( + `SELECT id, name FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + + if (roadMapRes.rows.length === 0) { + return res.status(404).json({ message: "Roadmap not found" }); + } + const roadmapName = roadMapRes.rows[0].name; + const result = await pool.query( `DELETE FROM roadmaps WHERE id = $1 AND user_id = $2 @@ -118,6 +145,14 @@ router.delete("/:id", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "delete_roadmap", + metadata: { roadmapId: id }, + displayMetadata: { "roadmap name": roadmapName }, + }); + res.json({ message: "Roadmap deleted successfully" }); } catch (err) { console.error(err); @@ -234,6 +269,12 @@ router.post("/:id/items", async (req, res) => { const nextPosition = positionResult.rows[0].next_position; + const roadmapNameRes = await pool.query( + `SELECT name FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + const roadmapName = roadmapNameRes.rows[0]?.name; + const courseIds = await getCoursesFromModules(client, [module_id]); const enrolledCourses = await ensureUserEnrolledInCourses( client, @@ -247,8 +288,33 @@ router.post("/:id/items", async (req, res) => { [id, module_id, nextPosition] ); + const moduleNameRes = await pool.query( + `SELECT mod.title as module_title + FROM modules mod + WHERE mod.id = $1 + `, + [module_id] + ); + + if (moduleNameRes.rows.length === 0) { + return res.status(404).json({ message: "Module item not found" }); + } + + const moduleName = moduleNameRes.rows[0].module_title; + await client.query("COMMIT"); + await logActivity({ + 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, @@ -290,6 +356,26 @@ router.put("/:id/items/:moduleId", async (req, res) => { return res.status(404).json({ message: "Roadmap not found" }); } + const roadmapNameRes = await pool.query( + `SELECT name FROM roadmaps WHERE id = $1 AND user_id = $2`, + [id, user.userId] + ); + const roadmapName = roadmapNameRes.rows[0]?.name; + + const moduleNameRes = await pool.query( + `SELECT mod.title as module_title + FROM modules mod + 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 @@ -305,6 +391,17 @@ router.put("/:id/items/:moduleId", async (req, res) => { await client.query("COMMIT"); + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "move_roadmap_item", + metadata: { roadmapId: id, moduleId, newPosition: position }, + displayMetadata: { + "roadmap name": roadmapName, + "module name": moduleName, + }, + }); + res.json({ message: "Position updated", position: result.rows[0].position, @@ -327,6 +424,25 @@ router.delete("/:id/items/:moduleId", async (req, res) => { const { id, moduleId } = req.params; try { + const moduleNameRes = await pool.query( + `SELECT mod.title as module_title + FROM modules mod + WHERE mod.id = $1 + `, + [moduleId] + ); + + if (moduleNameRes.rows.length === 0) { + return res.status(404).json({ message: "Module item not found" }); + } + + const moduleName = moduleNameRes.rows[0].module_title; + + const 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 @@ -342,6 +458,17 @@ router.delete("/:id/items/:moduleId", async (req, res) => { return res.status(404).json({ message: "Roadmap item not found" }); } + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "remove_roadmap_item", + metadata: { roadmapId: id, moduleId }, + displayMetadata: { + "roadmap name": roadmapName, + "module name": moduleName, + }, + }); + res.json({ message: "Module removed from roadmap" }); } catch (err) { console.error(err); @@ -494,6 +621,14 @@ router.post("/generate", async (req, res) => { await client.query("COMMIT"); + await logActivity({ + userId: user.userId, + organisationId: user.organisation?.id, + action: "generate_roadmap", + metadata: { roadmapId: roadmap.id, modulesAdded, enrolledCourses }, + displayMetadata: { "roadmap name": name.trim() }, + }); + res.status(201).json({ roadmap, modulesAdded, diff --git a/routes/users.js b/routes/users.js index c0df150..3243118 100644 --- a/routes/users.js +++ b/routes/users.js @@ -2,6 +2,7 @@ const express = require("express"); const bcrypt = require("bcrypt"); const pool = require("../database/db"); const router = express.Router(); +const logActivity = require("./activityLogger"); function setAuthCookie(res, payload) { res.cookie("auth", JSON.stringify(payload), { @@ -73,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 @@ -83,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", }); @@ -146,6 +167,13 @@ router.put("/profile", async (req, res) => { setAuthCookie(res, updatedSession); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "update_profile", + metadata: { firstname, lastname, email }, + displayMetadata: { firstname, lastname, email }, + }); return res.json({ message: "Profile updated successfully", user: updatedSession, @@ -214,6 +242,12 @@ router.put("/password", async (req, res) => { ]); await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "update_password", + metadata: {}, + }); return res.json({ message: "Password updated successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -305,12 +339,26 @@ router.post("/skills", async (req, res) => { return res.status(400).json({ message: "Skill already added" }); } + const skillNameResult = await client.query( + "SELECT name FROM skills WHERE id = $1", + [skill_id] + ); + const skillName = skillNameResult.rows[0]?.name; + await client.query( "INSERT INTO user_skills (user_id, skill_id, level) VALUES ($1, $2, $3)", [session.userId, skill_id, level] ); 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"); @@ -346,6 +394,12 @@ router.put("/skills", async (req, res) => { try { await client.query("BEGIN"); + const skillNameResult = await client.query( + "SELECT name FROM skills WHERE id = $1", + [skill_id] + ); + const skillName = skillNameResult.rows[0]?.name; + const updateResult = await client.query( "UPDATE user_skills SET level = $1, updated_at = NOW() WHERE user_id = $2 AND skill_id = $3", [level, session.userId, skill_id] @@ -357,6 +411,14 @@ router.put("/skills", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "update_user_skill", + metadata: { skillId: skill_id, newLevel: level }, + displayMetadata: { "skill name": skillName, newLevel: level }, + }); + return res.json({ message: "Skill level updated successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -387,6 +449,12 @@ router.delete("/skills", async (req, res) => { try { await client.query("BEGIN"); + const skillNameResult = await client.query( + "SELECT name FROM skills WHERE id = $1", + [skill_id] + ); + const skillName = skillNameResult.rows[0]?.name; + const deleteResult = await client.query( "DELETE FROM user_skills WHERE user_id = $1 AND skill_id = $2", [session.userId, skill_id] @@ -398,6 +466,13 @@ router.delete("/skills", async (req, res) => { } await client.query("COMMIT"); + await logActivity({ + userId: session.userId, + organisationId: session.organisation?.id, + action: "remove_user_skill", + metadata: { skillId: skill_id }, + displayMetadata: { "skill name": skillName }, + }); return res.json({ message: "Skill removed successfully" }); } catch (error) { await client.query("ROLLBACK"); @@ -529,12 +604,25 @@ router.post("/preferences/channels", async (req, res) => { const nextRank = rankResult.rows[0].next_rank; + const channelNameResult = await client.query( + "SELECT name FROM channels WHERE id = $1", + [channel_id] + ); + const channelName = channelNameResult.rows[0]?.name; + await client.query( "INSERT INTO user_channels (user_id, channel_id, preference_rank) VALUES ($1, $2, $3)", [userId, channel_id, nextRank] ); 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"); @@ -597,12 +685,25 @@ router.post("/preferences/levels", async (req, res) => { const nextRank = rankResult.rows[0].next_rank; + const levelNameResult = await client.query( + "SELECT name FROM levels WHERE id = $1", + [level_id] + ); + const levelName = levelNameResult.rows[0]?.name; + await client.query( "INSERT INTO user_levels (user_id, level_id, preference_rank) VALUES ($1, $2, $3)", [userId, level_id, nextRank] ); 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"); @@ -625,6 +726,7 @@ router.delete("/preferences/channels", async (req, res) => { } const userId = session.userId; + const organisationId = session.organisation?.id; const { channel_id } = req.body; if (!channel_id) { @@ -635,6 +737,12 @@ router.delete("/preferences/channels", async (req, res) => { try { await client.query("BEGIN"); + const channelNameResult = await client.query( + "SELECT name FROM channels WHERE id = $1", + [channel_id] + ); + const channelName = channelNameResult.rows[0]?.name; + const result = await client.query( "DELETE FROM user_channels WHERE user_id = $1 AND channel_id = $2", [userId, channel_id] @@ -646,6 +754,13 @@ router.delete("/preferences/channels", async (req, res) => { } 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"); @@ -669,6 +784,7 @@ router.delete("/preferences/levels", async (req, res) => { const userId = session.userId; const { level_id } = req.body; + const organisationId = session.organisation?.id; if (!level_id) { return res.status(400).json({ message: "Level ID is required" }); @@ -678,6 +794,12 @@ router.delete("/preferences/levels", async (req, res) => { try { await client.query("BEGIN"); + const levelNameResult = await client.query( + "SELECT name FROM levels WHERE id = $1", + [level_id] + ); + const levelName = levelNameResult.rows[0]?.name; + const result = await client.query( "DELETE FROM user_levels WHERE user_id = $1 AND level_id = $2", [userId, level_id] @@ -689,6 +811,14 @@ router.delete("/preferences/levels", async (req, res) => { } 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"); diff --git a/server.js b/server.js index 5139b11..f01d7af 100644 --- a/server.js +++ b/server.js @@ -8,6 +8,7 @@ const reportsRoutes = require("./routes/reports"); const onboardingRoutes = require("./routes/onboarding"); const roadmapRoutes = require("./routes/roadmaps"); const materialRoutes = require("./routes/materials"); +const activityRoutes = require("./routes/activity"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; @@ -50,6 +51,7 @@ app.use("/api/reports", reportsRoutes); app.use("/api/onboarding", onboardingRoutes); app.use("/api/roadmaps", roadmapRoutes); app.use("/api/materials", materialRoutes); +app.use("/api/activity", activityRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`);