From 5749d6e39765c12531a222fb4475d68b8145e1f6 Mon Sep 17 00:00:00 2001 From: Kushagra905 Date: Fri, 26 Jun 2026 13:06:11 +0530 Subject: [PATCH 1/2] feat: add Students list page with search, filter, and BR toggle (closes #144) --- admin/src/App.jsx | 9 + admin/src/apis/student.js | 77 +++ admin/src/components/Sidebar.jsx | 3 +- admin/src/pages/Students.jsx | 474 +++++++++++++++++++ server/index.js | 2 + server/modules/student/student.controller.js | 103 ++++ server/modules/student/student.routes.js | 20 + 7 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 admin/src/apis/student.js create mode 100644 admin/src/pages/Students.jsx create mode 100644 server/modules/student/student.controller.js create mode 100644 server/modules/student/student.routes.js diff --git a/admin/src/App.jsx b/admin/src/App.jsx index 36f9958..a200ec3 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 Students from "./pages/Students"; import Courses from "./pages/Courses"; import CourseLinking from "./pages/CourseLinking"; import PrivateRoute from "./router_utils/PrivateRoutes"; @@ -31,6 +32,14 @@ function App() { } /> + + + + } + /> { + try { + const url = `${API_BASE_URL}api/student/all${brOnly ? "?isBR=true" : ""}`; + const response = await fetch(url, { credentials: "include" }); + return await response.json(); + } catch (error) { + console.error("Error fetching students:", error); + throw error; + } +}; + +// Search students by name or roll number +// Pass brOnly=true to restrict search results to Branch Representatives +export const searchStudents = async (query, brOnly = false) => { + try { + const params = new URLSearchParams({ q: query }); + if (brOnly) params.set("isBR", "true"); + + const response = await fetch( + `${API_BASE_URL}api/student/search?${params.toString()}`, + { credentials: "include" } + ); + return await response.json(); + } catch (error) { + console.error("Error searching students:", error); + throw error; + } +}; + +export const refreshStudentCourses = async (id) => { + try { + const response = await fetch(`${API_BASE_URL}api/student/refresh/${id}`, { + method: "PUT", + credentials: "include", + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || result.message || "Failed to refresh courses"); + return result; + } catch (error) { + console.error("Error refreshing student courses:", error); + throw error; + } +}; + +export const deleteStudent = async (id) => { + try { + const response = await fetch(`${API_BASE_URL}api/student/${id}`, { + method: "DELETE", + credentials: "include", + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || result.message || "Failed to delete student"); + return result; + } catch (error) { + console.error("Error deleting student:", error); + throw error; + } +}; + +export const semesterReset = async () => { + try { + const response = await fetch(`${API_BASE_URL}api/student/semester-reset`, { + method: "POST", + credentials: "include", + }); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || result.message || "Semester reset failed"); + return result; + } catch (error) { + console.error("Error during semester reset:", error); + throw error; + } +}; \ No newline at end of file diff --git a/admin/src/components/Sidebar.jsx b/admin/src/components/Sidebar.jsx index 379fb29..352f03d 100644 --- a/admin/src/components/Sidebar.jsx +++ b/admin/src/components/Sidebar.jsx @@ -1,10 +1,11 @@ import React from "react"; import { Link, useLocation } from "react-router-dom"; -import { FaBook, FaUsers, FaLayerGroup, FaLink } from "react-icons/fa"; +import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate } from "react-icons/fa"; import { adminLogout } from "@/apis/auth"; const navItems = [ { label: "Branch Representatives", to: "/admin/", icon: FaUsers }, + { label: "Students", to: "/admin/students", icon: FaUserGraduate }, { label: "Courses", to: "/admin/courses", icon: FaBook }, { label: "Course Linking", to: "/admin/course-linking", icon: FaLink }, ]; diff --git a/admin/src/pages/Students.jsx b/admin/src/pages/Students.jsx new file mode 100644 index 0000000..816a9fa --- /dev/null +++ b/admin/src/pages/Students.jsx @@ -0,0 +1,474 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { + fetchStudents, + searchStudents, + refreshStudentCourses, + deleteStudent, + semesterReset, +} from "@/apis/student"; +import AddBRs from "../components/AddBRs"; +import { FaRedo, FaSearch, FaSync, FaTrash, FaChevronDown, FaChevronUp, FaPlus, FaUserGraduate, FaUsers } from "react-icons/fa"; +const ConfirmDialog = ({ message, onConfirm, onCancel, loading }) => ( +
+
e.stopPropagation()} + > +

Are you sure?

+

{message}

+
+ + +
+
+
+); + +export default function Students() { + const [students, setStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(null); + + const [searchQuery, setSearchQuery] = useState(""); + const [expandedId, setExpandedId] = useState(null); + const [showBROnly, setShowBROnly] = useState(false); + const [showAddModal, setShowAddModal] = useState(false); + + const [rowLoadingId, setRowLoadingId] = useState(null); + const [rowConfirm, setRowConfirm] = useState(null); + + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [resetLoading, setResetLoading] = useState(false); + const [resetSuccess, setResetSuccess] = useState(null); + const [resetError, setResetError] = useState(null); + +const loadStudents = useCallback(async () => { + try { + setLoading(true); + setError(null); + const response = await fetchStudents(showBROnly); + setStudents(response.students || []); + } catch (err) { + setError(err.message || "An error occurred while fetching students."); + } finally { + setLoading(false); + } + }, [showBROnly]); + + useEffect(() => { + loadStudents(); + }, [loadStudents]); + + useEffect(() => { + if (!searchQuery.trim()) { + loadStudents(); + return; + } + + setSearching(true); + const timer = setTimeout(async () => { + try { + setError(null); + const response = await searchStudents(searchQuery.trim(), showBROnly); + setStudents(response.students || []); + } catch (err) { + setError(err.message || "Search failed."); + } finally { + setSearching(false); + } + }, 500); + + return () => clearTimeout(timer); + }, [searchQuery, showBROnly, loadStudents]); + + const handleToggleExpand = (id) => { + setExpandedId((prev) => (prev === id ? null : id)); + }; + + const handleRowRefresh = async (id) => { + setRowLoadingId(id); + try { + await refreshStudentCourses(id); + loadStudents(); + } catch (err) { + setError(err.message || "Failed to refresh courses."); + } finally { + setRowLoadingId(null); + setRowConfirm(null); + } + }; + + const handleRowDelete = async (id) => { + setRowLoadingId(id); + try { + await deleteStudent(id); + loadStudents(); + } catch (err) { + setError(err.message || "Failed to delete student."); + } finally { + setRowLoadingId(null); + setRowConfirm(null); + } + }; + + const handleSemesterReset = async () => { + setResetLoading(true); + setResetError(null); + setResetSuccess(null); + try { + const result = await semesterReset(); + setResetSuccess(result.message || "Semester reset complete."); + setShowResetConfirm(false); + loadStudents(); + } catch (err) { + setResetError(err.message || "Semester reset failed."); + setShowResetConfirm(false); + } finally { + setResetLoading(false); + } + }; + + const isLoading = loading || searching; + + return ( +
+
+
+
+

Students

+

+ View and manage all registered students +

+
+
+ {showBROnly && ( + + )} + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + className="w-full sm:w-72 pl-9 pr-4 py-2 text-sm border border-gray-200 rounded-xl bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all placeholder:text-gray-400" + /> + {searching && ( +
+ )} +
+ +
+ + +
+
+
+ {resetSuccess && ( +
+ {resetSuccess} + +
+ )} + {resetError && ( +
+ {resetError} + +
+ )} + +
+
+

+ {showBROnly ? "Branch Representatives" : "Student Table"} +

+ {!isLoading && ( + + {students.length} result{students.length !== 1 ? "s" : ""} + + )} +
+ + {isLoading && ( +
+
+ {searching ? "Searching..." : "Loading..."} +
+ )} + + {!isLoading && error && ( +

+ {error} +

+ )} + + {!isLoading && !error && students.length === 0 && ( +
+ {showBROnly ? "No branch representatives found." : "No students found."} +
+ )} + + {!isLoading && !error && students.length > 0 && ( +
+ + + + + + + + + + + + + + {students.map((student) => { + const isExpanded = expandedId === student._id; + const isRowLoading = rowLoadingId === student._id; + + return ( + + + + + + + + + + + + {expandedId === student._id && ( + + + + )} + + ); + })} + +
+ Roll Number + + Name + + Email + + Degree + + Semester + + Actions +
+ {student.rollNumber} + {student.name}{student.email}{student.degree}{student.semester} +
+ + +
+
+ +
+
+
+

+ Department +

+

{student.department}

+
+
+

+ BR Status +

+ + {student.isBR ? "Branch Rep" : "Regular Student"} + +
+
+

+ Courses Enrolled +

+

+ {student.courses?.length || 0} +

+
+
+

+ Student ID +

+

+ {student._id} +

+
+
+ + {student.courses?.length > 0 && ( +
+

+ Current Courses +

+
+ {student.courses.map((course, idx) => ( + + {course.code} + + ))} +
+
+ )} +
+
+ )} +
+ + {rowConfirm && ( + + rowConfirm.type === "delete" + ? handleRowDelete(rowConfirm.id) + : handleRowRefresh(rowConfirm.id) + } + onCancel={() => setRowConfirm(null)} + /> + )} +{showResetConfirm && ( + setShowResetConfirm(false)} + /> + )} + + {showAddModal && ( + setShowAddModal(false)} + /> + )} +
+ ); +} \ No newline at end of file diff --git a/server/index.js b/server/index.js index 003026c..ee4b314 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,7 @@ import eventRoutes from "./modules/event/event.routes.js"; import contributionRoutes from "./modules/contribution/contribution.routes.js"; import adminRoutes from "./modules/admin/admin.routes.js"; import brRoutes from "./modules/br/br.routes.js"; +import studentRoutes from "./modules/student/student.routes.js"; import fileRoutes from "./modules/file/file.routes.js"; import folderRoutes from "./modules/folder/folder.routes.js"; import yearRoutes from "./modules/year/year.routes.js"; @@ -58,6 +59,7 @@ app.use("/api/event", eventRoutes); app.use("/api/contribution", contributionRoutes); app.use("/api/admin", adminRoutes); app.use("/api/br", brRoutes); +app.use("/api/student", studentRoutes); app.use("/api/files", fileRoutes); app.use("/api/folder", folderRoutes); app.use("/api/year", yearRoutes); diff --git a/server/modules/student/student.controller.js b/server/modules/student/student.controller.js new file mode 100644 index 0000000..e689922 --- /dev/null +++ b/server/modules/student/student.controller.js @@ -0,0 +1,103 @@ +import User from "../user/user.model.js"; +import { fetchCoursesForBr } from "../auth/auth.controller.js"; +import logger from "../../utils/logger.js"; + +// GET /api/student/all?isBR=true +// Returns every student, sorted by rollNumber descending. +// If isBR=true is passed, only returns students who are Branch Representatives. +const getAllStudents = async (req, res) => { + try { + const { isBR } = req.query; + const filter = isBR === "true" ? { isBR: true } : {}; + + const students = await User.find(filter).sort({ rollNumber: -1 }); + res.status(200).json({ students }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// GET /api/student/search?q=...&isBR=true +// Searches students by name or rollNumber using a MongoDB $or regex query. +// If isBR=true is passed, results are additionally restricted to Branch Representatives. +// Results are still sorted by rollNumber descending. +const searchStudents = async (req, res) => { + try { + const { q = "", isBR } = req.query; + const brFilter = isBR === "true" ? { isBR: true } : {}; + + if (!q.trim()) { + const students = await User.find(brFilter).sort({ rollNumber: -1 }); + return res.status(200).json({ students }); + } + + const searchRegex = new RegExp(q, "i"); + const orConditions = [{ name: { $regex: searchRegex } }]; + + // rollNumber is stored as a Number, so only add a numeric match + // if the query looks like a number (partial roll number searches + // like "2210" are matched via regex against the stringified field + // using $expr, since Mongo can't $regex a Number field directly). + orConditions.push({ + $expr: { + $regexMatch: { + input: { $toString: "$rollNumber" }, + regex: q, + options: "i", + }, + }, + }); + + const students = await User.find({ ...brFilter, $or: orConditions }).sort({ rollNumber: -1 }); + res.status(200).json({ students }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// PUT /api/student/refresh/:id +const refreshStudentCourses = async (req, res) => { + try { + const { id } = req.params; + const student = await User.findById(id); + if (!student) return res.status(404).json({ error: "Student not found" }); + + await fetchCoursesForBr(student.rollNumber); + res.status(200).json({ message: "Courses refreshed successfully" }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// DELETE /api/student/:id +const deleteStudent = async (req, res) => { + try { + const { id } = req.params; + const student = await User.findByIdAndDelete(id); + if (!student) return res.status(404).json({ error: "Student not found" }); + + res.status(200).json({ message: "Student deleted successfully" }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +// POST /api/student/semester-reset +const semesterReset = async (req, res) => { + try { + const result = await User.updateMany({}, { $set: { courses: [] } }); + res.status(200).json({ + message: "Semester reset successful. All student courses cleared.", + modifiedCount: result.modifiedCount, + }); + } catch (error) { + logger.error(error); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +export { getAllStudents, searchStudents, refreshStudentCourses, deleteStudent, semesterReset }; \ No newline at end of file diff --git a/server/modules/student/student.routes.js b/server/modules/student/student.routes.js new file mode 100644 index 0000000..eec2e53 --- /dev/null +++ b/server/modules/student/student.routes.js @@ -0,0 +1,20 @@ +import express from "express"; +import catchAsync from "../../utils/catchAsync.js"; +import isAdmin from "../../middleware/isAdmin.js"; +import { + getAllStudents, + searchStudents, + refreshStudentCourses, + deleteStudent, + semesterReset, +} from "./student.controller.js"; + +const router = express.Router(); + +router.get("/all", isAdmin, catchAsync(getAllStudents)); +router.get("/search", isAdmin, catchAsync(searchStudents)); +router.put("/refresh/:id", isAdmin, catchAsync(refreshStudentCourses)); +router.post("/semester-reset", isAdmin, catchAsync(semesterReset)); +router.delete("/:id", isAdmin, catchAsync(deleteStudent)); + +export default router; \ No newline at end of file From 3a08f98c2e235b3caa84792ce5ed3c7b155f6b9b Mon Sep 17 00:00:00 2001 From: Kushagra905 Date: Fri, 3 Jul 2026 14:37:26 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?add=20missing=20files,=20fix=20refresh=20and=20semester=20reset?= =?UTF-8?q?=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/App.jsx | 12 +- admin/src/apis/student.js | 15 +- admin/src/components/AddBRs.jsx | 174 +++++++++++++++++++ admin/src/pages/Students.jsx | 157 +++++------------ server/index.js | 5 +- server/modules/student/student.controller.js | 58 ++++--- server/modules/student/student.routes.js | 2 +- 7 files changed, 267 insertions(+), 156 deletions(-) create mode 100644 admin/src/components/AddBRs.jsx diff --git a/admin/src/App.jsx b/admin/src/App.jsx index a200ec3..3c594a3 100644 --- a/admin/src/App.jsx +++ b/admin/src/App.jsx @@ -25,20 +25,20 @@ function App() { } /> - + } /> - + - } + } /> { try { const url = `${API_BASE_URL}api/student/all${brOnly ? "?isBR=true" : ""}`; @@ -13,13 +13,12 @@ export const fetchStudents = async (brOnly = false) => { } }; -// Search students by name or roll number -// Pass brOnly=true to restrict search results to Branch Representatives +// Search students by name or roll number. +// Pass brOnly=true to restrict results to Branch Representatives. export const searchStudents = async (query, brOnly = false) => { try { const params = new URLSearchParams({ q: query }); if (brOnly) params.set("isBR", "true"); - const response = await fetch( `${API_BASE_URL}api/student/search?${params.toString()}`, { credentials: "include" } @@ -31,6 +30,8 @@ export const searchStudents = async (query, brOnly = false) => { } }; +// Refresh a single student's courses by deleting their UserUpdate record. +// Courses will be re-fetched when the student next logs in. export const refreshStudentCourses = async (id) => { try { const response = await fetch(`${API_BASE_URL}api/student/refresh/${id}`, { @@ -46,6 +47,7 @@ export const refreshStudentCourses = async (id) => { } }; +// Delete a single student permanently. export const deleteStudent = async (id) => { try { const response = await fetch(`${API_BASE_URL}api/student/${id}`, { @@ -61,6 +63,7 @@ export const deleteStudent = async (id) => { } }; +// Semester reset — deletes all UserUpdate records and clears courses for every student. export const semesterReset = async () => { try { const response = await fetch(`${API_BASE_URL}api/student/semester-reset`, { @@ -74,4 +77,4 @@ export const semesterReset = async () => { console.error("Error during semester reset:", error); throw error; } -}; \ No newline at end of file +}; diff --git a/admin/src/components/AddBRs.jsx b/admin/src/components/AddBRs.jsx new file mode 100644 index 0000000..246f2ff --- /dev/null +++ b/admin/src/components/AddBRs.jsx @@ -0,0 +1,174 @@ +import React, { useState } from "react"; +import { Modal, ModalHeader, ModalBody, ModalCloseButton } from "./ui/modal"; +import { uploadBRs, createBR } from "../apis/br"; +import { FaUser, FaFileCsv } from "react-icons/fa"; + +const AddBRs = ({ onSuccess, onClose }) => { + const [tab, setTab] = useState("single"); + + const [email, setEmail] = useState(""); + const [singleLoading, setSingleLoading] = useState(false); + const [singleError, setSingleError] = useState(null); + const [singleSuccess, setSingleSuccess] = useState(null); + + const [file, setFile] = useState(null); + const [bulkLoading, setBulkLoading] = useState(false); + const [bulkError, setBulkError] = useState(null); + const [bulkSuccess, setBulkSuccess] = useState(null); + const [bulkWarnings, setBulkWarnings] = useState(null); + + const handleSingleSubmit = async (e) => { + e.preventDefault(); + setSingleError(null); + setSingleSuccess(null); + if (!email.trim()) { setSingleError("Please enter an email address."); return; } + if (!email.includes("@")) { setSingleError("Please enter a valid email address."); return; } + setSingleLoading(true); + try { + await createBR(email.trim()); + setSingleSuccess(`Successfully added ${email.trim()} as a BR.`); + setEmail(""); + if (onSuccess) onSuccess(); + } catch (err) { + setSingleError(err.message || "Failed to add BR."); + } finally { + setSingleLoading(false); + } + }; + + const handleBulkSubmit = async (e) => { + e.preventDefault(); + setBulkError(null); + setBulkSuccess(null); + setBulkWarnings(null); + if (!file) { setBulkError("Please select a CSV file."); return; } + if (!file.name.toLowerCase().endsWith(".csv")) { setBulkError("Please upload a CSV file."); return; } + setBulkLoading(true); + try { + const response = await uploadBRs(file); + setBulkSuccess(response.message); + setFile(null); + if (onSuccess) onSuccess(); + } catch (err) { + if (err.existingEmails || err.notInUsers) { + let warning = ""; + if (err.existingEmails?.length > 0) + warning += `Already existing BRs: ${err.existingEmails.join(", ")}. `; + if (err.notInUsers?.length > 0) + warning += `Not found in users: ${err.notInUsers.join(", ")}.`; + setBulkWarnings(warning); + setBulkSuccess("Partial upload completed."); + if (onSuccess) onSuccess(); + } else { + setBulkError(err.error || err.message || "An error occurred during upload."); + } + } finally { + setBulkLoading(false); + } + }; + + return ( + + + + Add Branch Representatives + + +
+ + +
+ + + {tab === "single" && ( +
+

+ Add a single user as a Branch Representative using their registered email. +

+
+
+ + { setEmail(e.target.value); setSingleError(null); setSingleSuccess(null); }} + disabled={singleLoading} + className="w-full border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent transition-all disabled:opacity-50" + /> +
+ {singleError &&

{singleError}

} + {singleSuccess &&

{singleSuccess}

} + +
+
+ )} + + {tab === "bulk" && ( +
+

+ Upload a CSV file with one column named{" "} + email{" "} + to add multiple BRs at once. +

+
+
+ + { setFile(e.target.files[0]); setBulkError(null); setBulkSuccess(null); setBulkWarnings(null); }} + disabled={bulkLoading} + className="block w-full text-sm text-gray-500 border border-gray-200 rounded-md cursor-pointer bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-400 file:mr-4 file:py-2 file:px-3 file:border-0 file:border-r file:border-gray-200 file:text-sm file:font-medium file:bg-white file:text-blue-700 hover:file:bg-blue-50 transition-all disabled:opacity-50" + /> + {file &&

Selected: {file.name}

} +
+ {bulkError &&

{bulkError}

} + {bulkSuccess &&

{bulkSuccess}

} + {bulkWarnings &&

Warning: {bulkWarnings}

} + +
+
+ )} +
+
+ ); +}; + +export default AddBRs; diff --git a/admin/src/pages/Students.jsx b/admin/src/pages/Students.jsx index 816a9fa..f4f4814 100644 --- a/admin/src/pages/Students.jsx +++ b/admin/src/pages/Students.jsx @@ -8,6 +8,7 @@ import { } from "@/apis/student"; import AddBRs from "../components/AddBRs"; import { FaRedo, FaSearch, FaSync, FaTrash, FaChevronDown, FaChevronUp, FaPlus, FaUserGraduate, FaUsers } from "react-icons/fa"; + const ConfirmDialog = ({ message, onConfirm, onCancel, loading }) => (
( disabled={loading} className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-all disabled:opacity-50 flex items-center gap-2" > - {loading && ( -
- )} + {loading &&
} {loading ? "Processing..." : "Confirm"}
@@ -48,7 +47,7 @@ export default function Students() { const [searching, setSearching] = useState(false); const [error, setError] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); const [expandedId, setExpandedId] = useState(null); const [showBROnly, setShowBROnly] = useState(false); const [showAddModal, setShowAddModal] = useState(false); @@ -61,7 +60,7 @@ export default function Students() { const [resetSuccess, setResetSuccess] = useState(null); const [resetError, setResetError] = useState(null); -const loadStudents = useCallback(async () => { + const loadStudents = useCallback(async () => { try { setLoading(true); setError(null); @@ -83,7 +82,6 @@ const loadStudents = useCallback(async () => { loadStudents(); return; } - setSearching(true); const timer = setTimeout(async () => { try { @@ -96,13 +94,10 @@ const loadStudents = useCallback(async () => { setSearching(false); } }, 500); - return () => clearTimeout(timer); }, [searchQuery, showBROnly, loadStudents]); - const handleToggleExpand = (id) => { - setExpandedId((prev) => (prev === id ? null : id)); - }; + const handleToggleExpand = (id) => setExpandedId((prev) => (prev === id ? null : id)); const handleRowRefresh = async (id) => { setRowLoadingId(id); @@ -151,13 +146,12 @@ const loadStudents = useCallback(async () => { return (
+ {/* Header */}

Students

-

- View and manage all registered students -

+

View and manage all registered students

{showBROnly && ( @@ -194,6 +188,7 @@ const loadStudents = useCallback(async () => { )}
+ {/* All Students / BRs Only toggle */}
+ + {/* Status banners */} {resetSuccess && (
{resetSuccess} - +
)} {resetError && (
{resetError} - +
)} + {/* Table card */}

@@ -255,13 +249,9 @@ const loadStudents = useCallback(async () => { {searching ? "Searching..." : "Loading..."}

)} - {!isLoading && error && ( -

- {error} -

+

{error}

)} - {!isLoading && !error && students.length === 0 && (
{showBROnly ? "No branch representatives found." : "No students found."} @@ -273,24 +263,12 @@ const loadStudents = useCallback(async () => { - - - - - - + + + + + + @@ -298,17 +276,10 @@ const loadStudents = useCallback(async () => { {students.map((student) => { const isExpanded = expandedId === student._id; const isRowLoading = rowLoadingId === student._id; - return ( - - + + @@ -316,15 +287,9 @@ const loadStudents = useCallback(async () => { @@ -372,54 +327,30 @@ const loadStudents = useCallback(async () => {
- Roll Number - - Name - - Email - - Degree - - Semester - - Actions - Roll NumberNameEmailDegreeSemesterActions
- {student.rollNumber} -
{student.rollNumber} {student.name} {student.email} {student.degree}
-

- Department -

+

Department

{student.department}

-

- BR Status -

- +

BR Status

+ {student.isBR ? "Branch Rep" : "Regular Student"}
-

- Courses Enrolled -

-

- {student.courses?.length || 0} -

+

Courses Enrolled

+

{student.courses?.length || 0}

-

- Student ID -

-

- {student._id} -

+

Student ID

+

{student._id}

- {student.courses?.length > 0 && (
-

- Current Courses -

+

Current Courses

{student.courses.map((course, idx) => ( - + {course.code} ))} @@ -443,20 +374,17 @@ const loadStudents = useCallback(async () => { message={ rowConfirm.type === "delete" ? `Delete ${rowConfirm.label}? This will permanently remove their student record and cannot be undone.` - : `Refresh courses for ${rowConfirm.label}?` + : `Reset courses for ${rowConfirm.label}? Their courses will be re-fetched the next time they log in.` } loading={rowLoadingId === rowConfirm.id} - onConfirm={() => - rowConfirm.type === "delete" - ? handleRowDelete(rowConfirm.id) - : handleRowRefresh(rowConfirm.id) - } + onConfirm={() => rowConfirm.type === "delete" ? handleRowDelete(rowConfirm.id) : handleRowRefresh(rowConfirm.id)} onCancel={() => setRowConfirm(null)} /> )} -{showResetConfirm && ( + + {showResetConfirm && ( setShowResetConfirm(false)} @@ -464,10 +392,7 @@ const loadStudents = useCallback(async () => { )} {showAddModal && ( - setShowAddModal(false)} - /> + setShowAddModal(false)} /> )}
); diff --git a/server/index.js b/server/index.js index ee4b314..6c78f65 100644 --- a/server/index.js +++ b/server/index.js @@ -22,10 +22,10 @@ import eventRoutes from "./modules/event/event.routes.js"; import contributionRoutes from "./modules/contribution/contribution.routes.js"; import adminRoutes from "./modules/admin/admin.routes.js"; import brRoutes from "./modules/br/br.routes.js"; -import studentRoutes from "./modules/student/student.routes.js"; import fileRoutes from "./modules/file/file.routes.js"; import folderRoutes from "./modules/folder/folder.routes.js"; import yearRoutes from "./modules/year/year.routes.js"; +import studentRoutes from "./modules/student/student.routes.js"; const app = express(); @@ -59,10 +59,10 @@ app.use("/api/event", eventRoutes); app.use("/api/contribution", contributionRoutes); app.use("/api/admin", adminRoutes); app.use("/api/br", brRoutes); -app.use("/api/student", studentRoutes); app.use("/api/files", fileRoutes); app.use("/api/folder", folderRoutes); app.use("/api/year", yearRoutes); +app.use("/api/student", studentRoutes); app.use( "/homepage", @@ -104,7 +104,6 @@ app.use((err, req, res, next) => { }); }); -// Set static folder app.use(express.static("static")); app.get("*", (req, res) => { diff --git a/server/modules/student/student.controller.js b/server/modules/student/student.controller.js index e689922..5d5a837 100644 --- a/server/modules/student/student.controller.js +++ b/server/modules/student/student.controller.js @@ -1,15 +1,14 @@ import User from "../user/user.model.js"; -import { fetchCoursesForBr } from "../auth/auth.controller.js"; +import UserUpdate from "../user/userUpdate.model.js"; import logger from "../../utils/logger.js"; // GET /api/student/all?isBR=true -// Returns every student, sorted by rollNumber descending. -// If isBR=true is passed, only returns students who are Branch Representatives. +// Returns every student sorted by rollNumber descending. +// Pass isBR=true to only return Branch Representatives. const getAllStudents = async (req, res) => { try { const { isBR } = req.query; const filter = isBR === "true" ? { isBR: true } : {}; - const students = await User.find(filter).sort({ rollNumber: -1 }); res.status(200).json({ students }); } catch (error) { @@ -19,9 +18,8 @@ const getAllStudents = async (req, res) => { }; // GET /api/student/search?q=...&isBR=true -// Searches students by name or rollNumber using a MongoDB $or regex query. -// If isBR=true is passed, results are additionally restricted to Branch Representatives. -// Results are still sorted by rollNumber descending. +// Searches by name or rollNumber using $or regex. +// Pass isBR=true to restrict results to Branch Representatives. const searchStudents = async (req, res) => { try { const { q = "", isBR } = req.query; @@ -33,21 +31,18 @@ const searchStudents = async (req, res) => { } const searchRegex = new RegExp(q, "i"); - const orConditions = [{ name: { $regex: searchRegex } }]; - - // rollNumber is stored as a Number, so only add a numeric match - // if the query looks like a number (partial roll number searches - // like "2210" are matched via regex against the stringified field - // using $expr, since Mongo can't $regex a Number field directly). - orConditions.push({ - $expr: { - $regexMatch: { - input: { $toString: "$rollNumber" }, - regex: q, - options: "i", + const orConditions = [ + { name: { $regex: searchRegex } }, + { + $expr: { + $regexMatch: { + input: { $toString: "$rollNumber" }, + regex: q, + options: "i", + }, }, }, - }); + ]; const students = await User.find({ ...brFilter, $or: orConditions }).sort({ rollNumber: -1 }); res.status(200).json({ students }); @@ -58,14 +53,21 @@ const searchStudents = async (req, res) => { }; // PUT /api/student/refresh/:id +// Deletes the student's UserUpdate record so their courses are safely +// re-fetched from the academic portal on their next login. +// (Per issue #144 — directly calling fetchCoursesForBr would block the +// server for 30-40s and only update previousCourses, not current courses.) const refreshStudentCourses = async (req, res) => { try { const { id } = req.params; const student = await User.findById(id); if (!student) return res.status(404).json({ error: "Student not found" }); - await fetchCoursesForBr(student.rollNumber); - res.status(200).json({ message: "Courses refreshed successfully" }); + await UserUpdate.deleteOne({ rollNumber: student.rollNumber }); + + res.status(200).json({ + message: "Student update record reset successfully. Courses will be re-fetched on next login.", + }); } catch (error) { logger.error(error); res.status(500).json({ error: "Internal Server Error" }); @@ -73,6 +75,7 @@ const refreshStudentCourses = async (req, res) => { }; // DELETE /api/student/:id +// Permanently deletes a student document. const deleteStudent = async (req, res) => { try { const { id } = req.params; @@ -87,11 +90,18 @@ const deleteStudent = async (req, res) => { }; // POST /api/student/semester-reset +// Deletes all UserUpdate tracking records (forcing re-fetch on next login) +// and atomically clears the courses array on every User document. const semesterReset = async (req, res) => { try { + // Delete all user update tracking logs so the SSO scraper runs again + await UserUpdate.deleteMany({}); + + // Clear current course associations const result = await User.updateMany({}, { $set: { courses: [] } }); + res.status(200).json({ - message: "Semester reset successful. All student courses cleared.", + message: "Semester reset successful. All student updates cleared and course lists reset.", modifiedCount: result.modifiedCount, }); } catch (error) { @@ -100,4 +110,4 @@ const semesterReset = async (req, res) => { } }; -export { getAllStudents, searchStudents, refreshStudentCourses, deleteStudent, semesterReset }; \ No newline at end of file +export { getAllStudents, searchStudents, refreshStudentCourses, deleteStudent, semesterReset }; diff --git a/server/modules/student/student.routes.js b/server/modules/student/student.routes.js index eec2e53..456b973 100644 --- a/server/modules/student/student.routes.js +++ b/server/modules/student/student.routes.js @@ -17,4 +17,4 @@ router.put("/refresh/:id", isAdmin, catchAsync(refreshStudentCourses)); router.post("/semester-reset", isAdmin, catchAsync(semesterReset)); router.delete("/:id", isAdmin, catchAsync(deleteStudent)); -export default router; \ No newline at end of file +export default router \ No newline at end of file