diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..99ba1929 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Dependencies +node_modules/ +**/node_modules/ + +# Logs +*.log + +# Environment +.env +.env.* + +# Build files +dist/ +build/ +coverage/ + +# OS files +.DS_Store +Thumbs.db + +# Editor +.vscode/ +.idea/ \ No newline at end of file diff --git a/Peer_Evaluation_V3_NPTEL/.gitignore b/Peer_Evaluation_V3_NPTEL/.gitignore index 3b51f7f4..c3911254 100644 --- a/Peer_Evaluation_V3_NPTEL/.gitignore +++ b/Peer_Evaluation_V3_NPTEL/.gitignore @@ -1,47 +1,24 @@ +node_modules/ + # Logs -logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* -pnpm-debug.log* -lerna-debug.log* -# Dependency directories -node_modules -/node_modules -/.pnp -.pnp.js +# Environment files +.env +.env.* -# Build output -dist -dist-ssr -/build -/coverage +# Build folders +dist/ +build/ +coverage/ -# Miscellaneous system files +# OS files .DS_Store +Thumbs.db -# Local environment files -.env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -**/node_modules -**/*.json -**/.env -**/vite.config.ts -Peer_Evaluation_V3_NPTEL/backend/package-lock.json -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. \ No newline at end of file +# Editor files +.vscode/ +.idea/ \ No newline at end of file diff --git a/Peer_Evaluation_V3_NPTEL/backend/package-lock.json b/Peer_Evaluation_V3_NPTEL/backend/package-lock.json index b1eac275..5ff47f3a 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/package-lock.json +++ b/Peer_Evaluation_V3_NPTEL/backend/package-lock.json @@ -23,7 +23,7 @@ "mongoose": "^8.15.1", "multer": "^2.0.1", "nanoid": "^5.1.5", - "node-cron": "^4.1.0", + "node-cron": "^4.2.1", "nodemailer": "^7.0.3", "nodemon": "^3.1.10", "npm": "^11.4.2", @@ -44,6 +44,7 @@ "@types/jsonwebtoken": "^9.0.9", "@types/multer": "^1.4.13", "@types/node": "^22.15.30", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", "@types/pdf-parse": "^1.1.5", "@types/pdfkit": "^0.14.0", @@ -1189,6 +1190,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/nodemailer": { "version": "6.4.17", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", @@ -3881,9 +3889,9 @@ } }, "node_modules/node-cron": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.1.0.tgz", - "integrity": "sha512-OS+3ORu+h03/haS6Di8Qr7CrVs4YaKZZOynZwQpyPZDnR3tqRbwJmuP2gVR16JfhLgyNlloAV1VTrrWlRogCFA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", "license": "ISC", "engines": { "node": ">=6.0.0" diff --git a/Peer_Evaluation_V3_NPTEL/backend/package.json b/Peer_Evaluation_V3_NPTEL/backend/package.json index 0b9eabcf..cc5b7d7c 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/package.json +++ b/Peer_Evaluation_V3_NPTEL/backend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "dev": "nodemon --exec node --loader ts-node/esm src/server.ts" + "dev": "nodemon --exec node --loader ts-node/esm src/server.ts", + "reset:dashboard-seed": "node --loader ts-node/esm src/scripts/resetDashboardSeed.ts" }, "keywords": [], "author": "", @@ -26,7 +27,7 @@ "mongoose": "^8.15.1", "multer": "^2.0.1", "nanoid": "^5.1.5", - "node-cron": "^4.1.0", + "node-cron": "^4.2.1", "nodemailer": "^7.0.3", "nodemon": "^3.1.10", "npm": "^11.4.2", @@ -47,6 +48,7 @@ "@types/jsonwebtoken": "^9.0.9", "@types/multer": "^1.4.13", "@types/node": "^22.15.30", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", "@types/pdf-parse": "^1.1.5", "@types/pdfkit": "^0.14.0", diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/admin/evaluations.controller.ts b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/admin/evaluations.controller.ts new file mode 100644 index 00000000..835bf5e7 --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/admin/evaluations.controller.ts @@ -0,0 +1,228 @@ +import { Request, Response } from 'express'; +import { Evaluation } from '../../models/Evaluation.ts'; +import { + ESCALATION_REASSIGNMENT_THRESHOLD, + EvaluationService, +} from '../../services/EvaluationService.ts'; +import { Types } from 'mongoose'; +import { Submission } from '../../models/Submission.ts'; +import { clearDashboardSeedData, seedDatabase } from '../../seed/seedData.ts'; + +const getEntityId = (value: any) => { + if (!value) return null; + if (typeof value === 'string') return value; + if (value._id) return value._id.toString(); + return value.toString(); +}; + +const isDashboardDemoUser = (user: any) => { + const email = typeof user?.email === 'string' ? user.email : ''; + return email.endsWith('.dashboard@example.com'); +}; + +export const getEvaluationsOverview = async (req: Request, res: Response): Promise => { + try { + const now = new Date(); + const total = await Evaluation.countDocuments(); + const pending = await Evaluation.countDocuments({ status: 'pending' }); + const overdue = await Evaluation.countDocuments({ + status: { $in: ['pending', 'overdue'] }, + deadline: { $lt: now } + }); + const escalated = await Evaluation.countDocuments({ status: 'escalated' }); + const reassignedCount = await Evaluation.countDocuments({ reassignmentCount: { $gt: 0 } }); + + const evaluations = await Evaluation.find() + .populate('evaluator', 'name email') + .populate('evaluatee', 'name email') + .populate('exam', 'title') + .sort({ deadline: 1 }); + + const submissionKeys = evaluations + .map((evaluation) => ({ + exam: getEntityId(evaluation.exam), + student: getEntityId(evaluation.evaluatee), + })) + .filter((item): item is { exam: string; student: string } => Boolean(item.exam && item.student)); + + const submissions = submissionKeys.length + ? await Submission.find({ + $or: submissionKeys.map(({ exam, student }) => ({ + exam, + student, + })), + }).select('_id exam student') + : []; + + const submissionLookup = new Map( + submissions.map((submission) => [ + `${submission.exam.toString()}::${submission.student.toString()}`, + String(submission._id), + ]) + ); + + const evaluationRows = evaluations.map((evaluation) => { + const examId = getEntityId(evaluation.exam); + const evaluateeId = getEntityId(evaluation.evaluatee); + const submissionId = + examId && evaluateeId ? submissionLookup.get(`${examId}::${evaluateeId}`) ?? null : null; + const isOverdue = + ['pending', 'overdue'].includes(evaluation.status) && + new Date(evaluation.deadline) < now; + const isFrequentlyReassigned = evaluation.reassignmentCount >= ESCALATION_REASSIGNMENT_THRESHOLD; + + return { + _id: evaluation._id, + submissionId, + assignedStudent: evaluation.evaluator, + evaluatee: evaluation.evaluatee, + exam: evaluation.exam, + deadline: evaluation.deadline, + status: evaluation.status, + reassignmentCount: evaluation.reassignmentCount, + isOverdue, + isFrequentlyReassigned, + isDashboardDemo: + isDashboardDemoUser(evaluation.evaluator) || isDashboardDemoUser(evaluation.evaluatee), + }; + }); + + res.json({ + statistics: { + total, + pending, + overdue, + escalated, + reassignedCount + }, + evaluations: evaluationRows + }); + } catch (error) { + console.error('Error in getEvaluationsOverview:', error); + res.status(500).json({ message: 'Internal server error' }); + } +}; + +export const manualReassign = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + // Check if evaluation exists + const evaluation = await Evaluation.findById(id); + if (!evaluation) { + res.status(404).json({ message: 'Evaluation not found' }); + return; + } + + // Use existing auto-reassignment logic + const reassignedEvaluation = await EvaluationService.reassignTask( + evaluation._id as unknown as Types.ObjectId + ); + + if (!reassignedEvaluation) { + res.status(400).json({ message: 'Evaluation could not be reassigned' }); + return; + } + + res.json({ message: 'Evaluation reassigned successfully', evaluation: reassignedEvaluation }); + } catch (error) { + console.error('Error in manualReassign:', error); + res.status(500).json({ message: 'Failed to reassign evaluation' }); + } +}; + +export const updateEvaluationDeadline = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const { deadline } = req.body; + + const parsedDeadline = deadline ? new Date(deadline) : null; + if (!parsedDeadline || Number.isNaN(parsedDeadline.getTime())) { + res.status(400).json({ message: 'A valid deadline is required.' }); + return; + } + + const evaluation = await Evaluation.findById(id); + if (!evaluation) { + res.status(404).json({ message: 'Evaluation not found' }); + return; + } + + evaluation.deadline = parsedDeadline; + + if (['pending', 'overdue'].includes(evaluation.status)) { + evaluation.status = parsedDeadline <= new Date() ? 'overdue' : 'pending'; + } + + await evaluation.save(); + + res.json({ message: 'Evaluation deadline updated successfully', evaluation }); + } catch (error) { + console.error('Error in updateEvaluationDeadline:', error); + res.status(500).json({ message: 'Failed to update evaluation deadline' }); + } +}; + +export const overrideReassignTask = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + const evaluation = await Evaluation.findById(id); + if (!evaluation) { + res.status(404).json({ message: 'Evaluation not found' }); + return; + } + + const reassignedEvaluation = await EvaluationService.reassignTask( + evaluation._id as unknown as Types.ObjectId, + { override: true } + ); + + if (!reassignedEvaluation) { + res.status(400).json({ message: 'Override reassignment failed. No eligible student found.' }); + return; + } + + res.json({ + message: 'Evaluation override reassigned successfully', + evaluation: reassignedEvaluation, + }); + } catch (error) { + console.error('Error in overrideReassignTask:', error); + res.status(500).json({ message: 'Failed to override reassign evaluation' }); + } +}; + +export const escalateTask = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + const evaluation = await Evaluation.findByIdAndUpdate( + id, + { status: 'escalated' }, + { new: true } + ); + + if (!evaluation) { + res.status(404).json({ message: 'Evaluation not found' }); + return; + } + + res.json({ message: 'Evaluation escalated successfully', evaluation }); + } catch (error) { + console.error('Error in escalateTask:', error); + res.status(500).json({ message: 'Failed to escalate evaluation' }); + } +}; + +export const resetDashboardDemoData = async (_req: Request, res: Response): Promise => { + try { + await clearDashboardSeedData(); + await seedDatabase(); + + res.json({ message: 'Dashboard demo data reset successfully.' }); + } catch (error) { + console.error('Error in resetDashboardDemoData:', error); + res.status(500).json({ message: 'Failed to reset dashboard demo data' }); + } +}; diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/shared/notification.controller.ts b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/shared/notification.controller.ts new file mode 100644 index 00000000..2511f0c4 --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/shared/notification.controller.ts @@ -0,0 +1,157 @@ +import { Response, NextFunction } from "express"; +import AuthenticatedRequest from "../../middlewares/authMiddleware.ts"; +import { Notification } from "../../models/Notification.ts"; +import { Evaluation } from "../../models/Evaluation.ts"; + +export const getNotifications = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.user?._id; + if (!userId) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + const notifications = await Notification.find({ + recipient: userId, + status: { $ne: "dismissed" }, + }).sort({ createdAt: -1 }); + + const taskNotificationIds = notifications + .filter( + (notification) => + notification.relatedResource?.type === "evaluation" && + notification.message.startsWith("Urgent: An evaluation task has been reassigned") + ) + .map((notification) => notification.relatedResource?.id) + .filter(Boolean); + + const evaluations = taskNotificationIds.length + ? await Evaluation.find({ _id: { $in: taskNotificationIds } }).select("_id evaluator status") + : []; + + const evaluationMap = new Map( + evaluations.map((evaluation) => [String(evaluation._id), evaluation]) + ); + + const visibleNotifications = notifications.filter((notification) => { + const relatedEvaluationId = notification.relatedResource?.id; + + if ( + notification.relatedResource?.type === "evaluation" && + notification.message.startsWith("Urgent: An evaluation task has been reassigned") && + relatedEvaluationId + ) { + const evaluation = evaluationMap.get(String(relatedEvaluationId)); + if (!evaluation) return false; + + const isStillAssignedToUser = String(evaluation.evaluator) === String(userId); + return evaluation.status === "pending" && isStillAssignedToUser; + } + + return true; + }); + + res.json({ + notifications: visibleNotifications.map((notification) => ({ + _id: notification._id, + recipient: notification.recipient, + message: notification.message, + relatedResource: notification.relatedResource, + status: notification.status ?? (notification.read ? "read" : "unread"), + read: notification.read || notification.status === "read", + readAt: notification.readAt ?? null, + dismissedAt: notification.dismissedAt ?? null, + createdAt: notification.createdAt, + })), + }); + } catch (err) { + next(err); + } +}; + +export const markNotificationsRead = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.user?._id; + if (!userId) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + const ids = Array.isArray(req.body?.ids) + ? req.body.ids + : req.body?.id + ? [req.body.id] + : []; + + if (!ids.length) { + res.status(400).json({ error: "Notification id is required" }); + return; + } + + await Notification.updateMany( + { _id: { $in: ids }, recipient: userId }, + { + $set: { + read: true, + status: "read", + readAt: new Date(), + }, + } + ); + + res.json({ message: "Notifications marked as read" }); + } catch (err) { + next(err); + } +}; + +export const dismissNotifications = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) => { + try { + const userId = req.user?._id; + if (!userId) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + const ids = Array.isArray(req.body?.ids) + ? req.body.ids + : req.body?.id + ? [req.body.id] + : []; + + if (!ids.length) { + res.status(400).json({ error: "Notification id is required" }); + return; + } + + await Notification.updateMany( + { + _id: { $in: ids }, + recipient: userId, + $or: [{ status: { $in: ["read", "dismissed"] } }, { read: true }], + }, + { + $set: { + status: "dismissed", + dismissedAt: new Date(), + }, + } + ); + + res.json({ message: "Notifications dismissed" }); + } catch (err) { + next(err); + } +}; diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/getPendingEvaluations.controller.ts b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/getPendingEvaluations.controller.ts index 7695a0b3..28da8e35 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/getPendingEvaluations.controller.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/getPendingEvaluations.controller.ts @@ -15,7 +15,7 @@ export const getPendingEvaluations = async ( const pending = await Evaluation.find({ evaluator: studentId, - status: "pending", + status: { $in: ["pending", "overdue"] }, }) .populate({ path: "exam", @@ -35,18 +35,16 @@ export const getPendingEvaluations = async ( answerKeyMimeType?: string; }; - if ( - !exam || - !ev.evaluatee || - typeof ev.evaluatee !== "object" || - !("_id" in ev.evaluatee) - ) { + if (!exam || !ev.evaluatee) { return null; } + // Get submission for the target evaluatee + const evaluateeId = ev.evaluatee._id || ev.evaluatee; + const submission = await Submission.findOne({ exam: exam._id, - student: ev.evaluatee._id, + student: evaluateeId, }); return { @@ -66,6 +64,10 @@ export const getPendingEvaluations = async ( exam.answerKeyPdf && exam.answerKeyMimeType ? `http://localhost:${PORT}/api/student/answer-key/${exam._id}` : null, + deadline: ev.deadline, + status: ev.status, + reassignmentCount: ev.reassignmentCount, + reassigned: ev.reassignmentCount > 0, }; }) ); diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/student.controller.ts b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/student.controller.ts index 96b12a9b..1e8178a1 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/student.controller.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/student.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { Batch } from '../../models/Batch.ts'; // Make sure the path is correct +import { Evaluation } from '../../models/Evaluation.ts'; interface AuthRequest extends Request { user?: any; @@ -16,6 +17,10 @@ export const getStudentProfile = async (req: AuthRequest, res: Response): Promis // Check if the student is a TA in any batch const isTA = await Batch.exists({ ta: student._id }); + const completedEvaluations = await Evaluation.countDocuments({ + evaluator: student._id, + status: 'completed', + }); res.json({ id: student._id, @@ -23,6 +28,11 @@ export const getStudentProfile = async (req: AuthRequest, res: Response): Promis email: student.email, role: "student", isTA: !!isTA, // Ensures it's a boolean + assignedEvaluationsCount: student.assignedEvaluationsCount || 0, + completedEvaluations, + missedDeadlines: student.missedDeadlines || 0, + warnings: student.warnings || 0, + isRestricted: student.isRestricted || false, }); } catch (err) { console.error(err); diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/submitPeerEvaluation.controller.ts b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/submitPeerEvaluation.controller.ts index 64ff33d5..ef5e18be 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/submitPeerEvaluation.controller.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/student/submitPeerEvaluation.controller.ts @@ -2,6 +2,7 @@ import { Response, NextFunction } from "express"; import AuthenticatedRequest from "../../middlewares/authMiddleware.ts"; import { Evaluation } from "../../models/Evaluation.ts"; import { Exam } from "../../models/Exam.ts"; +import { Notification } from "../../models/Notification.ts"; export const submitPeerEvaluation = async ( req: AuthenticatedRequest, @@ -47,6 +48,21 @@ export const submitPeerEvaluation = async ( evaluation.status = "completed"; await evaluation.save(); + await Notification.updateMany( + { + recipient: studentId, + "relatedResource.type": "evaluation", + "relatedResource.id": evaluation._id, + }, + { + $set: { + read: true, + status: "read", + readAt: new Date(), + }, + } + ); + res.json({ message: "Evaluation submitted" }); } catch (err) { next(err); diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/teacher/peerEvaluation.controller.ts b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/teacher/peerEvaluation.controller.ts index b396435b..b21c4edc 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/controllers/teacher/peerEvaluation.controller.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/controllers/teacher/peerEvaluation.controller.ts @@ -13,7 +13,7 @@ export const initiatePeerEvaluation = async ( ): Promise => { try { const teacherId = req.user?._id; - const { examId } = req.body; + const { examId, deadline } = req.body; if (!teacherId || req.user.role !== "teacher") { res.status(403).json({ message: "Only teachers can initiate evaluation." }); @@ -59,6 +59,12 @@ export const initiatePeerEvaluation = async ( await Evaluation.deleteMany({ exam: examId }); + const parsedDeadline = deadline ? new Date(deadline) : null; + if (!parsedDeadline || Number.isNaN(parsedDeadline.getTime())) { + res.status(400).json({ message: "A valid evaluation deadline is required." }); + return; + } + const evalsToInsert = pairs.map(([evaluator, evaluatee]) => ({ exam: examId, evaluator, @@ -67,6 +73,9 @@ export const initiatePeerEvaluation = async ( feedback: '', status: 'pending', flagged: false, + deadline: parsedDeadline, + evaluatorsHistory: [], + reassignmentCount: 0, })); await Evaluation.insertMany(evalsToInsert); diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/jobs/scheduler.ts b/Peer_Evaluation_V3_NPTEL/backend/src/jobs/scheduler.ts new file mode 100644 index 00000000..fc8eff5c --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/backend/src/jobs/scheduler.ts @@ -0,0 +1,49 @@ +import cron from 'node-cron'; +import { Types } from 'mongoose'; +import { Evaluation } from '../models/Evaluation.ts'; +import { EvaluationService } from '../services/EvaluationService.ts'; + +export const initScheduler = () => { + console.log('[Scheduler] Initializing cron jobs...'); + + // Run every 10 seconds for faster demo/testing feedback. + cron.schedule('*/10 * * * * *', async () => { + console.log('[Scheduler] Running Auto-Reassignment check...'); + try { + const now = new Date(); + + // Step 1: first mark missed pending tasks as overdue so the UI can reflect the red state. + const newlyOverdueTasks = await Evaluation.find({ + status: 'pending', + deadline: { $lte: new Date() } + }); + + if (newlyOverdueTasks.length > 0) { + console.log(`[Scheduler] Marking ${newlyOverdueTasks.length} task(s) as overdue.`); + + for (const task of newlyOverdueTasks) { + await EvaluationService.markTaskOverdue(task._id as unknown as Types.ObjectId); + } + } + + // Step 2: on the next scheduler cycle, pick up already-overdue tasks and reassign them. + const overdueTasks = await Evaluation.find({ + status: 'overdue', + deadline: { $lte: now } + }); + + if (overdueTasks.length === 0) { + console.log('[Scheduler] No overdue evaluations ready for reassignment.'); + return; + } + + console.log(`[Scheduler] Found ${overdueTasks.length} overdue task(s). Triggering reassignment...`); + + for (const task of overdueTasks) { + await EvaluationService.reassignTask(task._id as unknown as Types.ObjectId); + } + } catch (error) { + console.error('[Scheduler] Error during reassignment check:', error); + } + }); +}; diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/models/Evaluation.ts b/Peer_Evaluation_V3_NPTEL/backend/src/models/Evaluation.ts index a42f4a01..a7af3c58 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/models/Evaluation.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/models/Evaluation.ts @@ -3,11 +3,15 @@ import { Schema, model, Document, Types } from "mongoose"; export interface IEvaluation extends Document { exam: Types.ObjectId; evaluator: Types.ObjectId; + assignedTo?: Types.ObjectId; evaluatee: Types.ObjectId; marks: number[]; feedback: string; - status: 'pending' | 'completed'; + status: 'pending' | 'completed' | 'overdue' | 'escalated'; flagged: boolean; + deadline: Date; + evaluatorsHistory: Types.ObjectId[]; + reassignmentCount: number; } const evaluationSchema = new Schema({ @@ -16,8 +20,22 @@ const evaluationSchema = new Schema({ evaluatee: { type: Schema.Types.ObjectId, ref: 'User', required: true }, marks: [{ type: Number, required: true }], feedback: { type: String }, - status: { type: String, enum: ['pending', 'completed'], default: 'pending' }, + status: { type: String, enum: ['pending', 'completed', 'overdue', 'escalated'], default: 'pending' }, flagged: { type: Boolean, default: false }, + deadline: { type: Date, required: true }, + evaluatorsHistory: [{ type: Schema.Types.ObjectId, ref: 'User' }], + reassignmentCount: { type: Number, default: 0 }, }); +evaluationSchema.virtual('assignedTo') + .get(function (this: IEvaluation) { + return this.evaluator; + }) + .set(function (this: IEvaluation, value: Types.ObjectId) { + this.evaluator = value; + }); + +evaluationSchema.set('toJSON', { virtuals: true }); +evaluationSchema.set('toObject', { virtuals: true }); + export const Evaluation = model('Evaluation', evaluationSchema); diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/models/Notification.ts b/Peer_Evaluation_V3_NPTEL/backend/src/models/Notification.ts index 8482736b..76b94dee 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/models/Notification.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/models/Notification.ts @@ -7,7 +7,10 @@ export interface INotification extends Document { type: string; id: Types.ObjectId; }; + status: "unread" | "read" | "dismissed"; read: boolean; + readAt?: Date; + dismissedAt?: Date; createdAt: Date; } @@ -18,8 +21,11 @@ const notificationSchema = new Schema({ type: { type: String, enum: ['evaluation', 'flag', 'exam'] }, id: { type: Schema.Types.ObjectId } }, + status: { type: String, enum: ['unread', 'read', 'dismissed'], default: 'unread' }, read: { type: Boolean, default: false }, + readAt: { type: Date }, + dismissedAt: { type: Date }, createdAt: { type: Date, default: Date.now } }); -export const Notification = model('Notification', notificationSchema); \ No newline at end of file +export const Notification = model('Notification', notificationSchema); diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/models/User.ts b/Peer_Evaluation_V3_NPTEL/backend/src/models/User.ts index e45f5be3..2696b373 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/models/User.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/models/User.ts @@ -9,6 +9,10 @@ export interface IUser extends Document { role: Role; enrolledCourses: Types.ObjectId[]; reputationScore: number; + assignedEvaluationsCount: number; + missedDeadlines: number; + warnings: number; + isRestricted: boolean; } const userSchema = new Schema({ @@ -18,6 +22,10 @@ const userSchema = new Schema({ role: { type: String, enum: ['admin', 'teacher', 'ta', 'student'], required: true }, enrolledCourses: [{ type: Schema.Types.ObjectId, ref: 'Course' }], reputationScore: { type: Number, default: 0 }, + assignedEvaluationsCount: { type: Number, default: 0 }, + missedDeadlines: { type: Number, default: 0 }, + warnings: { type: Number, default: 0 }, + isRestricted: { type: Boolean, default: false }, }); export const User = model('User', userSchema); diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/routes/admin/admin.routes.ts b/Peer_Evaluation_V3_NPTEL/backend/src/routes/admin/admin.routes.ts index 2724b1b5..e5622ca8 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/routes/admin/admin.routes.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/routes/admin/admin.routes.ts @@ -19,6 +19,14 @@ import { import { authMiddleware } from "../../middlewares/authMiddleware.ts"; import { authorizeRoles } from "../../middlewares/authorizeRoles.ts"; import { User } from '../../models/User.ts'; +import { + getEvaluationsOverview, + manualReassign, + updateEvaluationDeadline, + overrideReassignTask, + escalateTask, + resetDashboardDemoData, +} from "../../controllers/admin/evaluations.controller.ts"; const router = Router(); @@ -53,6 +61,15 @@ router.get('/batches/',authMiddleware,authorizeRoles("admin"), getAllBatches); //router.get('/batches/:id',authMiddleware,authorizeRoles("admin"), getBatchById); router.post("/update-role", updateStudentTaRole); router.post('/create-batch-with-names', createBatchWithNames); + +// Evaluation operations +router.get('/evaluations/overview', authMiddleware, authorizeRoles("admin"), getEvaluationsOverview); +router.post('/evaluations/reset-demo-data', authMiddleware, authorizeRoles("admin"), resetDashboardDemoData); +router.put('/evaluations/:id/deadline', authMiddleware, authorizeRoles("admin"), updateEvaluationDeadline); +router.post('/evaluations/reassign/:id', authMiddleware, authorizeRoles("admin"), manualReassign); +router.post('/evaluations/reassign/:id/override', authMiddleware, authorizeRoles("admin"), overrideReassignTask); +router.post('/evaluations/escalate/:id', authMiddleware, authorizeRoles("admin"), escalateTask); + router.delete("/:courseId", deleteCourseAndBatches); export default router; diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/routes/notifications.routes.ts b/Peer_Evaluation_V3_NPTEL/backend/src/routes/notifications.routes.ts new file mode 100644 index 00000000..5b5ff59f --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/backend/src/routes/notifications.routes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { authMiddleware } from "../middlewares/authMiddleware.ts"; +import { + dismissNotifications, + getNotifications, + markNotificationsRead, +} from "../controllers/shared/notification.controller.ts"; + +const router = Router(); + +router.get("/", authMiddleware, getNotifications as any); +router.put("/read", authMiddleware, markNotificationsRead as any); +router.put("/dismiss", authMiddleware, dismissNotifications as any); + +export default router; diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/routes/student/student.routes.ts b/Peer_Evaluation_V3_NPTEL/backend/src/routes/student/student.routes.ts index 717eea84..0e499e3a 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/routes/student/student.routes.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/routes/student/student.routes.ts @@ -61,4 +61,4 @@ router.get("/all-courses", authMiddleware, getAllCourses); router.get("/batches-by-course", authMiddleware, getBatchesByCourse); router.get("/answer-key/:examId", authMiddleware, getAnswerKeyPdf); router.get("/question-paper/:examId", authMiddleware, getQuestionPaperPdf); -export default router; \ No newline at end of file +export default router; diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/scripts/resetDashboardSeed.ts b/Peer_Evaluation_V3_NPTEL/backend/src/scripts/resetDashboardSeed.ts new file mode 100644 index 00000000..ea93eb36 --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/backend/src/scripts/resetDashboardSeed.ts @@ -0,0 +1,22 @@ +import dotenv from "dotenv"; +import mongoose from "mongoose"; +import connectDB from "../config/db.ts"; +import { clearDashboardSeedData, seedDatabase } from "../seed/seedData.ts"; + +dotenv.config(); + +const resetDashboardSeed = async () => { + try { + await connectDB(); + await clearDashboardSeedData(); + await seedDatabase(); + console.log("Admin dashboard demo data reset and reseeded successfully."); + } catch (error) { + console.error("Failed to reset admin dashboard demo data:", error); + process.exitCode = 1; + } finally { + await mongoose.connection.close(); + } +}; + +resetDashboardSeed(); diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/seed/seedData.ts b/Peer_Evaluation_V3_NPTEL/backend/src/seed/seedData.ts new file mode 100644 index 00000000..5de5c776 --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/backend/src/seed/seedData.ts @@ -0,0 +1,285 @@ +import { User, Role } from "../models/User.ts"; +import { Evaluation } from "../models/Evaluation.ts"; +import { Course } from "../models/Course.ts"; +import { Batch } from "../models/Batch.ts"; +import { Exam } from "../models/Exam.ts"; +import { Submission } from "../models/Submission.ts"; +import { Notification } from "../models/Notification.ts"; +import bcrypt from "bcryptjs"; + +const DASHBOARD_SEED_CODE = "ADM-MON-101"; +const DASHBOARD_ADMIN_EMAIL = "admin.dashboard@example.com"; +const DASHBOARD_SEED_EMAILS = [ + DASHBOARD_ADMIN_EMAIL, + "teacher.dashboard@example.com", + "ta.dashboard@example.com", + "aarav.dashboard@example.com", + "diya.dashboard@example.com", + "kabir.dashboard@example.com", + "isha.dashboard@example.com", + "neel.dashboard@example.com", +]; + +export const clearDashboardSeedData = async () => { + const seededCourse = await Course.findOne({ code: DASHBOARD_SEED_CODE }); + const seededUsers = await User.find({ email: { $in: DASHBOARD_SEED_EMAILS } }).select("_id"); + const seededUserIds = seededUsers.map((user) => user._id); + + if (seededCourse) { + const seededExams = await Exam.find({ course: seededCourse._id }).select("_id"); + const seededExamIds = seededExams.map((exam) => exam._id); + + if (seededExamIds.length > 0) { + const seededEvaluations = await Evaluation.find({ exam: { $in: seededExamIds } }).select("_id"); + const seededEvaluationIds = seededEvaluations.map((evaluation) => evaluation._id); + + if (seededEvaluationIds.length > 0) { + await Notification.deleteMany({ + "relatedResource.type": "evaluation", + "relatedResource.id": { $in: seededEvaluationIds }, + }); + } + + await Evaluation.deleteMany({ _id: { $in: seededEvaluationIds } }); + await Submission.deleteMany({ exam: { $in: seededExamIds } }); + await Exam.deleteMany({ _id: { $in: seededExamIds } }); + } + + await Batch.deleteMany({ course: seededCourse._id }); + await Course.deleteOne({ _id: seededCourse._id }); + } + + if (seededUserIds.length > 0) { + await Notification.deleteMany({ recipient: { $in: seededUserIds } }); + await User.deleteMany({ _id: { $in: seededUserIds } }); + } +}; + +export const seedDatabase = async () => { + try { + const existingCourse = await Course.findOne({ code: DASHBOARD_SEED_CODE }); + if (existingCourse) { + console.log("Admin dashboard mock data already present. Skipping seed."); + return; + } + + console.log("Seeding admin dashboard mock data..."); + + const hashedPassword = await bcrypt.hash("password123", 10); + + await User.create({ + name: "Admin Dashboard Owner", + email: DASHBOARD_ADMIN_EMAIL, + password: hashedPassword, + role: "admin" as Role, + enrolledCourses: [], + reputationScore: 0, + assignedEvaluationsCount: 0, + missedDeadlines: 0, + warnings: 0, + isRestricted: false, + }); + + const teacher = await User.create({ + name: "Prof. Meera Nair", + email: "teacher.dashboard@example.com", + password: hashedPassword, + role: "teacher" as Role, + enrolledCourses: [], + reputationScore: 0, + assignedEvaluationsCount: 0, + missedDeadlines: 0, + warnings: 0, + isRestricted: false, + }); + + const ta = await User.create({ + name: "Rahul TA", + email: "ta.dashboard@example.com", + password: hashedPassword, + role: "ta" as Role, + enrolledCourses: [], + reputationScore: 0, + assignedEvaluationsCount: 0, + missedDeadlines: 0, + warnings: 0, + isRestricted: false, + }); + + const students = await User.insertMany([ + { + name: "Aarav Patel", + email: "aarav.dashboard@example.com", + password: hashedPassword, + role: "student" as Role, + enrolledCourses: [], + reputationScore: 92, + assignedEvaluationsCount: 2, + missedDeadlines: 0, + warnings: 0, + isRestricted: false, + }, + { + name: "Diya Sharma", + email: "diya.dashboard@example.com", + password: hashedPassword, + role: "student" as Role, + enrolledCourses: [], + reputationScore: 88, + assignedEvaluationsCount: 1, + missedDeadlines: 0, + warnings: 0, + isRestricted: false, + }, + { + name: "Kabir Singh", + email: "kabir.dashboard@example.com", + password: hashedPassword, + role: "student" as Role, + enrolledCourses: [], + reputationScore: 85, + assignedEvaluationsCount: 1, + missedDeadlines: 1, + warnings: 1, + isRestricted: false, + }, + { + name: "Isha Verma", + email: "isha.dashboard@example.com", + password: hashedPassword, + role: "student" as Role, + enrolledCourses: [], + reputationScore: 96, + assignedEvaluationsCount: 1, + missedDeadlines: 0, + warnings: 0, + isRestricted: false, + }, + { + name: "Neel Joshi", + email: "neel.dashboard@example.com", + password: hashedPassword, + role: "student" as Role, + enrolledCourses: [], + reputationScore: 90, + assignedEvaluationsCount: 0, + missedDeadlines: 2, + warnings: 2, + isRestricted: false, + }, + ]); + + const course = await Course.create({ + name: "Admin Monitoring Systems", + code: DASHBOARD_SEED_CODE, + startDate: new Date("2026-01-10T00:00:00.000Z"), + endDate: new Date("2026-12-20T00:00:00.000Z"), + ta: [ta._id], + }); + + const batch = await Batch.create({ + name: "Spring Dashboard Batch", + course: course._id, + instructor: teacher._id, + students: students.map((student) => student._id), + ta: [ta._id], + }); + + await User.updateMany( + { _id: { $in: students.map((student) => student._id) } }, + { $set: { enrolledCourses: [course._id] } } + ); + + const exam = await Exam.create({ + title: "Peer Review Mock Exam", + course: course._id, + batch: batch._id, + startTime: new Date("2026-03-01T09:00:00.000Z"), + endTime: new Date("2026-03-01T11:00:00.000Z"), + numQuestions: 3, + maxMarks: [10, 10, 10], + createdBy: teacher._id, + k: 1, + }); + + await Submission.insertMany( + students.map((student, index) => ({ + student: student._id, + exam: exam._id, + course: course._id, + batch: batch._id, + answerPdf: Buffer.from(`Mock answer pdf ${index + 1}`), + answerPdfMimeType: "application/pdf", + submittedAt: new Date(Date.now() - (index + 2) * 60 * 60 * 1000), + })) + ); + + await Evaluation.insertMany([ + { + exam: exam._id, + evaluatee: students[1]._id, + evaluator: students[0]._id, + marks: [], + feedback: "", + status: "pending", + flagged: false, + deadline: new Date(Date.now() + 36 * 60 * 60 * 1000), + evaluatorsHistory: [], + reassignmentCount: 0, + }, + { + exam: exam._id, + evaluatee: students[2]._id, + evaluator: students[1]._id, + marks: [], + feedback: "", + status: "pending", + flagged: true, + deadline: new Date(Date.now() - 12 * 60 * 60 * 1000), + evaluatorsHistory: [], + reassignmentCount: 1, + }, + { + exam: exam._id, + evaluatee: students[3]._id, + evaluator: students[2]._id, + marks: [], + feedback: "", + status: "pending", + flagged: false, + deadline: new Date(Date.now() + 8 * 60 * 60 * 1000), + evaluatorsHistory: [students[4]._id, students[0]._id], + reassignmentCount: 2, + }, + { + exam: exam._id, + evaluatee: students[4]._id, + evaluator: students[3]._id, + marks: [8, 9, 7], + feedback: "Good structure and decent coverage.", + status: "completed", + flagged: false, + deadline: new Date(Date.now() - 48 * 60 * 60 * 1000), + evaluatorsHistory: [], + reassignmentCount: 0, + }, + { + exam: exam._id, + evaluatee: students[0]._id, + evaluator: students[4]._id, + marks: [], + feedback: "", + status: "escalated", + flagged: true, + deadline: new Date(Date.now() - 72 * 60 * 60 * 1000), + evaluatorsHistory: [students[1]._id, students[2]._id, students[3]._id], + reassignmentCount: 4, + }, + ]); + + console.log("Seeded admin dashboard mock users, course, batch, exam, submissions, and evaluations."); + console.log(`Admin login: ${DASHBOARD_ADMIN_EMAIL} / password123`); + } catch (error) { + console.error("Seeding error:", error); + } +}; diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/server.ts b/Peer_Evaluation_V3_NPTEL/backend/src/server.ts index b199620a..1d1bdb0e 100644 --- a/Peer_Evaluation_V3_NPTEL/backend/src/server.ts +++ b/Peer_Evaluation_V3_NPTEL/backend/src/server.ts @@ -1,33 +1,35 @@ +import express, { Request, Response, NextFunction } from "express"; +import cors from "cors"; +import dotenv from "dotenv"; -import express from "express"; -import { Request, Response, NextFunction } from 'express'; -import connectDB from "./config/db.ts"; +// Load env variables FIRST +dotenv.config(); + +// Routes import studentRoutes from "./routes/student/student.routes.ts"; -import taRoutes from "./routes/ta/ta.routes.ts"; +import taRoutes from "./routes/ta/ta.routes.ts"; import teacherRoutes from "./routes/teacher/teacher.routes.ts"; -import authRoutes from './routes/authorization/auth.routes.ts'; -import adminroutes from './routes/admin/admin.routes.ts'; -// import adminstudentroutes from './routes/admin/student_admin.routes.ts'; -// import adminteachroutes from './routes/admin/teacher.routes.ts'; -import dashboardRoutes from './routes/admin/dashboard.ts'; -//import admincourseroutes from './routes/admin/admin.routes.ts'; - +import authRoutes from "./routes/authorization/auth.routes.ts"; +import adminRoutes from "./routes/admin/admin.routes.ts"; +import dashboardRoutes from "./routes/admin/dashboard.ts"; +// DB + Scheduler + Seed +import connectDB from "./config/db.ts"; +import { initScheduler } from "./jobs/scheduler.ts"; +import { seedDatabase } from "./seed/seedData.ts"; +// Models (important for Mongoose) import "./models/Course.ts"; import "./models/Batch.ts"; import "./models/Exam.ts"; import "./models/User.ts"; -import "./models/Flag.ts"; +import "./models/Flag.ts"; import "./models/UIDMap.ts"; -import cors from 'cors'; -import dotenv from "dotenv"; -dotenv.config(); - const app = express(); const PORT = process.env.PORT || 5000; +// CORS config const corsOptions = { origin: "http://localhost:5173", credentials: true, @@ -37,35 +39,47 @@ const corsOptions = { app.use(cors(corsOptions)); -app.use((req, res, next) => { +// Handle preflight requests +app.use((req: Request, res: Response, next: NextFunction): void => { if (req.method === "OPTIONS") { res.sendStatus(204); - } else { - next(); + return; } + next(); }); +// Middleware app.use(express.json()); -// Connect DB -connectDB(); - // Routes -app.use("/api/admin",adminroutes); -// app.use("/api/admin/student",adminstudentroutes); -// app.use("/api/admin/teachers",adminteachroutes); +app.use("/api/admin", adminRoutes); app.use("/api/student", studentRoutes); -app.use('/api/auth', authRoutes); -app.use('/api/ta', taRoutes); -app.use('/api/teacher', teacherRoutes); -app.use('/api/dashboard', dashboardRoutes); -//app.use("/api/admin/courses", admincourseroutes); -//app.use('/api/teacher', teacherEnrollRoutes); +app.use("/api/auth", authRoutes); +app.use("/api/ta", taRoutes); +app.use("/api/teacher", teacherRoutes); +app.use("/api/dashboard", dashboardRoutes); +// Global error handler app.use((err: any, req: Request, res: Response, next: NextFunction) => { - console.error("Unhandled error:", err); + console.error("❌ Unhandled error:", err); res.status(500).json({ error: "Something went wrong on the server." }); }); -app.listen(PORT, () => { - console.log(`Server running on http://localhost:${PORT}`); -}); + +// 🚀 Start server properly +const startServer = async () => { + try { + await connectDB(); // connect DB first + await seedDatabase(); // seed initial testing data + initScheduler(); // then start scheduler + + app.listen(PORT, () => { + console.log(`🚀 Server running on http://localhost:${PORT}`); + }); + + } catch (error) { + console.error("❌ Failed to start server:", error); + process.exit(1); + } +}; + +startServer(); \ No newline at end of file diff --git a/Peer_Evaluation_V3_NPTEL/backend/src/services/EvaluationService.ts b/Peer_Evaluation_V3_NPTEL/backend/src/services/EvaluationService.ts new file mode 100644 index 00000000..1e67f278 --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/backend/src/services/EvaluationService.ts @@ -0,0 +1,151 @@ +import { Evaluation } from '../models/Evaluation.ts'; +import { User } from '../models/User.ts'; +import { Notification } from '../models/Notification.ts'; +import { Types } from 'mongoose'; + +const parsedEscalationThreshold = Number.parseInt( + process.env.ESCALATION_REASSIGNMENT_THRESHOLD ?? '3', + 10 +); + +export const ESCALATION_REASSIGNMENT_THRESHOLD = + Number.isFinite(parsedEscalationThreshold) && parsedEscalationThreshold >= 0 + ? parsedEscalationThreshold + : 3; + +export class EvaluationService { + + static async applyAccountabilityPenalty(userId: Types.ObjectId, evaluationId?: Types.ObjectId) { + const student = await User.findById(userId); + if (!student) return; + + student.missedDeadlines += 1; + + // Decrease their assigned evaluations count since they are no longer assigned to this + if (student.assignedEvaluationsCount > 0) { + student.assignedEvaluationsCount -= 1; + } + + if (student.missedDeadlines === 1) { + student.warnings = 1; + console.log(`[Accountability] ⚠️ Warning issued to student ${userId}`); + } else if (student.missedDeadlines === 2) { + student.warnings = 2; + console.log(`[Accountability] ⚠️ Second warning issued to student ${userId}`); + } else if (student.missedDeadlines >= 3) { + student.warnings = 3; + student.isRestricted = true; + console.log(`[Accountability] ⛔ Student ${userId} restricted due to 3 missed deadlines.`); + } + + await student.save(); + + await Notification.create({ + recipient: student._id, + message: `You missed the evaluation deadline for an assigned task. The task is now overdue and your accountability record has been updated.`, + relatedResource: evaluationId + ? { + type: 'evaluation', + id: evaluationId, + } + : undefined, + }); + } + + static async findStudentForReassignment(examId: Types.ObjectId, excludedUserIds: Types.ObjectId[]) { + // Priority: Least Load First + // Exclude the current & past evaluators, and anyone restricted + const potentials = await User.find({ + role: 'student', + _id: { $nin: excludedUserIds }, + isRestricted: false + }).sort({ assignedEvaluationsCount: 1, missedDeadlines: 1 }); + + if (potentials.length === 0) return null; + return potentials[0]; // Returns the student with the lowest assigned count + } + + static async markTaskOverdue(evaluationId: Types.ObjectId) { + const evaluation = await Evaluation.findById(evaluationId); + if (!evaluation || evaluation.status !== 'pending') return null; + + evaluation.status = 'overdue'; + await evaluation.save(); + return evaluation; + } + + static async reassignTask(evaluationId: Types.ObjectId, options?: { override?: boolean }) { + const evaluation = await Evaluation.findById(evaluationId); + if (!evaluation) return null; + + const override = options?.override === true; + + if (!override && !['pending', 'overdue'].includes(evaluation.status)) return null; + + console.log(`[Reassignment] Processing evaluation ${evaluation._id}${override ? ' with admin override' : ''}`); + + // Track the previous evaluator for penalties + const previousEvaluatorId = evaluation.evaluator; + + // Apply penalties only for regular reassignment flows, not manual overrides. + if (!override) { + await this.applyAccountabilityPenalty(previousEvaluatorId, evaluation._id as Types.ObjectId); + } + + // Prevent infinite reassignment loops + if (!override && evaluation.reassignmentCount >= ESCALATION_REASSIGNMENT_THRESHOLD) { + console.log( + `[Escalation] Task ${evaluation._id} reached the reassignment threshold (${ESCALATION_REASSIGNMENT_THRESHOLD}). Escalating to TA/Teacher.` + ); + evaluation.status = 'escalated'; + await evaluation.save(); + return null; + } + + // Add current to history + if (!evaluation.evaluatorsHistory.some((id) => id.toString() === previousEvaluatorId.toString())) { + evaluation.evaluatorsHistory.push(previousEvaluatorId); + } + + // Find new student + const newStudent = await this.findStudentForReassignment( + evaluation.exam, + [...evaluation.evaluatorsHistory, evaluation.evaluatee] // exclude the person being evaluated and past evaluators + ); + + if (!newStudent) { + console.log(`[Escalation] Could not find any student to reassign task ${evaluation._id}.`); + evaluation.status = 'escalated'; + await evaluation.save(); + return null; + } + + // Assign to new student + evaluation.evaluator = newStudent._id as Types.ObjectId; + evaluation.reassignmentCount += 1; + evaluation.status = 'pending'; + + // Extend deadline by another 24 hours + const newDeadline = new Date(); + newDeadline.setHours(newDeadline.getHours() + 24); + evaluation.deadline = newDeadline; + + await evaluation.save(); + + // Increment new student's load + newStudent.assignedEvaluationsCount += 1; + await newStudent.save(); + + await Notification.create({ + recipient: newStudent._id, + message: `Urgent: An evaluation task has been reassigned to you. You have 24 hours to complete it.`, + relatedResource: { + type: 'evaluation', + id: evaluation._id + } + }); + + console.log(`[Reassignment] Success: Task ${evaluationId} reassigned from ${previousEvaluatorId} to ${newStudent._id}`); + return evaluation; + } +} diff --git a/Peer_Evaluation_V3_NPTEL/frontend/package-lock.json b/Peer_Evaluation_V3_NPTEL/frontend/package-lock.json index e8d813fd..e36c44a7 100644 --- a/Peer_Evaluation_V3_NPTEL/frontend/package-lock.json +++ b/Peer_Evaluation_V3_NPTEL/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.0.0", + "version": "1.0.0", "dependencies": { "@tanstack/react-query": "^5.80.7", "axios": "^1.9.0", @@ -899,13 +899,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { @@ -913,9 +913,9 @@ } }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1103,9 +1103,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", - "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -1117,9 +1117,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz", - "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -1131,9 +1131,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz", - "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -1145,9 +1145,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz", - "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -1159,9 +1159,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz", - "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -1173,9 +1173,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz", - "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -1187,9 +1187,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz", - "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -1201,9 +1201,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz", - "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -1215,9 +1215,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz", - "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -1229,9 +1229,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz", - "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -1242,10 +1242,24 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz", - "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], @@ -1256,10 +1270,24 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz", - "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], @@ -1271,9 +1299,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz", - "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], @@ -1285,9 +1313,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz", - "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], @@ -1299,9 +1327,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz", - "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], @@ -1313,9 +1341,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", - "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], @@ -1327,9 +1355,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz", - "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], @@ -1340,10 +1368,38 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz", - "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -1355,9 +1411,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz", - "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -1368,10 +1424,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz", - "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -1970,9 +2040,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -1980,13 +2050,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -2095,9 +2165,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -2179,14 +2249,14 @@ } }, "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -2197,9 +2267,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2371,12 +2441,16 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -2886,16 +2960,16 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -2913,9 +2987,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3235,9 +3309,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3663,9 +3737,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3686,9 +3760,9 @@ } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -3698,22 +3772,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/motion-dom": { "version": "12.19.0", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.19.0.tgz", @@ -3873,9 +3931,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3932,10 +3990,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -4009,9 +4070,9 @@ } }, "node_modules/react-router": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", - "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz", + "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4031,12 +4092,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz", - "integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==", + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz", + "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==", "license": "MIT", "dependencies": { - "react-router": "7.6.2" + "react-router": "7.14.0" }, "engines": { "node": ">=20.0.0" @@ -4068,13 +4129,13 @@ } }, "node_modules/rollup": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", - "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -4084,36 +4145,34 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.43.0", - "@rollup/rollup-android-arm64": "4.43.0", - "@rollup/rollup-darwin-arm64": "4.43.0", - "@rollup/rollup-darwin-x64": "4.43.0", - "@rollup/rollup-freebsd-arm64": "4.43.0", - "@rollup/rollup-freebsd-x64": "4.43.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.43.0", - "@rollup/rollup-linux-arm-musleabihf": "4.43.0", - "@rollup/rollup-linux-arm64-gnu": "4.43.0", - "@rollup/rollup-linux-arm64-musl": "4.43.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.43.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", - "@rollup/rollup-linux-riscv64-gnu": "4.43.0", - "@rollup/rollup-linux-riscv64-musl": "4.43.0", - "@rollup/rollup-linux-s390x-gnu": "4.43.0", - "@rollup/rollup-linux-x64-gnu": "4.43.0", - "@rollup/rollup-linux-x64-musl": "4.43.0", - "@rollup/rollup-win32-arm64-msvc": "4.43.0", - "@rollup/rollup-win32-ia32-msvc": "4.43.0", - "@rollup/rollup-win32-x64-msvc": "4.43.0", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4155,9 +4214,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, "node_modules/shebang-command": { @@ -4237,17 +4296,16 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { @@ -4297,9 +4355,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -4438,9 +4496,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4528,9 +4586,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/Peer_Evaluation_V3_NPTEL/frontend/package.json b/Peer_Evaluation_V3_NPTEL/frontend/package.json index 8533e233..574023f9 100644 --- a/Peer_Evaluation_V3_NPTEL/frontend/package.json +++ b/Peer_Evaluation_V3_NPTEL/frontend/package.json @@ -1,41 +1,47 @@ { "name": "frontend", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", + "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "lint": "eslint ." }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.2", "@tanstack/react-query": "^5.80.7", "axios": "^1.9.0", "framer-motion": "^12.19.1", "jwt-decode": "^4.0.0", "papaparse": "^5.5.3", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-icons": "^5.5.0", - "react-router-dom": "^7.6.2" + "react-icons": "^5.5.0" }, + "devDependencies": { - "@eslint/js": "^9.25.0", - "@tailwindcss/vite": "^4.1.8", - "@types/papaparse": "^5.3.16", + "vite": "^6.3.5", + "@vitejs/plugin-react": "^4.4.1", + + "typescript": "~5.8.3", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", - "@vitejs/plugin-react": "^4.4.1", + "@types/papaparse": "^5.3.16", + + "tailwindcss": "^4.1.8", + "postcss": "^8.5.4", "autoprefixer": "^10.4.21", + "@tailwindcss/vite": "^4.1.8", + "eslint": "^9.25.0", + "@eslint/js": "^9.25.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", - "globals": "^16.0.0", - "postcss": "^8.5.4", - "tailwindcss": "^4.1.8", - "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" + "globals": "^16.0.0" } } diff --git a/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/AccountabilityStats.tsx b/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/AccountabilityStats.tsx new file mode 100644 index 00000000..42f89266 --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/AccountabilityStats.tsx @@ -0,0 +1,86 @@ +interface StatsProps { + stats: { + completedTasks: number; + missedDeadlines: number; + warnings: number; + isRestricted: boolean; + }; + darkMode?: boolean; +} + +const AccountabilityStats = ({ stats, darkMode = false }: StatsProps) => { + if (!stats) return null; + + const warnings = stats.warnings || 0; + const missedDeadlines = stats.missedDeadlines || 0; + const isRestricted = Boolean(stats.isRestricted); + + const panelTone = isRestricted + ? darkMode + ? 'bg-red-950/40 border-red-700 text-red-50' + : 'bg-red-50 border-red-300 text-red-950' + : warnings > 0 || missedDeadlines > 0 + ? darkMode + ? 'bg-yellow-950/30 border-yellow-700 text-yellow-50' + : 'bg-yellow-50 border-yellow-300 text-yellow-950' + : darkMode + ? 'bg-emerald-950/30 border-emerald-700 text-emerald-50' + : 'bg-emerald-50 border-emerald-300 text-emerald-950'; + + const statusLabel = isRestricted ? 'Restricted' : warnings > 0 || missedDeadlines > 0 ? 'Warning' : 'Good'; + const statusDot = isRestricted ? 'bg-red-500' : warnings > 0 || missedDeadlines > 0 ? 'bg-yellow-500' : 'bg-green-500'; + + const valueTone = (level: 'good' | 'warning' | 'critical') => { + if (level === 'critical') return 'text-red-500'; + if (level === 'warning') return 'text-yellow-500'; + return 'text-green-500'; + }; + + return ( +
+
+
+

Accountability Status

+

{statusLabel}

+
+
+ + {isRestricted ? 'Restricted from further assignment' : 'Current standing'} +
+
+ + {isRestricted && ( +
+ Restricted status triggered because warnings exceeded the allowed threshold. +
+ )} + +
+
+
{stats.completedTasks || 0}
+
Completed Evaluations
+
+
+
1 ? 'critical' : missedDeadlines > 0 ? 'warning' : 'good')}`}> + {missedDeadlines} +
+
Missed Deadlines
+
+
+
0 ? 'warning' : 'good')}`}> + {warnings} +
+
Warnings
+
+
+
+ {isRestricted ? 'Restricted' : 'Active'} +
+
Assignment Access
+
+
+
+ ); +}; + +export default AccountabilityStats; diff --git a/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/DashboardOverview.tsx b/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/DashboardOverview.tsx index 675b3edc..bbabd3ea 100644 --- a/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/DashboardOverview.tsx +++ b/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/DashboardOverview.tsx @@ -1,62 +1,79 @@ import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; +import AccountabilityStats from './AccountabilityStats'; +import TaskCard from './TaskCard'; +import type { StudentTaskCardData } from './TaskCard'; +import { useNavigate } from 'react-router-dom'; +import type { CSSProperties } from 'react'; interface DashboardOverviewProps { darkMode: boolean; } +type StudentProfile = { + completedEvaluations?: number; + missedDeadlines?: number; + warnings?: number; + isRestricted?: boolean; +}; + const PORT = import.meta.env.VITE_BACKEND_PORT || 5000; const DashboardOverview: React.FC = ({ darkMode }) => { const token = localStorage.getItem('token'); + const navigate = useNavigate(); - const { data: exams = [], isLoading: examsLoading } = useQuery({ - queryKey: ['upcomingExams'], + const { data: profile, isLoading: profileLoading } = useQuery({ + queryKey: ['studentProfile'], queryFn: async () => { - const { data } = await axios.get(`http://localhost:${PORT}/api/student/exams`, { + const { data } = await axios.get(`http://localhost:${PORT}/api/student/profile`, { headers: { Authorization: `Bearer ${token}` }, }); - return data?.exams || []; + return data; }, + enabled: !!token, }); - const { data: evaluations = [], isLoading: evalsLoading } = useQuery({ - queryKey: ['pendingEvaluations'], + const { data: exams = [], isLoading: examsLoading } = useQuery({ + queryKey: ['upcomingExams'], queryFn: async () => { - const { data } = await axios.get(`http://localhost:${PORT}/api/student/pending-evaluations`, { + const { data } = await axios.get(`http://localhost:${PORT}/api/student/exams`, { headers: { Authorization: `Bearer ${token}` }, }); - return data?.evaluations || data?.evaluatees || []; + return data?.exams || []; }, + enabled: !!token, }); - const { data: results = [], isLoading: resultsLoading } = useQuery({ - queryKey: ['evaluationResults'], + const { data: evaluations = [], isLoading: evalsLoading } = useQuery({ + queryKey: ['pendingEvaluations'], queryFn: async () => { - const { data } = await axios.get(`http://localhost:${PORT}/api/student/results`, { + const { data } = await axios.get(`http://localhost:${PORT}/api/student/pending-evaluations`, { headers: { Authorization: `Bearer ${token}` }, }); - return data?.results || []; + return data?.evaluations || []; }, + enabled: !!token, + refetchInterval: 10000, }); - const palette = darkMode ? { - background: '#16213E', - card: '#1A1A2E', - text: '#E0E0E0', - muted: '#B0BEC5', - border: '#3F51B5', - shadow: 'rgba(0, 0, 0, 0.4)', - } : { - background: '#FFFBF6', - card: '#FFFAF2', - text: '#4B0082', - muted: '#A9A9A9', - border: '#F0E6EF', - shadow: 'rgba(128, 0, 128, 0.08)', - }; + const palette = darkMode + ? { + card: '#16213E', + text: '#E0E0E0', + muted: '#B0BEC5', + border: '#3F51B5', + shadow: 'rgba(0, 0, 0, 0.4)', + } + : { + card: '#FFFAF2', + text: '#4B0082', + muted: '#A9A9A9', + border: '#F0E6EF', + shadow: 'rgba(128, 0, 128, 0.08)', + }; - const cardStyle: React.CSSProperties = { + const cardStyle: CSSProperties = { backgroundColor: palette.card, borderColor: palette.border, color: palette.text, @@ -65,55 +82,116 @@ const DashboardOverview: React.FC = ({ darkMode }) => { const cardBeforeGradient = 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)'; + const accountabilityStats = { + completedTasks: profile?.completedEvaluations || 0, + missedDeadlines: profile?.missedDeadlines || 0, + warnings: profile?.warnings || 0, + isRestricted: Boolean(profile?.isRestricted), + }; + + const overdueTasks = evaluations.filter((task) => { + if (!task.deadline) return false; + const deadlinePassed = new Date(task.deadline).getTime() < Date.now(); + return deadlinePassed && (task.status === 'pending' || task.status === 'overdue'); + }); + + const activeTasks = evaluations.filter((task) => !overdueTasks.some((overdueTask) => overdueTask._id === task._id)); + + const handleStartEvaluation = () => { + navigate('/dashboard?tab=peerEvaluation'); + }; + return ( -
- {/* Upcoming Exams */} -
-
-

Upcoming Exams

- {examsLoading ? ( -

Loading...

- ) : exams.length === 0 ? ( -

No upcoming exams

- ) : ( -
    - {exams.slice(0, 3).map((exam: any) => ( -
  • - {exam.title} - {new Date(exam.startTime).toLocaleString()} -
  • - ))} -
- )} -
+
+

+ Student Accountability +

+ {profileLoading ? ( +
+

Loading accountability data...

+
+ ) : ( + + )} - {/* Peer Evaluations */} -
-
-

Peer Evaluations

- {evalsLoading ? ( -

Loading...

- ) : ( -

You have {evaluations.length} peer reviews pending.

- )} -
+ {!evalsLoading && overdueTasks.length > 0 && ( +
+
+
+
+

+ Overdue Tasks +

+

+ Overdue tasks need immediate attention and are shown before your other assignments. +

+
+ + {overdueTasks.length} overdue + +
+ {overdueTasks.map((task) => ( + + ))} +
+ )} + +
+
+
+

+ Upcoming Exams +

+ {examsLoading ? ( +

Loading...

+ ) : exams.length === 0 ? ( +

No upcoming exams

+ ) : ( +
    + {exams.slice(0, 3).map((exam: any) => ( +
  • + {exam.title} - {new Date(exam.startTime).toLocaleString()} +
  • + ))} +
+ )} +
- {/* Recent Grades */} -
-
-

Recent Grades

- {resultsLoading ? ( -

Loading...

- ) : results.length === 0 ? ( -

No evaluation results available.

- ) : ( -
    - {results.slice(0, 3).map((r: any) => ( -
  • - {r.exam.courseName}: {r.averageMarks} -
  • - ))} -
- )} +
+
+

+ Assigned Evaluation Tasks +

+ {evalsLoading ? ( +

Loading...

+ ) : evaluations.length === 0 ? ( +

You are all caught up

+ ) : activeTasks.length === 0 ? ( +

+ No non-overdue tasks right now. Focus on the overdue section above. +

+ ) : ( +
+

+ You have {activeTasks.length} other active evaluation task(s). +

+ {activeTasks.map((task) => ( + + ))} +
+ )} +
); diff --git a/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/TaskCard.tsx b/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/TaskCard.tsx new file mode 100644 index 00000000..1c9a4b0c --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/frontend/src/components/student/TaskCard.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from 'react'; + +export type StudentTaskCardData = { + _id: string; + submissionId: string | null; + deadline: string; + status?: 'pending' | 'completed' | 'overdue' | 'escalated' | string; + reassigned?: boolean; + reassignmentCount?: number; +}; + +const TaskCard = ({ + task, + onStart, + darkMode = false, +}: { + task: StudentTaskCardData; + onStart: (task: StudentTaskCardData) => void; + darkMode?: boolean; +}) => { + const [timeLeft, setTimeLeft] = useState(''); + const [urgency, setUrgency] = useState<'normal' | 'urgent' | 'overdue'>('normal'); + + useEffect(() => { + if (!task.deadline) { + setTimeLeft('No deadline'); + return; + } + + const checkTimer = () => { + const diff = new Date(task.deadline).getTime() - Date.now(); + if (diff <= 0) { + setTimeLeft('OVERDUE'); + setUrgency('overdue'); + return; + } + + const totalHours = Math.floor(diff / (1000 * 60 * 60)); + const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const secs = Math.floor((diff % (1000 * 60)) / 1000); + setTimeLeft(`${totalHours}h ${mins}m ${secs}s left`); + setUrgency(totalHours < 12 ? 'urgent' : 'normal'); + }; + + checkTimer(); + const timer = window.setInterval(checkTimer, 1000); + return () => window.clearInterval(timer); + }, [task.deadline]); + + const reassignmentCount = task.reassignmentCount || 0; + const isReassigned = reassignmentCount > 0 || Boolean(task.reassigned); + const isEscalated = task.status === 'escalated'; + const isOverdue = urgency === 'overdue' || task.status === 'overdue'; + + let currentStatus = task.status ? task.status.charAt(0).toUpperCase() + task.status.slice(1) : 'Pending'; + if (isEscalated) currentStatus = 'Escalated'; + else if (isOverdue && currentStatus !== 'Completed') currentStatus = 'Overdue'; + else if (isReassigned && currentStatus !== 'Completed' && currentStatus !== 'Overdue') currentStatus = 'Reassigned'; + + const formatDeadline = (dateString: string) => { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + const baseCardStyle = darkMode + ? 'border-slate-700 bg-slate-900/80 text-slate-100 hover:shadow-xl' + : 'border-gray-200 bg-white text-slate-800 hover:shadow-md'; + let cardStyle = baseCardStyle; + let badgeStyle = darkMode ? 'bg-slate-700 text-slate-100' : 'bg-gray-100 text-gray-700'; + + if (isEscalated) { + cardStyle = darkMode + ? 'border-rose-700 bg-rose-950/50 text-rose-50 shadow-md ring-2 ring-rose-900' + : 'border-rose-600 bg-rose-100 text-rose-950 shadow-md ring-2 ring-rose-200'; + badgeStyle = 'bg-rose-700 text-white shadow-sm'; + } else if (isOverdue) { + cardStyle = darkMode + ? 'border-red-500 bg-red-950/40 text-red-50 shadow-md ring-2 ring-red-900' + : 'border-red-500 bg-red-50 text-red-950 shadow-md ring-2 ring-red-200'; + badgeStyle = 'bg-red-600 text-white animate-pulse shadow-sm'; + } else if (urgency === 'urgent') { + cardStyle = darkMode + ? 'border-yellow-500 bg-yellow-950/30 text-yellow-50 shadow-md ring-2 ring-yellow-900/70' + : 'border-yellow-400 bg-yellow-50 text-yellow-950 shadow-md ring-2 ring-yellow-100'; + badgeStyle = 'bg-yellow-500 text-white shadow-sm'; + } else if (isReassigned) { + cardStyle = darkMode + ? 'border-violet-500 bg-violet-950/25 text-violet-50 shadow-md' + : 'border-violet-300 bg-violet-50 text-violet-950 shadow-md'; + } + + return ( +
+
+
+
+

+ Submission ID: {task.submissionId || task._id.slice(0, 8) || 'N/A'} +

+ {isEscalated && ( + + Escalated + + )} + {isReassigned && ( + + Reassigned Task + + )} +
+
+ Deadline: {formatDeadline(task.deadline)} +
+
+ Status: {currentStatus} +
+
+ Reassignments: {reassignmentCount} +
+ {isOverdue && !isEscalated && ( +
+ Overdue - needs immediate attention +
+ )} + {isReassigned && ( +
+ This task was reassigned due to missed deadline. +
+ )} + {isEscalated && ( +
+ This task has been escalated and is no longer auto-reassigned. +
+ )} +
+
+ {isEscalated ? 'Escalated' : timeLeft} +
+
+ +
+ ); +}; + +export default TaskCard; diff --git a/Peer_Evaluation_V3_NPTEL/frontend/src/components/teacher/TeacherExams.tsx b/Peer_Evaluation_V3_NPTEL/frontend/src/components/teacher/TeacherExams.tsx index 83db8bef..92c2e098 100644 --- a/Peer_Evaluation_V3_NPTEL/frontend/src/components/teacher/TeacherExams.tsx +++ b/Peer_Evaluation_V3_NPTEL/frontend/src/components/teacher/TeacherExams.tsx @@ -38,6 +38,7 @@ interface Exam { endTime: string; numQuestions: number; k: number; + maxMarks?: number[]; // questions?: { q?: string; max?: number; questionText?: string; maxMarks?: number }[]; } @@ -51,10 +52,13 @@ export default function TeacherExams() { const [allExams, setAllExams] = useState([]); const [isCreateOpen, setCreateOpen] = useState(false); const [isEditOpen, setEditOpen] = useState(false); + const [isDeadlineOpen, setDeadlineOpen] = useState(false); const [editExam, setEditExam] = useState(null); + const [selectedEvaluationExam, setSelectedEvaluationExam] = useState(null); const [title, setTitle] = useState(""); const [startTime, setStartTime] = useState(""); const [endTime, setEndTime] = useState(""); + const [evaluationDeadline, setEvaluationDeadline] = useState(""); const [k, setK] = useState(1); // Remove questions state, use numQuestions and questionPaperFile const [numQuestions, setNumQuestions] = useState(1); @@ -138,6 +142,12 @@ export default function TeacherExams() { setQuestionPaperFile(null); }; + const resetDeadlineForm = () => { + setSelectedEvaluationExam(null); + setEvaluationDeadline(""); + setDeadlineOpen(false); + }; + // Toast for ALL actions const toastAction = (message: string, type: "success" | "error" = "success") => { setToast({ message, type }); @@ -168,6 +178,26 @@ export default function TeacherExams() { } }; + const handleInitiateEvaluation = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedEvaluationExam || !evaluationDeadline) { + toastAction("Please choose an evaluation deadline", "error"); + return; + } + + try { + const res = await axios.post( + `http://localhost:${PORT}/api/teacher/initiate-evaluation`, + { examId: selectedEvaluationExam._id, deadline: evaluationDeadline }, + { headers: { Authorization: `Bearer ${token}` } } + ); + toastAction(res.data.message || "Evaluation initiated", "success"); + resetDeadlineForm(); + } catch (err: any) { + toastAction(err.response?.data?.message || "Error initiating evaluation", "error"); + } + }; + const handleEdit = async (e: React.FormEvent) => { e.preventDefault(); if (!editExam) return; @@ -461,17 +491,10 @@ export default function TeacherExams() {
)} + + {isDeadlineOpen && selectedEvaluationExam && ( +
+
+

Set Evaluation Deadline

+

+ Choose the deadline for peer evaluation tasks for {selectedEvaluationExam.title}. +

+
+ +
+ setEvaluationDeadline(e.target.value)} + required + /> + +
+
+
+ + +
+
+
+ )}
); } diff --git a/Peer_Evaluation_V3_NPTEL/frontend/src/global.d.ts b/Peer_Evaluation_V3_NPTEL/frontend/src/global.d.ts new file mode 100644 index 00000000..7f701834 --- /dev/null +++ b/Peer_Evaluation_V3_NPTEL/frontend/src/global.d.ts @@ -0,0 +1 @@ +declare module "*.css"; \ No newline at end of file diff --git a/Peer_Evaluation_V3_NPTEL/frontend/src/main.tsx b/Peer_Evaluation_V3_NPTEL/frontend/src/main.tsx index a888431c..5d5ec4ad 100644 --- a/Peer_Evaluation_V3_NPTEL/frontend/src/main.tsx +++ b/Peer_Evaluation_V3_NPTEL/frontend/src/main.tsx @@ -1,8 +1,8 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import './index.css' import App from './App.tsx' +import './index.css' const queryClient = new QueryClient(); diff --git a/Peer_Evaluation_V3_NPTEL/frontend/src/pages/AdminDashboard.tsx b/Peer_Evaluation_V3_NPTEL/frontend/src/pages/AdminDashboard.tsx index b7d1eca0..53f096f9 100644 --- a/Peer_Evaluation_V3_NPTEL/frontend/src/pages/AdminDashboard.tsx +++ b/Peer_Evaluation_V3_NPTEL/frontend/src/pages/AdminDashboard.tsx @@ -1,11 +1,11 @@ import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import axios from 'axios'; -import { FiMoon, FiSun } from 'react-icons/fi'; +import { FiBell, FiMoon, FiSun } from 'react-icons/fi'; const PORT = import.meta.env.VITE_BACKEND_PORT || 5000; -type Tab = 'home' | 'course' | 'batch'| 'role' ; +type Tab = 'home' | 'course' | 'batch'| 'role' | 'monitoring'; type Course = { _id: string; name: string; @@ -14,6 +14,40 @@ type Course = { endDate: string; }; +type EvaluationOverviewItem = { + _id: string; + submissionId: string | null; + assignedStudent?: { _id: string; name: string; email: string }; + evaluatee?: { _id: string; name: string; email: string }; + exam?: { _id: string; title?: string }; + deadline: string; + status: 'pending' | 'overdue' | 'completed' | 'escalated'; + reassignmentCount: number; + isOverdue: boolean; + isFrequentlyReassigned: boolean; + isDashboardDemo?: boolean; +}; + +type MonitoringFilter = 'active' | 'all' | 'demo' | 'escalated' | 'completed'; + +type EvaluationsOverview = { + statistics: { + total: number; + pending: number; + overdue: number; + escalated: number; + reassignedCount: number; + }; + evaluations: EvaluationOverviewItem[]; +}; + +type NotificationItem = { + _id: string; + message: string; + read: boolean; + createdAt: string; +}; + const lightPalette = { 'bg-primary': '#FFFBF6', 'bg-secondary': '#FFFAF2', @@ -274,9 +308,10 @@ const AdminDashboard = () => { const [showSidebar, setShowSidebar] = useState(true); const [showLogoutModal, setShowLogoutModal] = useState(false); const [darkMode, setDarkMode] = useState(() => localStorage.getItem('darkMode') === 'true'); + const [showNotifications, setShowNotifications] = useState(false); // Data State - const [counts, setCounts] = useState({ teachers: 0, courses: 0, students: 0 }); + const [counts, setCounts] = useState({ teachers: 0, courses: 0, students: 0, batches: 0, exams: 0 }); const [profileData, setProfileData] = useState({ name: "", email: "", role: "" }); const [showProfilePopup, setShowProfilePopup] = useState(false); const [courses, setCourses] = useState([]); @@ -286,6 +321,13 @@ const AdminDashboard = () => { const [batches, setBatches] = useState([]); const [batchInstructor, setBatchInstructor] = useState(''); const [allUsers, setAllUsers] = useState<{ _id: string | number | readonly string[] | undefined; role: string; email: string; name: string }[]>([]); + const [evaluationsOverview, setEvaluationsOverview] = useState({ + statistics: { total: 0, pending: 0, overdue: 0, escalated: 0, reassignedCount: 0 }, + evaluations: [], + }); + const [monitoringFilter, setMonitoringFilter] = useState('active'); + const [deadlineDrafts, setDeadlineDrafts] = useState>({}); + const [notifications, setNotifications] = useState([]); // Form & Message State const [courseName, setCourseName] = useState(''); @@ -299,6 +341,46 @@ const AdminDashboard = () => { const [toastType, setToastType] = useState<'success' | 'error'>('success'); const currentPalette = getColors(darkMode); // Get current palette based on dark mode state + const unreadNotificationCount = notifications.filter((notification) => !notification.read).length; + const filteredEvaluations = useMemo(() => { + const filtered = evaluationsOverview.evaluations.filter((evaluation) => { + if (monitoringFilter === 'active') { + return !['completed', 'escalated'].includes(evaluation.status); + } + + if (monitoringFilter === 'demo') { + return evaluation.isDashboardDemo === true; + } + + if (monitoringFilter === 'escalated') { + return evaluation.status === 'escalated'; + } + + if (monitoringFilter === 'completed') { + return evaluation.status === 'completed'; + } + + return true; + }); + + return filtered.sort((left, right) => { + const getPriority = (evaluation: EvaluationOverviewItem) => { + if (evaluation.isOverdue) return 0; + if (evaluation.status === 'pending') return 1; + if (evaluation.status === 'overdue') return 2; + if (evaluation.status === 'escalated') return 3; + if (evaluation.status === 'completed') return 4; + return 5; + }; + + const priorityDifference = getPriority(left) - getPriority(right); + if (priorityDifference !== 0) { + return priorityDifference; + } + + return new Date(left.deadline).getTime() - new Date(right.deadline).getTime(); + }); + }, [evaluationsOverview.evaluations, monitoringFilter]); // Apply dark mode on initial load and when it changes useEffect(() => { @@ -317,6 +399,37 @@ const AdminDashboard = () => { setTimeout(() => setToastMessage(''), 3000); }; + const refreshEvaluationsOverview = () => + fetchData( + `http://localhost:${PORT}/api/admin/evaluations/overview`, + setEvaluationsOverview, + 'Failed to fetch evaluations overview' + ); + + const fetchNotifications = async () => { + try { + const res = await axios.get(`http://localhost:${PORT}/api/notifications`, { + headers: { Authorization: `Bearer ${token}` }, + }); + setNotifications(res.data.notifications || []); + } catch (error) { + console.error('Failed to fetch notifications', error); + } + }; + + const markNotificationRead = async (id: string) => { + try { + await axios.put( + `http://localhost:${PORT}/api/notifications/read`, + { id }, + { headers: { Authorization: `Bearer ${token}` } } + ); + fetchNotifications(); + } catch (error) { + console.error('Failed to mark notification as read', error); + } + }; + const ProfileSVG = () => ( { fetchData(`http://localhost:${PORT}/api/dashboard/profile`, setProfileData, 'Failed to fetch profile'); fetchData(`http://localhost:${PORT}/api/dashboard/counts`, setCounts, 'Failed to fetch counts'); fetchData(`http://localhost:${PORT}/api/admin/users`, setAllUsers, 'Failed to fetch users'); + fetchNotifications(); }, [token, navigate]); + + useEffect(() => { + if (!token) return; + + const intervalId = window.setInterval(() => { + fetchNotifications(); + }, 30000); + + return () => window.clearInterval(intervalId); + }, [token]); + + useEffect(() => { + if (activeTab !== 'monitoring') return; + + refreshEvaluationsOverview(); + const intervalId = window.setInterval(() => { + refreshEvaluationsOverview(); + }, 15000); + + return () => window.clearInterval(intervalId); + }, [activeTab]); + + useEffect(() => { + setDeadlineDrafts((current) => { + const next = { ...current }; + evaluationsOverview.evaluations.forEach((evaluation) => { + if (!next[evaluation._id]) { + const date = new Date(evaluation.deadline); + date.setMinutes(date.getMinutes() - date.getTimezoneOffset()); + next[evaluation._id] = date.toISOString().slice(0, 16); + } + }); + return next; + }); + }, [evaluationsOverview.evaluations]); // Fetch data based on active tab useEffect(() => { @@ -372,6 +521,9 @@ const AdminDashboard = () => { if(activeTab === 'role') { fetchData(`http://localhost:${PORT}/api/admin/users`, setAllUsers, 'Failed to fetch users'); } + if (activeTab === 'monitoring') { + refreshEvaluationsOverview(); + } }, [activeTab]); const handleAddCourse = async (e: React.FormEvent) => { @@ -480,6 +632,84 @@ const AdminDashboard = () => { } }; + const handleReassignTask = async (id: string) => { + try { + await axios.post(`http://localhost:${PORT}/api/admin/evaluations/reassign/${id}`, {}, { headers: { Authorization: `Bearer ${token}` } }); + showToast('Task reassigned successfully'); + refreshEvaluationsOverview(); + } catch (error) { + console.error(error); + showToast('Failed to reassign task', 'error'); + } + }; + + const handleOverrideReassignTask = async (id: string) => { + try { + await axios.post( + `http://localhost:${PORT}/api/admin/evaluations/reassign/${id}/override`, + {}, + { headers: { Authorization: `Bearer ${token}` } } + ); + showToast('Reassignment overridden successfully'); + refreshEvaluationsOverview(); + } catch (error) { + console.error(error); + showToast('Failed to override reassignment', 'error'); + } + }; + + const handleEscalateTask = async (id: string) => { + try { + await axios.post(`http://localhost:${PORT}/api/admin/evaluations/escalate/${id}`, {}, { headers: { Authorization: `Bearer ${token}` } }); + showToast('Task escalated to TA/Teacher successfully'); + refreshEvaluationsOverview(); + } catch (error) { + console.error(error); + showToast('Failed to escalate task', 'error'); + } + }; + + const handleDeadlineDraftChange = (id: string, value: string) => { + setDeadlineDrafts((current) => ({ ...current, [id]: value })); + }; + + const handleDeadlineSave = async (id: string) => { + const deadline = deadlineDrafts[id]; + if (!deadline) { + showToast('Please choose a valid deadline', 'error'); + return; + } + + try { + await axios.put( + `http://localhost:${PORT}/api/admin/evaluations/${id}/deadline`, + { deadline }, + { headers: { Authorization: `Bearer ${token}` } } + ); + showToast('Deadline updated successfully'); + refreshEvaluationsOverview(); + } catch (error) { + console.error(error); + showToast('Failed to update deadline', 'error'); + } + }; + + const handleResetDemoData = async () => { + try { + await axios.post( + `http://localhost:${PORT}/api/admin/evaluations/reset-demo-data`, + {}, + { headers: { Authorization: `Bearer ${token}` } } + ); + showToast('Demo data reset successfully'); + setMonitoringFilter('demo'); + refreshEvaluationsOverview(); + } catch (error) { + console.error(error); + showToast('Failed to reset demo data', 'error'); + } + }; + const handleLogout = () => { localStorage.clear(); navigate('/'); @@ -552,6 +782,7 @@ const AdminDashboard = () => { { label: 'Manage Courses', tab: 'course' }, { label: 'Manage Batches', tab: 'batch' }, { label: 'Manage Roles', tab: 'role' }, + { label: 'Monitoring', tab: 'monitoring' }, ].map((btn) => (
+
+ {[ + { label: 'Total Evaluations', value: evaluationsOverview.statistics.total, color: currentPalette['accent-purple'] }, + { label: 'Pending Evaluations', value: evaluationsOverview.statistics.pending, color: currentPalette['accent-lilac'] }, + { label: 'Overdue Evaluations', value: evaluationsOverview.statistics.overdue, color: currentPalette['accent-pink'] }, + { label: 'Escalated Tasks', value: evaluationsOverview.statistics.escalated, color: currentPalette['accent-light-purple'] }, + { label: 'Reassigned Tasks', value: evaluationsOverview.statistics.reassignedCount, color: currentPalette['accent-bright-yellow'] }, + ].map((stat, idx) => ( +
+

{stat.label}

+

{stat.value}

+
+ ))} +
+ + +
+
+ {([ + { key: 'active', label: 'Active First' }, + { key: 'all', label: 'All Tasks' }, + { key: 'demo', label: 'Demo Tasks Only' }, + { key: 'escalated', label: 'Escalated' }, + { key: 'completed', label: 'Completed' }, + ] as { key: MonitoringFilter; label: string }[]).map((filterOption) => { + const isSelected = monitoringFilter === filterOption.key; + + return ( + + ); + })} +
+ +
+ +
+
+

+ Overdue Tasks +

+

+ Tasks past their deadline are highlighted in red and should be reviewed first. +

+
+
+

+ Frequently Reassigned Tasks +

+

+ Tasks with 3 or more reassignments are highlighted in amber for manual attention. +

+
+
+

+ Escalated Tasks +

+

+ Escalated tasks remain visible in this list with a purple status and are no longer auto-reassigned. +

+
+
+ +
+ + + + + + + + + + + + + + {filteredEvaluations.map((evalObj) => { + const isOverdue = evalObj.isOverdue; + const isFreqReassigned = evalObj.isFrequentlyReassigned; + + let rowClasses = "border-b transition-colors "; + if (isOverdue) rowClasses += "bg-red-50 dark:bg-red-900/20 "; + else if (isFreqReassigned) rowClasses += "bg-amber-50 dark:bg-amber-900/20 "; + + const statusBadgeClass = isOverdue + ? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200' + : evalObj.status === 'completed' + ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' + : evalObj.status === 'escalated' + ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' + : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + + return ( + + + + + + + + + + ); + })} + +
Submission IDAssigned StudentStatusDeadlineReassignment CountFlagsAdmin Controls
+ {evalObj.submissionId ? `...${evalObj.submissionId.slice(-6)}` : 'Not found'} + +
{evalObj.assignedStudent?.name || 'Unknown'}
+
+ {evalObj.assignedStudent?.email || 'No email'} +
+
+ + {isOverdue && evalObj.status !== 'completed' ? 'overdue' : evalObj.status} + + + {evalObj.status === 'completed' ? ( +
+
{new Date(evalObj.deadline).toLocaleString()}
+
+ Deadline locked because this task is completed +
+
+ ) : ( +
+ handleDeadlineDraftChange(evalObj._id, e.target.value)} + className="px-3 py-2 rounded-lg border text-sm" + style={{ + backgroundColor: isOverdue ? '#fef2f2' : currentPalette['bg-primary'], + borderColor: isOverdue ? '#ef4444' : currentPalette['border-soft'], + color: isOverdue ? '#b91c1c' : currentPalette['text-dark'], + }} + /> + {isOverdue && ( + + Deadline passed. Review this task first. + + )} + +
+ )} +
+ {evalObj.reassignmentCount} + +
+ {isOverdue && ( + + Overdue task + + )} + {isFreqReassigned && ( + + Frequently reassigned + + )} + {!isOverdue && !isFreqReassigned && ( + + No active flags + + )} +
+
+
+ + + +
+
+ {filteredEvaluations.length === 0 && ( +

+ {monitoringFilter === 'active' + ? 'No active pending or overdue evaluations found right now.' + : monitoringFilter === 'demo' + ? 'No dashboard seed evaluations are visible right now.' + : 'No evaluations found for this filter.'} +

+ )} +
+
+
+ ); default: return null; } @@ -1013,6 +1494,11 @@ const AdminDashboard = () => { } text="Role Manager" /> + + + + } text="Monitoring Center" /> @@ -1043,6 +1529,70 @@ const AdminDashboard = () => { >

{activeTab}

+
+ + + {showNotifications && ( +
+
+

Notifications

+ +
+
+ {notifications.length === 0 ? ( +

+ No notifications +

+ ) : ( + notifications.map((notification) => ( +
!notification.read && markNotificationRead(notification._id)} + className={`p-3 border-b transition-colors ${notification.read ? 'opacity-70' : 'cursor-pointer bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'}`} + style={{ borderColor: currentPalette['border-soft'] }} + > +

+ {notification.message} +

+

+ {new Date(notification.createdAt).toLocaleString()} +

+
+ )) + )} +
+
+ )} +
{/* Dark mode toggle - placed at the bottom right of the main content area */} {/* To ensure it stays absolutely positioned relative to the main content area */}
@@ -1067,7 +1617,10 @@ const AdminDashboard = () => {
+
+
+ {message} +
+
); @@ -61,42 +42,20 @@ export default function Register() { const [confirmPassword, setConfirmPassword] = useState(''); const [name, setName] = useState(''); const [role, setRole] = useState('student'); - const [matchStatus, setMatchStatus] = useState(''); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [showMsg, setShowMsg] = useState(false); const [msgContent, setMsgContent] = useState(''); const [msgType, setMsgType] = useState<'success' | 'error'>('success'); + const navigate = useNavigate(); useEffect(() => { document.documentElement.classList.toggle('dark', darkMode); }, [darkMode]); - const toggleDarkMode = () => setDarkMode(!darkMode); - - const getPasswordStrengthValue = (pwd: string) => { - if (pwd.length === 0) return { level: '', width: '0%', color: 'bg-gray-300' }; - if (pwd.length < 6) return { level: 'Weak', width: '33%', color: 'bg-red-500' }; - if (pwd.match(/[A-Z]/) && pwd.match(/[a-z]/) && pwd.match(/[0-9]/) && pwd.length >= 8) { - return { level: 'Strong', width: '100%', color: 'bg-green-500' }; - } - return { level: 'Medium', width: '66%', color: 'bg-yellow-400' }; - }; - - const strength = getPasswordStrengthValue(password); - - const checkMatch = (val: SetStateAction) => { - setConfirmPassword(val); - if (password && val) { - setMatchStatus(password === val ? 'Matched' : 'Not matched'); - } else { - setMatchStatus(''); - } - }; - - const showMessage = (message: SetStateAction, type: 'success' | 'error' = 'success') => { + const showMessage = (message: string, type: 'success' | 'error' = 'success') => { setMsgContent(message); setMsgType(type); setShowMsg(true); @@ -104,14 +63,33 @@ export default function Register() { const handleRegister = async (e: React.FormEvent) => { e.preventDefault(); - if (password !== confirmPassword) return showMessage('Passwords do not match', 'error'); + + if (password !== confirmPassword) { + return showMessage('Passwords do not match', 'error'); + } try { setIsSubmitting(true); - showMessage('Sending OTP...'); - await axios.post(`http://localhost:${PORT}/api/auth/send`, { email }); - navigate('/otp', { state: { email, password, role, name } }); + showMessage('Registering...'); + + const res = await axios.post( + `http://localhost:${PORT}/api/auth/register`, + { + name, + email, + password, + role + } + ); + + showMessage(res.data.message || 'Registered successfully', 'success'); + + setTimeout(() => { + navigate('/login'); + }, 2000); + } catch (err: any) { + console.error(err); showMessage(err?.response?.data?.message || 'Registration failed', 'error'); } finally { setIsSubmitting(false); @@ -119,130 +97,87 @@ export default function Register() { }; return ( -
+
+ setShowMsg(false)} /> - {/* Decorative blobs */} -
-
- - {/* Home Icon */} - - - - - - - {/* Register Box */} -
-
-
👤
-
-

Create Account

- -
- setName(e.target.value)} className="input-field" required /> - setEmail(e.target.value)} className="input-field" required /> +
+

+ Create Account +

+ + + setName(e.target.value)} + className="w-full p-2 border rounded" + required + /> + + setEmail(e.target.value)} + className="w-full p-2 border rounded" + required + /> - {/* Password Field */}
- setPassword(e.target.value)} className="input-field pr-12" required /> - setShowPassword(!showPassword)} className="eye-icon">{showPassword ? : } + setPassword(e.target.value)} + className="w-full p-2 border rounded pr-10" + required + /> + setShowPassword(!showPassword)} className="absolute right-2 top-2 cursor-pointer"> + {showPassword ? : } +
- {password && ( -
-

Password Strength: {strength.level}

-
-
- )} - - {/* Confirm Password */}
- checkMatch(e.target.value)} className="input-field pr-12" required /> - setShowConfirmPassword(!showConfirmPassword)} className="eye-icon">{showConfirmPassword ? : } + setConfirmPassword(e.target.value)} + className="w-full p-2 border rounded pr-10" + required + /> + setShowConfirmPassword(!showConfirmPassword)} className="absolute right-2 top-2 cursor-pointer"> + {showConfirmPassword ? : } +
- {matchStatus &&

{matchStatus}

} - {/* Role Dropdown */} - setRole(e.target.value)} className="w-full p-2 border rounded"> - {/**/} - {/**/} -

- Already have an account? Login here +

+ Already have an account? Login

- {/* Dark Mode Toggle */} -
- -
- - +
); } \ No newline at end of file diff --git a/Peer_Evaluation_V3_NPTEL/frontend/src/pages/StudentDashboard.tsx b/Peer_Evaluation_V3_NPTEL/frontend/src/pages/StudentDashboard.tsx index 4daee4b6..6b690fc0 100644 --- a/Peer_Evaluation_V3_NPTEL/frontend/src/pages/StudentDashboard.tsx +++ b/Peer_Evaluation_V3_NPTEL/frontend/src/pages/StudentDashboard.tsx @@ -1,10 +1,11 @@ import { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { - FiMenu, FiLogOut, FiHome, FiBook, FiUsers, FiCheckCircle, FiUploadCloud, FiUser, FiSun, FiMoon, + FiMenu, FiLogOut, FiHome, FiBook, FiUsers, FiCheckCircle, FiUploadCloud, FiUser, FiSun, FiMoon, FiBell } from 'react-icons/fi'; import { motion } from "framer-motion"; import axios from 'axios'; +import { useQuery } from '@tanstack/react-query'; import ProfileSection from "../components/student/ProfileSection"; import CourseList from "../components/student/CourseList"; @@ -38,6 +39,18 @@ const darkPalette = { const getColors = (isDarkMode: Boolean) => isDarkMode ? darkPalette : lightPalette; +type NotificationItem = { + _id: string; + message: string; + read: boolean; + status: 'unread' | 'read' | 'dismissed'; + createdAt: string; + relatedResource?: { + type: string; + id: string; + }; +}; + const StudentDashboard = () => { const [token] = useState(localStorage.getItem('token')); const [selectedCourseId, setSelectedCourseId] = useState(null); @@ -47,9 +60,12 @@ const StudentDashboard = () => { const [showSidebar, setShowSidebar] = useState(true); const [logoutDialog, setLogoutDialog] = useState(false); const [showProfilePopup, setShowProfilePopup] = useState(false); + const [showNotifications, setShowNotifications] = useState(false); const [profileData, setProfileData] = useState({ name: "", email: "", role: "", isTA: false }); + const [warningPopup, setWarningPopup] = useState(null); const navigate = useNavigate(); + const location = useLocation(); const currentPalette = getColors(darkMode); useEffect(() => { @@ -57,6 +73,36 @@ const StudentDashboard = () => { else document.documentElement.classList.remove('dark'); }, [darkMode]); + const { data: notifications = [], refetch: refetchNotifications } = useQuery({ + queryKey: ['notifications'], + queryFn: async () => { + const { data } = await axios.get(`http://localhost:${PORT}/api/notifications`, { + headers: { Authorization: `Bearer ${token}` } + }); + return data.notifications || []; + }, + enabled: !!token, + refetchInterval: 30000 // Poll every 30 seconds + }); + const unreadCount = notifications.filter((n) => !n.read).length; + + const markAsRead = async (id: string) => { + await axios.put(`http://localhost:${PORT}/api/notifications/read`, { id }, { + headers: { Authorization: `Bearer ${token}` } + }); + refetchNotifications(); + }; + + const dismissNotification = async (id: string) => { + await axios.put(`http://localhost:${PORT}/api/notifications/dismiss`, { id }, { + headers: { Authorization: `Bearer ${token}` } + }); + if (warningPopup?._id === id) { + setWarningPopup(null); + } + refetchNotifications(); + }; + useEffect(() => { if (!token) navigate('/'); else { @@ -66,6 +112,30 @@ const StudentDashboard = () => { } }, [token, navigate]); + useEffect(() => { + const params = new URLSearchParams(location.search); + const tab = params.get('tab'); + if (tab === 'peerEvaluation') { + setActiveMenu('peerEvaluation'); + setSelectedCourseId(null); + } + }, [location.search]); + + useEffect(() => { + const missedDeadlineNotification = notifications.find( + (notification) => + notification.status === 'unread' && + typeof notification.message === 'string' && + notification.message.includes('missed the evaluation deadline') + ); + + if (missedDeadlineNotification) { + setWarningPopup(missedDeadlineNotification); + } else if (warningPopup && !notifications.some((notification) => notification._id === warningPopup._id)) { + setWarningPopup(null); + } + }, [notifications, warningPopup]); + const handleLogout = () => { localStorage.removeItem("token"); navigate("/login"); @@ -130,12 +200,118 @@ const StudentDashboard = () => { {/* Main Content + Top Bar */}
- {/* Top Right Profile Icon and TA Dashboard Button */} + {warningPopup && ( +
+
+
+

Warning

+

{warningPopup.message}

+

+ {new Date(warningPopup.createdAt).toLocaleString()} +

+
+ +
+
+ )} + {/* Top Right Profile & Notifications Icon */}
+ + {/* Notifications Dropdown */} +
+ + {showNotifications && ( +
+
+

Notifications

+ +
+
+ {notifications.length === 0 ? ( +

No notifications

+ ) : ( + notifications.map((n) => ( +
!n.read && markAsRead(n._id)} + className={`p-3 border-b transition-colors ${n.read ? 'opacity-80' : 'cursor-pointer bg-red-50 hover:bg-red-100'}`} + style={{ borderColor: currentPalette['border-soft'] }}> +
+
+
+ + {n.status} + +
+

{n.message}

+

+ {new Date(n.createdAt).toLocaleString()} +

+
+ {n.status === 'read' && ( + + )} +
+
+ )) + )} +
+
+ )} +
+ {/* Profile Icon */}
+ + +
+
+ +
+ +
+

Students & Workload

+ + + + + + + + + + + + +
StudentActive LoadMissedStatus
+
+ + +
+

Evaluation Tasks

+ + + + + + + + + + + + +
Submission IDAssigned ToReassignedStatus
+
+ + +
+

System Notifications & Alerts

+
+ +
+
+ +
+

Action Logs

+
+
+
+
+ + + + diff --git a/peer_eval_scheduler_demo/services/evaluationService.js b/peer_eval_scheduler_demo/services/evaluationService.js new file mode 100644 index 00000000..b3c42523 --- /dev/null +++ b/peer_eval_scheduler_demo/services/evaluationService.js @@ -0,0 +1,132 @@ +const EvaluationTask = require('../models/EvaluationTask'); +const Student = require('../models/Student'); +const Notification = require('../models/Notification'); + +class EvaluationService { + + /** + * Reassigns an overdue paper to another student using the "Least Load First" strategy. + * Also applies accountability rules to the old student. + */ + async reassignTask(taskId) { + // 1. Fetch Task + const task = await EvaluationTask.findById(taskId).populate('assignedTo'); + if (!task || task.status !== 'pending') return; + + const currentStudent = task.assignedTo; + + // 2. Prevent infinite reassignment loops + if (task.reassignmentCount >= 3) { + await this.escalateToTA(task); + return; + } + + // 3. Accountability System (Penalize old student) + await this.applyAccountabilityPenalty(currentStudent); + + // 4. Update Task History (avoid assigning back to anyone who previously had it) + task.evaluatorsHistory.push(currentStudent._id); + + // 5. Auto Reassignment Logic (Least Load First) + const newAssignee = await this.findStudentForReassignment(task.evaluatorsHistory); + + if (!newAssignee) { + await this.escalateToTA(task, "No eligible student found for reassignment."); + return; + } + + // 6. Complete Reassignment + + // Decrement load of old student + await Student.findByIdAndUpdate(currentStudent._id, { $inc: { assignedEvaluationsCount: -1 } }); + + // Increment load of new student + await Student.findByIdAndUpdate(newAssignee._id, { $inc: { assignedEvaluationsCount: 1 } }); + + // Update the task data + task.assignedTo = newAssignee._id; + task.reassignmentCount += 1; + task.deadline = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); // Give 3 more days + task.reminderSent = false; // Reset reminder for new student + + await task.save(); + + // 7. Notify new assignee + await Notification.create({ + userId: newAssignee._id, + message: "You have been assigned a new evaluation task due to a reassignment.", + type: "info" + }); + } + + /** + * Select a new student based on: + * 1. Not in evaluatorsHistory + * 2. Not restricted (too many missed deadlines) + * 3. Least assignedEvaluationsCount (Priority 1) + * 4. Lesser missedDeadlines (Priority 2) + */ + async findStudentForReassignment(evaluatorsHistory) { + const students = await Student.find({ + _id: { $nin: evaluatorsHistory }, + isRestricted: false + }) + .sort({ + assignedEvaluationsCount: 1, // Ascending (Least Load First) + missedDeadlines: 1 // Ascending (Prefer fewer missed deadlines) + }) + .limit(1); + + return students.length > 0 ? students[0] : null; + } + + /** + * Apply penalty rules when deadlines are missed + */ + async applyAccountabilityPenalty(student) { + student.missedDeadlines += 1; + + let warningMessage = ""; + let warningType = "warning"; + + if (student.missedDeadlines === 1) { + student.warnings += 1; + warningMessage = "Warning: You missed an evaluation deadline. Please be careful next time."; + } + else if (student.missedDeadlines === 2) { + student.warnings += 1; + warningMessage = "Strong Warning: You have missed 2 deadlines. Further misses will restrict your privileges."; + warningType = "escalation"; // Send a stronger warning + } + else if (student.missedDeadlines >= 3) { + student.isRestricted = true; // Restrict privileges + warningMessage = "Notice: You missed 3+ deadlines. Your privileges have been restricted and the teacher has been notified."; + warningType = "escalation"; + } + + await student.save(); + + if (warningMessage) { + await Notification.create({ + userId: student._id, + message: warningMessage, + type: warningType + }); + } + } + + /** + * If task cannot be reassigned anymore, escalate to TA/teacher. + */ + async escalateToTA(task, reason = "Maximum reassignment attempts reached.") { + task.status = 'overdue'; + await task.save(); + + // Here you would notify TA or Admin instead of a specific student + // e.g., Sending email to TA or inserting specialized notification DB record. + console.log(`[ESCALATION] Task ${task._id} escalated to TA. Reason: ${reason}`); + } + +} + +module.exports = new EvaluationService();