diff --git a/database/schema.sql b/database/schema.sql index e4116e9..bc90171 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -18,7 +18,7 @@ CREATE TABLE organisations ( admin_user_id INTEGER UNIQUE NOT NULL REFERENCES users(id) ON DELETE NO ACTION, description TEXT NOT NULL DEFAULT '', - ai_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ai_enabled BOOLEAN NOT NULL DEFAULT TRUE, current_invitation_id TEXT UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(organisation_name, admin_user_id) @@ -343,5 +343,24 @@ CREATE TABLE chat_logs ( created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +CREATE TABLE badges ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + num_courses_completed INTEGER NOT NULL DEFAULT 0, + image_url TEXT, + organisation_id INTEGER NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + course_id INTEGER REFERENCES courses(id) ON DELETE CASCADE, + UNIQUE(name, organisation_id) +); + +CREATE TABLE user_badges ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + badge_id INTEGER NOT NULL REFERENCES badges(id) ON DELETE CASCADE, + awarded_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY(user_id, badge_id) +); + COMMIT; \ No newline at end of file diff --git a/routes/badges.js b/routes/badges.js new file mode 100644 index 0000000..f296c4b --- /dev/null +++ b/routes/badges.js @@ -0,0 +1,446 @@ +const express = require("express"); +const pool = require("../database/db"); +const router = express.Router(); +const logActivity = require("./activityLogger"); + +router.post("/create-frequent", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const { name, description, numCoursesCompleted } = req.body; + if (!name) { + return res.status(400).json({ message: "name is required" }); + } + + if (!numCoursesCompleted) { + return res.status(400).json({ + message: "numCoursesCompleted is required", + }); + } + + if (numCoursesCompleted < 0) { + return res.status(400).json({ + message: "numCoursesCompleted must be non-negative", + }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const { rows } = await client.query( + `INSERT INTO badges (name, description, num_courses_completed, organisation_id) + VALUES ($1, $2, $3, $4) RETURNING id`, + [name, description || "", numCoursesCompleted, organisationId] + ); + + const badgeId = rows[0].id; + + const { rows: qualified } = await client.query( + `SELECT user_id + FROM enrollments + WHERE status = 'completed' + GROUP BY user_id + HAVING COUNT(*) >= $1`, + [numCoursesCompleted] + ); + + for (const { user_id } of qualified) { + await client.query( + `INSERT INTO user_badges (user_id, badge_id) + VALUES ($1, $2) + ON CONFLICT (user_id, badge_id) DO NOTHING`, + [user_id, badgeId] + ); + await logActivity({ + userId: user_id, + organisationId, + action: "earn_badge", + metadata: { badgeId }, + displayMetadata: { + name, + description, + milestone: `${numCoursesCompleted} courses`, + }, + }); + } + + await logActivity({ + userId, + action: "create_badge", + organisationId, + metadata: { + badgeId, + name, + description, + numCoursesCompleted, + }, + displayMetadata: { + name, + description, + "Number of courses to be completed": numCoursesCompleted || 0, + }, + }); + + await client.query("COMMIT"); + res.status(201).json({ badgeId }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error creating badge:", err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.post("/create-specific-course", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const { name, description, courseId } = req.body; + if (!name || !courseId) { + return res.status(400).json({ message: "name and courseId are required" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const courseRes = await client.query( + `SELECT id, name FROM courses WHERE id = $1`, + [courseId] + ); + if (!courseRes.rows.length) { + return res.status(404).json({ message: "Course not found" }); + } + const courseName = courseRes.rows[0].name; + + const { rows } = await client.query( + `INSERT INTO badges (name, description, organisation_id, course_id) + VALUES ($1, $2, $3, $4) RETURNING id`, + [name, description || "", organisationId, courseId] + ); + + const badgeId = rows[0].id; + + const { rows: doneUsers } = await client.query( + `SELECT user_id + FROM enrollments + WHERE course_id = $1 + AND status = 'completed'`, + [courseId] + ); + + for (const { user_id } of doneUsers) { + await client.query( + `INSERT INTO user_badges (user_id, badge_id) + VALUES ($1, $2) + ON CONFLICT (user_id, badge_id) DO NOTHING`, + [user_id, badgeId] + ); + await logActivity({ + userId: user_id, + organisationId, + action: "earn_badge", + metadata: { badgeId, courseId }, + displayMetadata: { + name, + "course name": courseName, + }, + }); + } + + await logActivity({ + userId, + action: "create_badge", + organisationId, + metadata: { badgeId, name, description, courseId }, + displayMetadata: { + "Course Name": courseName, + }, + }); + + await client.query("COMMIT"); + res.status(201).json({ badgeId }); + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error creating badge:", err); + res.status(500).json({ message: "Server error" }); + } finally { + client.release(); + } +}); + +router.get("/created-badges", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + const userId = session.userId; + const organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + // const organisationRole = session.organisation?.role; + // if (organisationRole !== "admin") { + // return res.status(403).json({ message: "Forbidden" }); + // } + + const client = await pool.connect(); + try { + const { rows } = await client.query( + `SELECT id, name, description, num_courses_completed + FROM badges + WHERE organisation_id = $1 + AND num_courses_completed IS NOT NULL + AND num_courses_completed > 0 + ORDER BY created_at DESC`, + [organisationId] + ); + const coursesBadges = rows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + numCoursesCompleted: row.num_courses_completed, + })); + + const { rows: specificCourseBadges } = await client.query( + `SELECT id, name, description, course_id + FROM badges + WHERE organisation_id = $1 + AND course_id IS NOT NULL + ORDER BY created_at DESC`, + [organisationId] + ); + const courseBadges = specificCourseBadges.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + courseId: row.course_id, + })); + + await client.release(); + return res.status(200).json({ + coursesBadges, + courseBadges, + }); + } catch (err) { + await client.release(); + console.error("Error getting badges:", err); + return res.status(500).json({ message: "Server error" }); + } +}); + +router.get("/user-badges", async (req, res) => { + const { auth } = req.cookies; + if (!auth) return res.status(401).json({ message: "Not authenticated" }); + + let session; + try { + session = JSON.parse(auth); + } catch { + return res.status(400).json({ message: "Invalid session data" }); + } + + const userId = session.userId; + const organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const client = await pool.connect(); + try { + const { rows } = await client.query( + `SELECT b.id, b.name, b.description, ub.awarded_at + FROM badges b, user_badges ub + WHERE b.id = ub.badge_id + AND b.organisation_id = $1 AND ub.user_id = $2 + ORDER BY ub.awarded_at DESC`, + [organisationId, userId] + ); + + const badges = rows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description, + awardedAt: row.awarded_at ? new Date(row.awarded_at).toISOString() : null, + })); + + await client.release(); + + return res.json({ badges }); + } catch (err) { + await client.release(); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}); + +router.delete("/course-specific-badge", 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 { badgeId } = req.body; + if (!badgeId) { + return res.status(400).json({ message: "badgeId is required" }); + } + + const userId = session.userId; + const organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const badgeRes = await client.query( + `SELECT * FROM badges WHERE id = $1 AND organisation_id = $2`, + [badgeId, organisationId] + ); + if (!badgeRes.rows.length) { + return res.status(404).json({ message: "Badge not found" }); + } + + await client.query( + `DELETE FROM badges WHERE id = $1 AND organisation_id = $2`, + [badgeId, organisationId] + ); + + const badgeName = badgeRes.rows[0].name; + + await logActivity({ + userId, + action: "delete_badge", + organisationId, + metadata: { badgeId }, + displayMetadata: { "Badge Name": badgeName }, + }); + + await client.query("COMMIT"); + return res.status(200).json({ message: "Badge deleted successfully" }); + } catch (err) { + await client.release(); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}); + +router.delete("/frequent-badge", 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 { badgeId } = req.body; + if (!badgeId) { + return res.status(400).json({ message: "badgeId is required" }); + } + + const userId = session.userId; + const organisationId = session.organisation?.id; + if (!organisationId) { + return res.status(400).json({ message: "Organization required" }); + } + + const organisationRole = session.organisation?.role; + if (organisationRole !== "admin") { + return res.status(403).json({ message: "Forbidden" }); + } + + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const badgeRes = await client.query( + `SELECT * FROM badges WHERE id = $1 AND organisation_id = $2`, + [badgeId, organisationId] + ); + if (!badgeRes.rows.length) { + return res.status(404).json({ message: "Badge not found" }); + } + + await client.query( + `DELETE FROM badges WHERE id = $1 AND organisation_id = $2`, + [badgeId, organisationId] + ); + + const badgeName = badgeRes.rows[0].name; + + await logActivity({ + userId, + action: "delete_badge", + organisationId, + metadata: { badgeId }, + displayMetadata: { "Badge Name": badgeName }, + }); + + await client.query("COMMIT"); + return res.status(200).json({ message: "Badge deleted successfully" }); + } catch (err) { + await client.release(); + console.error(err); + return res.status(500).json({ message: "Server error" }); + } +}); + +module.exports = router; diff --git a/routes/courses.js b/routes/courses.js index ac55128..a0b7e3a 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -1384,6 +1384,89 @@ router.post("/complete-course", async (req, res) => { [userId, courseId] ); + const { rows: courseSpecificBadges } = await client.query( + `SELECT id, name, description + FROM badges + WHERE organisation_id = $1 + AND course_id = $2`, + [organisationId, courseId] + ); + + for (const badge of courseSpecificBadges) { + await client.query( + `INSERT INTO user_badges (user_id, badge_id) + VALUES ($1, $2) + ON CONFLICT (user_id, badge_id) DO NOTHING`, + [userId, badge.id] + ); + + await logActivity({ + userId, + organisationId, + action: "earn_badge", + metadata: { badgeId: badge.id }, + displayMetadata: { + "badge name": badge.name, + "course name": courseName, + }, + }); + } + + const specificBadges = courseSpecificBadges.map((b) => ({ + id: b.id, + name: b.name, + description: b.description, + })); + + const numberCoursesCompletedRes = await client.query( + `SELECT COUNT(*) AS count + FROM enrollments + WHERE user_id = $1 + AND status = 'completed'`, + [userId] + ); + + const numberCoursesCompleted = parseInt( + numberCoursesCompletedRes.rows[0].count, + 10 + ); + + const numberCoursesCompletedBadgeRes = await client.query( + `SELECT id, name, description, num_courses_completed + FROM badges + WHERE organisation_id = $1 + AND num_courses_completed IS NOT NULL + AND num_courses_completed <= $2`, + [organisationId, numberCoursesCompleted] + ); + + for (const badge of numberCoursesCompletedBadgeRes.rows) { + await client.query( + `INSERT INTO user_badges (user_id, badge_id) + VALUES ($1, $2) + ON CONFLICT (user_id, badge_id) DO NOTHING`, + [userId, badge.id] + ); + + await logActivity({ + userId, + organisationId, + action: "earn_badge", + metadata: { badgeId: badge.id }, + displayMetadata: { + "badge name": badge.name, + "course name": courseName, + "courses completed": numberCoursesCompleted, + }, + }); + } + + const earnedBadges = numberCoursesCompletedBadgeRes.rows.map((b) => ({ + id: b.id, + name: b.name, + description: b.description, + })); + await client.query("COMMIT"); await logActivity({ userId, @@ -1392,7 +1475,9 @@ router.post("/complete-course", async (req, res) => { metadata: { courseId }, displayMetadata: { "course name": courseName }, }); - return res.status(200).json({ success: true }); + return res + .status(200) + .json({ success: true, specificBadges, earnedBadges }); } catch (err) { await client.query("ROLLBACK"); console.error("Error completing course:", err); @@ -1443,6 +1528,34 @@ router.post("/uncomplete-course", async (req, res) => { [userId, courseId] ); + await client.query( + `DELETE FROM user_badges ub + USING badges b + WHERE ub.badge_id = b.id + AND ub.user_id = $1 + AND b.course_id = $2`, + [userId, courseId] + ); + + const { rows: cntRows } = await client.query( + `SELECT COUNT(*)::int AS completed_count + FROM enrollments + WHERE user_id = $1 + AND status = 'completed'`, + [userId] + ); + const newCount = cntRows[0].completed_count; + + await client.query( + `DELETE FROM user_badges ub + USING badges b + WHERE ub.badge_id = b.id + AND ub.user_id = $1 + AND b.num_courses_completed IS NOT NULL + AND b.num_courses_completed > $2`, + [userId, newCount] + ); + await client.query("COMMIT"); await logActivity({ userId, diff --git a/routes/dashboard.js b/routes/dashboard.js index a4fc984..bc8b1fd 100644 --- a/routes/dashboard.js +++ b/routes/dashboard.js @@ -18,6 +18,8 @@ router.get("/user-dashboard", async (req, res) => { return res.status(401).json({ message: "Not logged in" }); } + const organisationName = user.organisation.organisationname; + const client = await pool.connect(); try { await client.query("BEGIN"); @@ -136,6 +138,7 @@ WHERE e.user_id = $1`, await client.query("COMMIT"); res.json({ welcome: `Welcome, ${user.firstname}!`, + organisationName: organisationName, currentCourse, currentModule, nextToLearn, @@ -158,6 +161,8 @@ router.get("/admin-dashboard", async (req, res) => { return res.status(403).json({ message: "Forbidden" }); } + const organisationName = user.organisation.organisationname; + const client = await pool.connect(); try { await client.query("BEGIN"); @@ -197,7 +202,8 @@ router.get("/admin-dashboard", async (req, res) => { await client.query("COMMIT"); res.json({ - welcome: `Welcome, Admin ${user.firstname}!`, + welcome: `Welcome, ${user.firstname}!`, + organisationName: organisationName, employees, enrollments, }); diff --git a/routes/reports.js b/routes/reports.js index 6263a3a..6776caf 100644 --- a/routes/reports.js +++ b/routes/reports.js @@ -1,8 +1,6 @@ const express = require("express"); const pool = require("../database/db"); const router = express.Router(); -const multer = require("multer"); -const path = require("path"); router.get("/progress", async (req, res) => { const { auth } = req.cookies; @@ -126,6 +124,15 @@ GROUP BY skill_name const strengths = skillPerf.filter((r) => r.pct >= 80); const weaknesses = skillPerf.filter((r) => r.pct < 80); + const { rows: userBadges } = await client.query( + `SELECT b.id, b.name, b.description, ub.awarded_at + FROM user_badges ub + JOIN badges b ON b.id = ub.badge_id + WHERE ub.user_id = $1 + ORDER BY ub.awarded_at DESC`, + [userId] + ); + await client.query("COMMIT"); return res.json({ coursesDone, @@ -133,6 +140,7 @@ GROUP BY skill_name quizResults, strengths, weaknesses, + userBadges, }); } catch (err) { await client.query("ROLLBACK"); @@ -327,6 +335,15 @@ router.get("/overview", requireAdmin, async (req, res) => { const strengths = skillPerf.filter((r) => r.pct >= 80); const weaknesses = skillPerf.filter((r) => r.pct < 80); + const { rows: empBadges } = await client.query( + `SELECT b.id, b.name, b.description, ub.awarded_at + FROM user_badges ub + JOIN badges b ON b.id = ub.badge_id + WHERE ub.user_id = $1 + ORDER BY ub.awarded_at DESC`, + [uid] + ); + return { id: emp.id, firstname: emp.firstname, @@ -336,14 +353,33 @@ router.get("/overview", requireAdmin, async (req, res) => { quizResults, strengths, weaknesses, + badges: empBadges, }; }) ); + const { rows: badges } = await client.query( + `SELECT + b.id, + b.name, + b.description, + b.course_id, + b.num_courses_completed AS milestone, + COUNT(ub.user_id) AS earned_count + FROM badges b + LEFT JOIN user_badges ub + ON ub.badge_id = b.id + WHERE b.organisation_id = $1 + GROUP BY b.id, b.name, b.description, b.course_id, b.num_courses_completed + ORDER BY b.name`, + [orgId] + ); + await client.query("COMMIT"); res.json({ courses: coursesRes.rows, + badges, employees: { total: employees.length, list: employees, diff --git a/server.js b/server.js index b19c013..8d10a94 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const materialRoutes = require("./routes/materials"); const activityRoutes = require("./routes/activity"); const dashboardRoutes = require("./routes/dashboard"); const chatbotRoutes = require("./routes/chatbot"); +const badgesRoutes = require("./routes/badges"); const pool = require("./database/db"); const app = express(); const PORT = process.env.PORT || 4000; @@ -58,6 +59,7 @@ app.use("/api/materials", materialRoutes); app.use("/api/activity", activityRoutes); app.use("/api/dashboard", dashboardRoutes); app.use("/api/chatbot", chatbotRoutes); +app.use("/api/badges", badgesRoutes); app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`);