From d08f67682f2534e12eb02f8c4b95a5d449bdcab4 Mon Sep 17 00:00:00 2001 From: Mohit Mohan Date: Wed, 29 Jul 2026 01:46:14 +0530 Subject: [PATCH 1/2] feat(admin): add Courses Without BR page (#142) Adds an admin page listing courses that have no branch representative assigned, backed by the getCoursesWithoutBR endpoint. - fetchCoursesWithoutBR in admin/src/apis/br.js - CoursesWithoutBR page + coursesWithoutBRTable component - Wire up /admin/courses-without-br route and sidebar nav item Co-Authored-By: Claude Opus 4.8 --- admin/src/App.jsx | 9 ++++ admin/src/apis/br.js | 16 +++++++ admin/src/components/Sidebar.jsx | 3 +- .../src/components/coursesWithoutBRTable.jsx | 37 ++++++++++++++++ admin/src/pages/CoursesWithoutBR.jsx | 44 +++++++++++++++++++ server/modules/br/br.controller.js | 28 +++++++++++- server/modules/br/br.routes.js | 3 +- 7 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 admin/src/components/coursesWithoutBRTable.jsx create mode 100644 admin/src/pages/CoursesWithoutBR.jsx diff --git a/admin/src/App.jsx b/admin/src/App.jsx index 3c594a38..5ecfcff8 100644 --- a/admin/src/App.jsx +++ b/admin/src/App.jsx @@ -1,6 +1,7 @@ import { BrowserRouter as Router, Route, Routes } from "react-router-dom"; import Sidebar from "./components/Sidebar"; import BranchRepresentatives from "./pages/BranchRepresentatives"; +import CoursesWithoutBR from "./pages/CoursesWithoutBR"; import Students from "./pages/Students"; import Courses from "./pages/Courses"; import CourseLinking from "./pages/CourseLinking"; @@ -48,6 +49,14 @@ function App() { } /> + + + + } + /> { } }; +// Fetch all courses that don't have a branch representative +export const fetchCoursesWithoutBR = async () => { + try { + const response = await fetch(`${API_BASE_URL}api/br/coursesWithoutBR`, { + credentials: "include", + headers: { + Authorization: "Bearer admin-coursehub-cc23-golang", + }, + }); + return await response.json(); + } catch (error) { + console.error("Error fetching courses without BR:", error); + throw error; + } +}; + // Create a single BR export const createBR = async (email) => { try { diff --git a/admin/src/components/Sidebar.jsx b/admin/src/components/Sidebar.jsx index 352f03da..47e4b673 100644 --- a/admin/src/components/Sidebar.jsx +++ b/admin/src/components/Sidebar.jsx @@ -1,6 +1,6 @@ import React from "react"; import { Link, useLocation } from "react-router-dom"; -import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate } from "react-icons/fa"; +import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate, FaExclamationTriangle } from "react-icons/fa"; import { adminLogout } from "@/apis/auth"; const navItems = [ @@ -8,6 +8,7 @@ const navItems = [ { label: "Students", to: "/admin/students", icon: FaUserGraduate }, { label: "Courses", to: "/admin/courses", icon: FaBook }, { label: "Course Linking", to: "/admin/course-linking", icon: FaLink }, + { label: "Courses Without BR", to: "/admin/courses-without-br", icon: FaExclamationTriangle }, ]; const Sidebar = () => { diff --git a/admin/src/components/coursesWithoutBRTable.jsx b/admin/src/components/coursesWithoutBRTable.jsx new file mode 100644 index 00000000..83656a2f --- /dev/null +++ b/admin/src/components/coursesWithoutBRTable.jsx @@ -0,0 +1,37 @@ +import React from "react"; + +const CoursesWithoutBRTable = ({ courses }) => { + return ( +
+
+

Courses Without BR

+
+
+ + + + + + + + + {courses.map((course) => ( + + + + + ))} + +
+ Code + + Name +
+ {course.code} + {course.name}
+
+
+ ); +}; + +export default CoursesWithoutBRTable; diff --git a/admin/src/pages/CoursesWithoutBR.jsx b/admin/src/pages/CoursesWithoutBR.jsx new file mode 100644 index 00000000..3d2225e8 --- /dev/null +++ b/admin/src/pages/CoursesWithoutBR.jsx @@ -0,0 +1,44 @@ +import React, { useState, useEffect } from "react"; +import CoursesWithoutBRTable from "../components/coursesWithoutBRTable"; +import { fetchCoursesWithoutBR } from "@/apis/br"; + +export default function CoursesWithoutBR() { + const [courses, setCourses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadCourses = async () => { + try { + setLoading(true); + const response = await fetchCoursesWithoutBR(); + setCourses(response.coursesWithoutBR); + setLoading(false); + } catch (err) { + setError(err.message || "An error occurred while fetching courses without BR."); + setLoading(false); + } + }; + + useEffect(() => { + loadCourses(); + }, []); + + return ( +
+
+
+

Courses Without BR

+

+ Courses that don't have a branch representative assigned yet +

+
+
+ +
+ {loading &&

Loading...

} + {error &&

{error}

} + {!loading && !error && } +
+
+ ); +} diff --git a/server/modules/br/br.controller.js b/server/modules/br/br.controller.js index bd9a9588..9f149d82 100644 --- a/server/modules/br/br.controller.js +++ b/server/modules/br/br.controller.js @@ -2,6 +2,8 @@ import BR from "./br.model.js"; import User from "../user/user.model.js"; import { fetchCoursesForBr } from "../auth/auth.controller.js"; import logger from "../../utils/logger.js"; +import CourseModel from "../course/course.model.js"; +import { normalizeCourseCode } from "../../utils/course.js"; const normalizeEmail = (email) => email?.toString().trim().toLowerCase(); @@ -142,5 +144,29 @@ const getBRs = async (req, res) => { res.status(500).json({ error: "Internal Server Error" }); } }; +const getCoursesWithoutBR = async (req, res) => { + try { + const brRecords = await BR.find({}); + const brEmails = brRecords.map((br) => br.email.toLowerCase()); + + const brUsers = await User.find({ email: { $in: brEmails } }); + + const coveredCodes = new Set( + brUsers.flatMap((user) => + (user.courses || []).map((c) => normalizeCourseCode(c.code)) + ) + ); + + const allCourses = await CourseModel.find({}); + + const coursesWithoutBR = allCourses.filter( + (course) => !coveredCodes.has(normalizeCourseCode(course.code)) + ); -export { updateBRs, createBR, getAll, deleteBR, getBRs }; + res.status(200).json({ coursesWithoutBR }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; +export { updateBRs, createBR, getAll, deleteBR, getBRs, getCoursesWithoutBR }; diff --git a/server/modules/br/br.routes.js b/server/modules/br/br.routes.js index 803d21b5..55ed1923 100644 --- a/server/modules/br/br.routes.js +++ b/server/modules/br/br.routes.js @@ -1,5 +1,5 @@ import express from "express"; -import { updateBRs, createBR, getAll, deleteBR, getBRs } from "./br.controller.js"; +import { updateBRs, createBR, getAll, deleteBR, getBRs, getCoursesWithoutBR } from "./br.controller.js"; const router = express.Router(); @@ -7,6 +7,7 @@ router.post("/updateList", updateBRs); router.post("/create", createBR); router.get("/all", getAll); router.get("/allBRs", getBRs); +router.get("/coursesWithoutBR", getCoursesWithoutBR); router.delete("/delete", deleteBR); export default router; From 030b3af839cea3da55484c4b4f09cf3efe359ee7 Mon Sep 17 00:00:00 2001 From: Mohit Mohan Date: Sun, 9 Aug 2026 02:00:22 +0530 Subject: [PATCH 2/2] Address PR #184 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop hardcoded admin Bearer token from br.js; rely on cookie auth (credentials: "include") like the rest of the admin app — the token wasn't a valid JWT and /api/br/* has no auth middleware anyway. - Rename coursesWithoutBRTable.jsx to CoursesWithoutBRTable.jsx (PascalCase) and update its import. - Add a "No courses found" empty state to CoursesWithoutBRTable. - Refactor getCoursesWithoutBR to filter via a MongoDB aggregation pipeline ($lookup + $match) instead of loading all BRs, users, and courses into memory. Co-Authored-By: Claude Sonnet 5 --- admin/src/apis/br.js | 8 --- .../src/components/CoursesWithoutBRTable.jsx | 41 +++++++++++++ .../src/components/coursesWithoutBRTable.jsx | 37 ------------ admin/src/pages/CoursesWithoutBR.jsx | 2 +- server/modules/br/br.controller.js | 58 +++++++++++++------ 5 files changed, 83 insertions(+), 63 deletions(-) create mode 100644 admin/src/components/CoursesWithoutBRTable.jsx delete mode 100644 admin/src/components/coursesWithoutBRTable.jsx diff --git a/admin/src/apis/br.js b/admin/src/apis/br.js index 863b0f22..93c0ce62 100644 --- a/admin/src/apis/br.js +++ b/admin/src/apis/br.js @@ -5,9 +5,6 @@ export const fetchBRs = async () => { try { const response = await fetch(`${API_BASE_URL}api/br/allBRs`, { credentials: "include", - headers: { - Authorization: "Bearer admin-coursehub-cc23-golang", - }, }); return await response.json(); } catch (error) { @@ -21,9 +18,6 @@ export const fetchCoursesWithoutBR = async () => { try { const response = await fetch(`${API_BASE_URL}api/br/coursesWithoutBR`, { credentials: "include", - headers: { - Authorization: "Bearer admin-coursehub-cc23-golang", - }, }); return await response.json(); } catch (error) { @@ -40,7 +34,6 @@ export const createBR = async (email) => { credentials: "include", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ email }), }); @@ -85,7 +78,6 @@ export const uploadBRs = async (file) => { credentials: "include", headers: { "Content-Type": "application/json", - Authorization: "Bearer admin-coursehub-cc23-golang", }, body: JSON.stringify({ emails }), }); diff --git a/admin/src/components/CoursesWithoutBRTable.jsx b/admin/src/components/CoursesWithoutBRTable.jsx new file mode 100644 index 00000000..3ad15adb --- /dev/null +++ b/admin/src/components/CoursesWithoutBRTable.jsx @@ -0,0 +1,41 @@ +import React from "react"; + +const CoursesWithoutBRTable = ({ courses }) => { + return ( +
+
+

Courses Without BR

+
+ {courses.length === 0 ? ( +
No courses found.
+ ) : ( +
+ + + + + + + + + {courses.map((course) => ( + + + + + ))} + +
+ Code + + Name +
+ {course.code} + {course.name}
+
+ )} +
+ ); +}; + +export default CoursesWithoutBRTable; diff --git a/admin/src/components/coursesWithoutBRTable.jsx b/admin/src/components/coursesWithoutBRTable.jsx deleted file mode 100644 index 83656a2f..00000000 --- a/admin/src/components/coursesWithoutBRTable.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; - -const CoursesWithoutBRTable = ({ courses }) => { - return ( -
-
-

Courses Without BR

-
-
- - - - - - - - - {courses.map((course) => ( - - - - - ))} - -
- Code - - Name -
- {course.code} - {course.name}
-
-
- ); -}; - -export default CoursesWithoutBRTable; diff --git a/admin/src/pages/CoursesWithoutBR.jsx b/admin/src/pages/CoursesWithoutBR.jsx index 3d2225e8..2cfd8b79 100644 --- a/admin/src/pages/CoursesWithoutBR.jsx +++ b/admin/src/pages/CoursesWithoutBR.jsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import CoursesWithoutBRTable from "../components/coursesWithoutBRTable"; +import CoursesWithoutBRTable from "../components/CoursesWithoutBRTable"; import { fetchCoursesWithoutBR } from "@/apis/br"; export default function CoursesWithoutBR() { diff --git a/server/modules/br/br.controller.js b/server/modules/br/br.controller.js index 9f149d82..d4bcbc5a 100644 --- a/server/modules/br/br.controller.js +++ b/server/modules/br/br.controller.js @@ -3,10 +3,18 @@ import User from "../user/user.model.js"; import { fetchCoursesForBr } from "../auth/auth.controller.js"; import logger from "../../utils/logger.js"; import CourseModel from "../course/course.model.js"; -import { normalizeCourseCode } from "../../utils/course.js"; const normalizeEmail = (email) => email?.toString().trim().toLowerCase(); +// Mirrors normalizeCourseCode (utils/course.js) as an aggregation expression: uppercase + strip spaces. +const normalizedCodeExpr = (field) => ({ + $replaceAll: { + input: { $toUpper: { $ifNull: [field, ""] } }, + find: " ", + replacement: "", + }, +}); + const findUserByEmailInsensitive = async (email) => { if (!email) return null; return User.findOne({ email }).collation({ locale: "en", strength: 2 }); @@ -146,22 +154,38 @@ const getBRs = async (req, res) => { }; const getCoursesWithoutBR = async (req, res) => { try { - const brRecords = await BR.find({}); - const brEmails = brRecords.map((br) => br.email.toLowerCase()); - - const brUsers = await User.find({ email: { $in: brEmails } }); - - const coveredCodes = new Set( - brUsers.flatMap((user) => - (user.courses || []).map((c) => normalizeCourseCode(c.code)) - ) - ); - - const allCourses = await CourseModel.find({}); - - const coursesWithoutBR = allCourses.filter( - (course) => !coveredCodes.has(normalizeCourseCode(course.code)) - ); + // Normalized course codes covered by any registered BR, computed in the DB via a $lookup join. + const coveredCodesResult = await BR.aggregate([ + { $addFields: { emailLower: { $toLower: "$email" } } }, + { + $lookup: { + from: User.collection.name, + let: { emailLower: "$emailLower" }, + pipeline: [ + { $match: { $expr: { $eq: [{ $toLower: "$email" }, "$$emailLower"] } } }, + { $project: { courses: 1, _id: 0 } }, + ], + as: "brUser", + }, + }, + { $unwind: "$brUser" }, + { $unwind: { path: "$brUser.courses", preserveNullAndEmptyArrays: false } }, + { + $group: { + _id: null, + codes: { $addToSet: normalizedCodeExpr("$brUser.courses.code") }, + }, + }, + ]); + + const coveredCodes = coveredCodesResult[0]?.codes || []; + + // Courses whose normalized code isn't in the covered set, filtered at the DB level. + const coursesWithoutBR = await CourseModel.aggregate([ + { $addFields: { normalizedCode: normalizedCodeExpr("$code") } }, + { $match: { normalizedCode: { $nin: coveredCodes } } }, + { $project: { normalizedCode: 0 } }, + ]); res.status(200).json({ coursesWithoutBR }); } catch (error) {