diff --git a/client/src/pages/A1InternshipRequestForm.js b/client/src/pages/A1InternshipRequestForm.js index d5e08f69..61e0695a 100644 --- a/client/src/pages/A1InternshipRequestForm.js +++ b/client/src/pages/A1InternshipRequestForm.js @@ -252,10 +252,11 @@ const A1InternshipRequestForm = ({ userRole = "student" }) => { const submitFormData = async () => { try { - const submissionPayload = { - studentName: formData.interneeName, - soonerId: formData.soonerId, - studentEmail: formData.interneeEmail, + // Fetch logged-in student ID from localStorage or context + const studentId = localStorage.getItem("studentId"); // You must store this during login + + const payload = { + student: studentId, workplace: { name: formData.workplaceName, website: formData.website, @@ -271,15 +272,13 @@ const A1InternshipRequestForm = ({ userRole = "student" }) => { endDate: formData.endDate, tasks: formData.tasks, status: "submitted", + approvals: ["advisor"] }; - console.log("Submitting payload:", submissionPayload); - - + const response = await fetch(`${process.env.REACT_APP_API_URL}/api/form/submit`, { method: "POST", headers: { "Content-Type": "application/json" - }, body: JSON.stringify(submissionPayload), }); @@ -293,6 +292,7 @@ const A1InternshipRequestForm = ({ userRole = "student" }) => { throw error; } }; + const sendTaskDescriptions = async (descriptions) => { try { diff --git a/client/src/pages/Home.js b/client/src/pages/Home.js index ffe18418..171453ef 100644 --- a/client/src/pages/Home.js +++ b/client/src/pages/Home.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import "../styles/App.css"; import { FaEnvelope, FaLock, FaEye, FaEyeSlash } from "react-icons/fa"; @@ -13,28 +13,20 @@ function Home() { const [formData, setFormData] = useState({ email: "", password: "", - role: "student", }); const [showPassword, setShowPassword] = useState(false); - const [role] = useState("student"); - - // Sync role into formData.role - useEffect(() => { - setFormData((prev) => ({ ...prev, role })); - }, [role]); const handleInputChange = (e) => { const { name, value } = e.target; - setFormData({ - ...formData, + setFormData((prev) => ({ + ...prev, [name]: value, - }); + })); }; const handleSubmit = async (e) => { e.preventDefault(); - const { email: ouEmail, password, role } = formData; @@ -55,7 +47,7 @@ function Home() { "Content-Type": "application/json", }, body: JSON.stringify({ ouEmail, password, role }), - }, + } ); const data = await response.json(); @@ -64,22 +56,29 @@ function Home() { const user = data.user; if(role === "student"){ // Store only required fields - const limitedUserInfo = { - fullName: user.fullName, - id: user._id, - email:user.ouEmail - }; - - localStorage.setItem("ipmsUser", JSON.stringify(limitedUserInfo)); - navigate("/student-dashboard"); - }else if(role === "supervisor"){ + const limitedUserInfo = { + fullName: user.fullName, + id: user._id, + email:user.ouEmail + }; + + localStorage.setItem("ipmsUser", JSON.stringify(limitedUserInfo)); + navigate("/student-dashboard"); + } else if(role === "supervisor"){ Swal.fire({ icon: "success", title: "Login Successful 🌟", text: `Welcome back, ${role}!`, }); navigate("/supervisor-dashboard"); - }else{ + } else if (role === "coordinator") { + Swal.fire({ + icon: "success", + title: "Login Successful 🌟", + text: `Welcome back, ${role}!`, + }); + navigate("/coordinator-dashboard"); + } else{ Swal.fire({ icon: "success", title: "Login Successful 🌟", @@ -101,6 +100,7 @@ function Home() { Swal.fire({ icon: "error", title: "Login Failed", + text: data.message || "Something went wrong", html: data.message + " " + (data.renewalLink ? `Please click here to request a new token.` @@ -125,9 +125,7 @@ function Home() {
-

- Welcome back -

+

Welcome back

@@ -146,10 +144,7 @@ function Home() { formData.role === r ? "selected" : "" }`} onClick={() => - setFormData({ - ...formData, - role: r, - }) + setFormData((prev) => ({ ...prev, role: r })) } > @@ -157,6 +152,16 @@ function Home() {

{r.charAt(0).toUpperCase() + r.slice(1)}

+
))}
@@ -164,9 +169,7 @@ function Home() {
@@ -206,15 +207,7 @@ function Home() {
-
+
); }; diff --git a/client/src/styles/CoordinatorDashboard.css b/client/src/styles/CoordinatorDashboard.css new file mode 100644 index 00000000..e69de29b diff --git a/client/src/styles/StudentDashboard.css b/client/src/styles/StudentDashboard.css index 71975b3e..086c76c9 100644 --- a/client/src/styles/StudentDashboard.css +++ b/client/src/styles/StudentDashboard.css @@ -68,4 +68,4 @@ .card-button:hover { background-color: #e6e6e6; } - \ No newline at end of file + diff --git a/package.json b/package.json new file mode 100644 index 00000000..b5a9fdb2 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "dependencies": { + "bootstrap": "^5.3.5", + "jsonwebtoken": "^9.0.2", + "react-bootstrap": "^2.10.9", + "react-icons": "^5.5.0", + "react-signature-canvas": "^1.1.0-alpha.2", + "sweetalert2": "^11.17.2" + } +} diff --git a/server/controllers/approvalController.js b/server/controllers/approvalController.js index 0ef1fa07..c165326b 100644 --- a/server/controllers/approvalController.js +++ b/server/controllers/approvalController.js @@ -7,63 +7,54 @@ const UserTokenRequest = require("../models/TokenRequest"); // =========================================== // // Managing Supervisor Forms // // =========================================== // +const getSupervisorForms = async (req, res, filter) => { + try { + const InternshipRequest = require("../models/InternshipRequest"); + const WeeklyReport = require("../models/WeeklyReport"); + const Evaluation = require("../models/Evaluation"); -exports.getSupervisorForms = async (req, res, filter) => { - try { // ---------------------------- // Fetching A1 Form // ---------------------------- - const requests = await InternshipRequest.find(filter) - .populate("_id", "fullName ouEmail"); - - const typedRequests = requests.map(req => ({ - ...req.toObject(), // convert Mongoose doc to plain JS object - form_type: "A1" // add the custom type - })); + const a1Forms = await InternshipRequest.find(filter).populate("student", "fullName ouEmail soonerId"); + const typedA1 = a1Forms.map((form) => ({ + ...form.toObject(), + form_type: "A1", + })); // ---------------------------- // Fetching A2 Form // ---------------------------- - const reports = await WeeklyReport.find(filter) - .populate("student_id", "fullName ouEmail"); - - // Adding custom type to A2 Form - const typedReports = reports.map(report => ({ - ...report.toObject(), // convert Mongoose doc to plain JS object - form_type: "A2" // add the custom type - })); + const a2Forms = await WeeklyReport.find(filter).populate("student_id", "fullName ouEmail soonerId"); + const typedA2 = a2Forms.map((form) => ({ + ...form.toObject(), + form_type: "A2", + })); // ---------------------------- // Fetching A3 Form // ---------------------------- - const evaluations = await Evaluation.find(filter) - .populate("student_id", "fullName ouEmail"); - - // Adding custom type to A3 Form - const typedEvaluations = evaluations.map(evaluation => ({ - ...evaluation.toObject(), // convert Mongoose doc to plain JS object - form_type: "A3" // add the custom type - })); - + const a3Forms = await Evaluation.find(filter).populate("student_id", "fullName ouEmail soonerId"); + const typedA3 = a3Forms.map((form) => ({ + ...form.toObject(), + form_type: "A3", + })); + // ---------------------------- // Combine forms // ---------------------------- - const allRequests = [...typedRequests, ...typedReports, ...typedEvaluations]; - - // Sort by createdAt date - allRequests.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); - - // Send response - res.status(200).json(allRequests); - } catch (err) { - res.status(500).json({ - message: "Failed to fetch internship requests", - error: err.message, - }); - } -} + const allForms = [...typedA1, ...typedA2, ...typedA3]; + //Sort by createdAt date + allForms.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + //Send response + return res.status(200).json(allForms); + } catch (err) { + console.error("Error in getSupervisorForms:", err.message); + return res.status(500).json({ message: "Failed to fetch supervisor forms", error: err.message }); + } +}; -exports.handleSupervisorFormAction = async (req, res, action) => { +const handleSupervisorFormAction = async (req, res, action) => { try { const form_type = req.params.type; const formId = req.params.id; @@ -89,96 +80,88 @@ exports.handleSupervisorFormAction = async (req, res, action) => { supervisor_comment: comment, }; - const form = await FormModel.findByIdAndUpdate(formId, update, { new: true }).populate("student_id", "userName email"); + const form = await FormModel.findByIdAndUpdate(formId, update, { new: true }) + .populate("student_id", "userName email"); if (!form) { return res.status(404).json({ message: "Form not found" }); } - const studentEmail = - form.student_id?.email || - form.interneeEmail || - form.studentEmail || - null; - - if (!studentEmail) { - console.warn("⚠️ No student email found for form:", form._id); - } else { - const emailSubject = `Form ${action === "approve" ? "Approved" : "Rejected"}`; - let emailBody = `

Your ${form_type} form has been ${action}ed by the supervisor.

`; - if (comment) { - emailBody += `

Comment: ${comment}

`; - } + const studentEmail = form.student_id?.email || form.interneeEmail || form.studentEmail || null; + let emailSubject = `Form ${action === "approve" ? "Approved" : "Rejected"}`; + let emailBody = `

Your ${form_type} form has been ${action}ed by the supervisor.

`; + if (comment) { + emailBody += `

Comment: ${comment}

`; } const student_id = form.student_id || form.internee_id || form.student; const student = await UserTokenRequest.findById(student_id); const student_mail = student?.ouEmail || form?.interneeEmail; - try { - await EmailService.sendEmail({ - to: student_mail, - subject: emailSubject, - html: emailBody, - }); + try { + await EmailService.sendEmail({ + to: student_mail, + subject: emailSubject, + html: emailBody, + }); } catch (err) { - console.error("Email sending error:", err); + console.error("Email sending error:", err); } console.log("Email sent to:", student_mail); - - res.status(200).json({ - message: `Form ${action}ed successfully`, - updatedForm: form, - }); + res.status(200).json({ message: `Form ${action}ed successfully`, updatedForm: form }); } catch (err) { console.error("SupervisorFormAction error:", err); res.status(500).json({ message: "Error processing form", error: err.message }); } }; -// =========================================== // -// Coordinator Dashboard // -// =========================================== // - -exports.getCoordinatorRequests = async (req, res) => { - try { +const getCoordinatorRequests = async (req, res) => { + try { const requests = await InternshipRequest.find({ coordinator_status: "pending", }).populate("student", "userName email"); + res.status(200).json(requests); } catch (err) { res.status(500).json({ message: "Failed to fetch requests" }); } }; -// Coordinator View Single Request -exports.getCoordinatorRequestDetails = async (req, res) => { +const getCoordinatorRequestDetails = async (req, res) => { try { - const requestData = await InternshipRequest.findById(req.params.id).lean(); + const requestData = await InternshipRequest.findById(req.params.id) + .populate("student", "userName email") + .lean(); + if (!requestData) { return res.status(404).json({ message: "Request not found" }); } - res.status(200).json({ requestData, supervisorStatus: "Not Submitted" }); + const supervisorStatus = requestData.supervisor_status || "Not Submitted"; + + res.status(200).json({ requestData, supervisorStatus }); } catch (err) { res.status(500).json({ message: "Failed to fetch details" }); } }; -// Coordinator Approve Request -exports.coordinatorApproveRequest = async (req, res) => { +const coordinatorApproveRequest = async (req, res) => { try { const request = await InternshipRequest.findByIdAndUpdate( req.params.id, - { coordinator_status: "approved" }, + { status: "approved" }, { new: true } - ); + ).populate("student", "userName email"); if (!request) { return res.status(404).json({ message: "Request not found" }); } + request.coordinator_status = "Approved"; + request.coordinator_comment = "Approved by Coordinator"; + await request.save(); + await EmailService.sendEmail({ to: request.student.email, subject: "Internship Request Approved", @@ -191,22 +174,25 @@ exports.coordinatorApproveRequest = async (req, res) => { } }; -// Coordinator Reject Request -exports.coordinatorRejectRequest = async (req, res) => { +const coordinatorRejectRequest = async (req, res) => { const { reason } = req.body; if (!reason) return res.status(400).json({ message: "Reason required" }); try { const request = await InternshipRequest.findByIdAndUpdate( req.params.id, - { coordinator_status: "rejected" }, + { status: "rejected" }, { new: true } - ); + ).populate("student", "userName email"); if (!request) { return res.status(404).json({ message: "Request not found" }); } + request.coordinator_status = "Rejected"; + request.coordinator_comment = reason; + await request.save(); + await EmailService.sendEmail({ to: request.student.email, subject: "Internship Request Rejected", @@ -218,3 +204,145 @@ exports.coordinatorRejectRequest = async (req, res) => { res.status(500).json({ message: "Rejection failed", error: err.message }); } }; + +const coordinatorResendRequest = async (req, res) => { + try { + const submission = await InternshipRequest.findById(req.params.id); + if (!submission) + return res.status(404).json({ message: "Submission not found" }); + + submission.coordinator_reminder_count = 0; + submission.last_coordinator_reminder_at = new Date(); + submission.coordinator_status = "pending"; + await submission.save(); + + return res.status(200).json({ message: "Reminder cycle restarted." }); + } catch (error) { + console.error("Error in coordinatorResendRequest:", error); + return res.status(500).json({ message: "Server error while resending request." }); + } +}; + +const deleteStudentSubmission = async (req, res) => { + try { + const { id } = req.params; + const studentId = req.user._id; + + const submission = await InternshipRequest.findById(id); + if (!submission) + return res.status(404).json({ message: "Submission not found." }); + + if (submission.student.toString() !== studentId.toString()) { + return res.status(403).json({ message: "You are not authorized to delete this submission." }); + } + + if (submission.coordinator_status !== "pending") { + return res.status(400).json({ message: "Submission already reviewed. Cannot delete." }); + } + + await InternshipRequest.findByIdAndDelete(id); + return res.status(200).json({ message: "Submission successfully deleted by student." }); + } catch (err) { + console.error("Error deleting student submission:", err); + return res.status(500).json({ message: "Internal server error." }); + } +}; + +const getStudentSubmissions = async (req, res) => { + try { + const studentId = req.user._id; + const submissions = await InternshipRequest.find({ student: studentId }).sort({ createdAt: -1 }); + res.status(200).json(submissions); + } catch (error) { + console.error("Error fetching student submissions:", error); + res.status(500).json({ message: "Failed to fetch submissions." }); + } +}; + +const getPendingSubmissions = async (req, res) => { + try { + const pendingRequests = await InternshipRequest.find({ + supervisor_status: "pending", + }).populate("student", "fullName ouEmail"); + + res.status(200).json(pendingRequests); + } catch (err) { + res.status(500).json({ + message: "Failed to fetch pending supervisor submissions", + error: err.message, + }); + } +}; + +const approveSubmission = async (req, res) => { + const { id } = req.params; + const { comment } = req.body; + try { + const request = await InternshipRequest.findByIdAndUpdate( + id, + { supervisor_status: "approved", supervisor_comment: comment || "" }, + { new: true } + ); + if (!request) return res.status(404).json({ message: "Submission not found" }); + + res.json({ message: "Submission approved", updated: request }); + } catch (err) { + res.status(500).json({ message: "Approval failed", error: err.message }); + } +}; + +const rejectSubmission = async (req, res) => { + const { id } = req.params; + const { comment } = req.body; + try { + const request = await InternshipRequest.findByIdAndUpdate( + id, + { supervisor_status: "rejected", supervisor_comment: comment || "" }, + { new: true } + ); + if (!request) return res.status(404).json({ message: "Submission not found" }); + + res.json({ message: "Submission rejected", updated: request }); + } catch (err) { + res.status(500).json({ message: "Rejection failed", error: err.message }); + } +}; + +const deleteStalledSubmission = async (req, res) => { + try { + const { id } = req.params; + + const submission = await InternshipRequest.findById(id); + if (!submission) { + return res.status(404).json({ message: "Submission not found." }); + } + + if (submission.coordinator_status !== "pending") { + return res.status(400).json({ message: "Submission already reviewed. Cannot delete." }); + } + + await InternshipRequest.findByIdAndDelete(id); + + return res.status(200).json({ message: "Submission deleted successfully." }); + } catch (error) { + console.error("Error deleting submission:", error); + return res.status(500).json({ message: "Internal server error" }); + } +}; + + +module.exports = { + getCoordinatorRequests, + getCoordinatorRequestDetails, + coordinatorApproveRequest, + coordinatorRejectRequest, + coordinatorResendRequest, + deleteStudentSubmission, + getStudentSubmissions, + getPendingSubmissions, + getSupervisorForms, + handleSupervisorFormAction, + approveSubmission, + rejectSubmission, + deleteStalledSubmission, +}; diff --git a/server/index.js b/server/index.js index 80b7fe97..fbc3f98c 100644 --- a/server/index.js +++ b/server/index.js @@ -5,6 +5,7 @@ const express = require("express"); const mongoose = require("mongoose"); const cors = require("cors"); const User = require("./models/User"); + const formRoutes = require("./routes/formRoutes"); const emailRoutes = require("./routes/emailRoutes"); @@ -12,8 +13,11 @@ const tokenRoutes = require("./routes/token"); const approvalRoutes = require("./routes/approvalRoutes"); const studentRoutes = require("./routes/studentRoutes"); + const outcomeRoutes = require("./routes/outcomeRoutes"); + + // Import cron job manager and register jobs const cronJobManager = require("./utils/cronUtils").cronJobManager; const { registerAllJobs } = require("./jobs/registerCronJobs"); @@ -22,6 +26,9 @@ const fourWeekReportRoutes = require("./routes/fourWeekReportRoutes"); const path = require("path"); +const cronJobRoutes = require("./routes/cronJobRoutes"); + + const app = express(); app.use(express.json()); app.use(cors()); @@ -139,13 +146,11 @@ app.post("/api/evaluation", async (req, res) => { } }); - - - //Form A.4 const presentationRoutes = require("./routes/presentationRoutes"); app.use("/api/presentation", presentationRoutes); +// Graceful shutdown (async Mongoose support) process.on("SIGINT", async () => { try { cronJobManager.stopAllJobs(); @@ -158,5 +163,6 @@ process.on("SIGINT", async () => { } }); + const PORT = process.env.PORT || 5001; app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); \ No newline at end of file diff --git a/server/jobs/reminderEmail.js b/server/jobs/reminderEmail.js index c0435d25..a8b1a6f2 100644 --- a/server/jobs/reminderEmail.js +++ b/server/jobs/reminderEmail.js @@ -1,65 +1,76 @@ const emailService = require("../services/emailService"); -const Submission = require("../models/InternshipRequest"); +// const Submission = require("../models/InternshipRequest"); // ❌ Remove this const NotificationLog = require("../models/NotifLog"); const User = require("../models/User"); +const UserTokenRequest = require("../models/TokenRequest"); +const logger = require("../utils/logger"); const WeeklyReport = require("../models/WeeklyReport"); const SupervisorReview = require("../models/SupervisorReview"); const InternshipRequest = require("../models/InternshipRequest"); -const UserTokenRequest = require("../models/TokenRequest"); -const logger = require("../utils/logger"); const dayjs = require("dayjs"); -// Coordinator reminder: weekly report reviewed by supervisor but not yet commented by coordinator +// ================= Coordinator Reminder ================= const coordinatorReminder = async () => { const now = dayjs(); + const fiveWorkingDays = now.subtract(7, "day").toDate(); + try { - const supervisorReviews = await SupervisorReview.find({}); - - for (const review of supervisorReviews) { - const { studentId, weeks } = review; - const reports = await WeeklyReport.find({ - studentId, - week: { $in: weeks }, - }); - - const allCoordinatorCommentsMissing = reports.every( - (r) => !r.coordinatorComments || r.coordinatorComments.trim() === "" - ); - - if (!allCoordinatorCommentsMissing) continue; - - const coordinatorEmail = reports[0]?.coordinatorEmail; - const studentEmail = reports[0]?.email; - - const internship = await InternshipRequest.findOne({ - email: studentEmail, - }); - if (!internship || dayjs().isAfter(dayjs(internship.endDate))) continue; - - await emailService.sendEmail({ - to: coordinatorEmail, - subject: `Reminder: Coordinator Review Pending (Weeks ${weeks.join( - ", " - )})`, - html: `

Supervisor has reviewed weeks ${weeks.join( - ", " - )}.

-

Please add your coordinator comments in IPMS dashboard before the internship ends.

`, - text: `Reminder to review weeks ${weeks.join(", ")} as coordinator.`, - }); - - logger.info( - `[Reminder Sent] Coordinator: "${coordinatorEmail}" for weeks: ${weeks.join( - ", " - )}` - ); + const pendingSubs = await InternshipRequest.find({ + coordinator_status: "pending", + supervisor_status: "approved", + createdAt: { $lt: fiveWorkingDays }, + }); + + for (const submission of pendingSubs) { + const student = await User.findById(submission.student_id); + const coordinator = await User.findById(submission.coordinator_id); + + const reminderCount = submission.coordinator_reminder_count || 0; + const lastReminded = submission.last_coordinator_reminder_at || submission.createdAt; + const nextReminderDue = dayjs(lastReminded).add(5, "day"); + const shouldRemindAgain = now.isAfter(nextReminderDue); + + if (reminderCount >= 2 && shouldRemindAgain && !submission.studentNotified) { + await emailService.sendEmail({ + to: student.email, + subject: `Coordinator Not Responding for "${submission.name}"`, + html: `

Your submission "${submission.name}" has not been approved by the coordinator even after 2 reminders.

+

You can now choose to resend or delete the request.

`, + text: `Your submission "${submission.name}" is still awaiting coordinator approval.`, + }); + + await NotificationLog.create({ + submissionId: submission._id, + type: "studentEscalation", + recipientEmail: student.email, + message: `Student notified about stalled coordinator approval for "${submission.name}"`, + }); + + submission.studentNotified = true; + await submission.save(); + + logger.info(`🔔 Escalation: student notified for "${submission.name}"`); + } else if (shouldRemindAgain) { + await emailService.sendEmail({ + to: coordinator.email, + subject: `Reminder: Please Approve Submission "${submission.name}"`, + html: `

This is a reminder to review and approve the internship submission by ${submission.student_name}.

`, + text: `Reminder to approve submission "${submission.name}".`, + }); + + submission.coordinator_reminder_count = reminderCount + 1; + submission.last_coordinator_reminder_at = new Date(); + await submission.save(); + + logger.info(`📧 Reminder sent to coordinator for "${submission.name}"`); + } } } catch (err) { - logger.error("[CoordinatorReminder Error]:", err.message || err); + logger.error("❌ Error in coordinatorReminder:", err.message); } }; -// Utility to get all forms of type A1, A2, A3 +// ================= Supervisor Reminder ================= const getAllForms = async (filter = {}) => { const models = { A1: require("../models/InternshipRequest"), @@ -67,26 +78,22 @@ const getAllForms = async (filter = {}) => { A3: require("../models/Evaluation"), }; - const formPromises = Object.entries(models).map( - async ([form_type, Model]) => { - const results = await Model.find(filter); - return results; - } - ); + const formPromises = Object.entries(models).map(async ([form_type, Model]) => { + return await Model.find(filter); + }); const allResults = await Promise.all(formPromises); return allResults.flat(); }; -// Supervisor reminder: weekly progress reports pending review const supervisorReminder = async () => { const now = dayjs(); - const fiveWorkingDaysAgo = now.subtract(7, "day").toDate(); + const fiveWorkingDays = now.subtract(7, "day").toDate(); try { - const pendingSubs = await Submission.find({ + const pendingSubs = await getAllForms({ supervisor_status: "pending", - createdAt: { $lt: fiveWorkingDaysAgo }, + last_supervisor_reminder_at: { $lt: fiveWorkingDays }, }); const supervisors = await UserTokenRequest.find({ @@ -95,40 +102,35 @@ const supervisorReminder = async () => { }); for (const submission of pendingSubs) { - const student = await User.findById(submission.student_id); - const supervisor = await User.findById(submission.supervisor_id); - - if (!student || !supervisor) continue; - + const student = await UserTokenRequest.findById(submission.student_id); const reminderCount = submission.supervisor_reminder_count || 0; - const lastReminded = - submission.last_supervisor_reminder_at || submission.createdAt; + const lastReminded = submission.last_supervisor_reminder_at || submission.createdAt; const nextReminderDue = dayjs(lastReminded).add(5, "day"); const shouldRemindAgain = now.isAfter(nextReminderDue); if (reminderCount >= 2 && shouldRemindAgain) { await emailService.sendEmail({ - to: student.email, - subject: `Supervisor Not Responding for "${submission.name}"`, - html: `

Your submission "${submission.name}" has not been reviewed by your supervisor after multiple reminders.

-

Please consider resending or deleting the request.

`, - text: `Your submission "${submission.name}" is still awaiting supervisor review.`, + to: student.ouEmail, + subject: `Supervisor Not Responding for "${submission._id}"`, + html: `

Your submission "${submission._id}" has not been reviewed by the supervisor after multiple reminders.

+

Please consider resending the form or deleting the request.

`, + text: `Your submission "${submission._id}" is still awaiting supervisor review.`, }); await NotificationLog.create({ - submissionId: submission._id, + submission_id: submission._id, type: "studentEscalation", - recipientEmail: student.email, - message: `Student notified about supervisor inaction for "${submission.name}".`, + recipient_email: student.ouEmail, + message: `Student notified about supervisor status on: "${submission._id}"`, }); - logger.info(`[Escalated] Student notified for: "${submission.name}"`); + logger.info(`Returned to student for resubmit/delete: "${submission._id}"`); } else if (shouldRemindAgain) { - for (const sup of supervisors) { + for (const supervisor of supervisors) { await emailService.sendEmail({ - to: sup.ouEmail, + to: supervisor.ouEmail, subject: `Reminder: Please Review Submission "${submission._id}"`, - html: `

This is a reminder to review the submission by ${student.email}.

`, + html: `

This is a reminder to review the submission by ${student.ouEmail}.

`, text: `Reminder to review submission "${submission._id}".`, }); } @@ -142,13 +144,11 @@ const supervisorReminder = async () => { logger.error(`Failed to save submission: ${err.message}`); } - logger.info( - `[Reminder Sent] Supervisor: "${supervisor.email}" for "${submission.name}"` - ); + logger.info(`Reminder sent to supervisor for "${submission._id}"`); } } } catch (err) { - logger.error("[SupervisorReminder Error]:", err.message || err); + logger.error("Error in supervisorReminder:", err.message); } }; diff --git a/server/middleware/authMiddleware.js b/server/middleware/authMiddleware.js index 6ea8cb9c..736b2f6e 100644 --- a/server/middleware/authMiddleware.js +++ b/server/middleware/authMiddleware.js @@ -1,9 +1,8 @@ const User = require("../models/User"); const UserTokenRequest = require("../models/TokenRequest"); -exports.isSupervisor = (req, res, next) => { - // const supervisor = Sup.find({$id: username}) - +// 🔹 Supervisor Middleware +const isSupervisor = (req, res, next) => { req.user = { role: "supervisor" }; // Mocking user role for demo if (req.user.role === "supervisor") { next(); @@ -12,42 +11,30 @@ exports.isSupervisor = (req, res, next) => { } }; -/* - // This is token management if we'll use it in the future -exports.isSupervisor = async (req, res, next) => { - try { - // Token management - const raw = req.headers.authorization?.split(" ")[1]; // "Bearer " - const token = raw.replace(/^"|"$/g, ""); // removes surrounding quotes - - if (!token) { - return res.status(401).json({ message: "No token provided" }); - } - - const tokenEntry = await UserTokenRequest.findOne({ token }); - if (!tokenEntry) { - return res.status(401).json({ message: "Invalid or expired token" }); - } - - if (tokenEntry.role !== "supervisor") { - return res.status(403).json({ message: "Access denied. Not a supervisor." }); - } - - req.user = tokenEntry; // make user info available to routes - next(); - } catch (err) { - console.error("Supervisor auth error:", err); - res.status(500).json({ message: "Internal server error" }); - } -}; -*/ - -exports.isCoordinator = (req, res, next) => { - req.user = { role: "coordinator" }; // Mocking role for now (or fetch from DB if implemented) - +// 🔹 Coordinator Middleware +const isCoordinator = (req, res, next) => { + req.user = { role: "coordinator" }; // Mocking user role for demo if (req.user.role === "coordinator") { next(); } else { res.status(403).json({ message: "Access denied. Not a coordinator." }); } }; + +// 🔹 Student Middleware +const isStudent = (req, res, next) => { + const ipmsUser = JSON.parse(req.headers["ipms-user"] || "{}"); + if (ipmsUser && ipmsUser.role === "student") { + req.user = ipmsUser; // Includes _id + next(); + } else { + res.status(403).json({ message: "Student access denied" }); + } +}; + +// Export all properly +module.exports = { + isSupervisor, + isCoordinator, + isStudent, +}; diff --git a/server/models/InternshipRequest.js b/server/models/InternshipRequest.js index 68dced0e..58232bbe 100644 --- a/server/models/InternshipRequest.js +++ b/server/models/InternshipRequest.js @@ -49,6 +49,20 @@ const formA1 = new mongoose.Schema({ required: true, enum: [1, 2, 3] }, + + requestedAt: { + type: Date, + default: Date.now, + }, + coordinatorResponded: { + type: Boolean, + default: false, + }, + studentNotified: { + type: Boolean, + default: false, + }, + startDate: { type: Date, required: true @@ -79,4 +93,6 @@ formA1.virtual("requiredHours").get(function() { return this.creditHours * 60; }) -module.exports = mongoose.models.InternshipRequest || mongoose.model("InternshipRequest", formA1); \ No newline at end of file +module.exports = + mongoose.models.InternshipRequest || + mongoose.model("InternshipRequest", formA1); diff --git a/server/routes/approvalRoutes.js b/server/routes/approvalRoutes.js index ca23a75b..add290b1 100644 --- a/server/routes/approvalRoutes.js +++ b/server/routes/approvalRoutes.js @@ -1,7 +1,7 @@ const express = require("express"); const router = express.Router(); const { isSupervisor, isCoordinator } = require("../middleware/authMiddleware"); - +const { isSupervisor, isCoordinator, isStudent } = require("../middleware/authMiddleware"); const { getSupervisorForms, handleSupervisorFormAction, @@ -9,8 +9,23 @@ const { getCoordinatorRequestDetails, coordinatorApproveRequest, coordinatorRejectRequest, + getStudentSubmissions, + getPendingSubmissions, + coordinatorResendRequest, + deleteStalledSubmission, + deleteStudentSubmission, + rejectSubmission, + approveSubmission, } = require("../controllers/approvalController"); +// Student API +router.get("/student/submissions", isStudent, getStudentSubmissions); +router.delete("/student/request/:id/delete", isStudent, deleteStudentSubmission); + +// Supervisor APIs +router.get("/submissions/pending", isSupervisor, getPendingSubmissions); +router.post("/submissions/:id/approve", isSupervisor, approveSubmission); +router.post("/submissions/:id/reject", isSupervisor, rejectSubmission); // =========================================== // @@ -42,22 +57,15 @@ router.post("/supervisor/form/:type/:id/reject", isSupervisor, (req, res) => // Coordinator Approval Routes // // =========================================== // + // Coordinator APIs router.get("/coordinator/requests", isCoordinator, getCoordinatorRequests); -router.get( - "/coordinator/request/:id", - isCoordinator, - getCoordinatorRequestDetails -); -router.post( - "/coordinator/request/:id/approve", - isCoordinator, - coordinatorApproveRequest -); -router.post( - "/coordinator/request/:id/reject", - isCoordinator, - coordinatorRejectRequest -); + +router.get("/coordinator/request/:id", isCoordinator, getCoordinatorRequestDetails); +router.post("/coordinator/request/:id/approve", isCoordinator, coordinatorApproveRequest); +router.post("/coordinator/request/:id/reject", isCoordinator, coordinatorRejectRequest); +router.post("/coordinator/request/:id/resend", isCoordinator, coordinatorResendRequest); +router.delete("/coordinator/request/:id/delete", isCoordinator, deleteStalledSubmission); + module.exports = router; \ No newline at end of file diff --git a/server/routes/formRoutes.js b/server/routes/formRoutes.js index 8fe44896..65cb6aa9 100644 --- a/server/routes/formRoutes.js +++ b/server/routes/formRoutes.js @@ -92,4 +92,71 @@ router.post('/submit', async (req, res) => { } }); +router.get('/pending-requests', async (req, res) => { + try { + const pending = await InternshipRequest.find({ status: { $in: ['submitted', 'pending manual review'] } }); + res.json(pending); + } catch (err) { + console.error("Error fetching pending submissions:", err); + res.status(500).json({ message: "Server error" }); + } +}); + +router.post("/requests/:id/resend", async (req, res) => { + try { + const request = await InternshipRequest.findById(req.params.id); + if (!request) return res.status(404).json({ message: "Request not found" }); + + // Reset reminders + request.reminders = [new Date()]; + request.coordinatorResponded = false; + request.studentNotified = false; + await request.save(); + + // Send email to coordinator + await emailService.sendEmail({ + to: [ + request.internshipAdvisor.email, + request.student.email, + "coordinator@ipms.edu" + ], + subject: "Internship Request Resent", + html: ` +

Hello,

+

The student ${request.student.userName} has resent their internship approval request due to inactivity.

+

Please review and take necessary action.

+ ` + }); + + res.json({ message: "Request resent successfully" }); + } catch (err) { + console.error("Resend error:", err); + res.status(500).json({ message: "Failed to resend request" }); + } +}); +router.delete("/requests/:id", async (req, res) => { + try { + const deleted = await InternshipRequest.findByIdAndDelete(req.params.id); + if (!deleted) return res.status(404).json({ message: "Request not found" }); + res.json({ message: "Request deleted successfully" }); + } catch (err) { + console.error("Delete error:", err); + res.status(500).json({ message: "Failed to delete request" }); + } +}); +router.post("/student", async (req, res) => { + const { ouEmail } = req.body; + if (!ouEmail) return res.status(400).json({ message: "Missing email" }); + + try { + const request = await InternshipRequest.findOne({ "student.email": ouEmail }); + if (!request) return res.json({ approvalStatus: "not_submitted" }); + + return res.json({ approvalStatus: request.status || "draft" }); + } catch (err) { + console.error("Student route error:", err); + res.status(500).json({ message: "Server error" }); + } +}); + module.exports = router; diff --git a/server/utils/cronUtils.test.js b/server/utils/cronUtils.test.js index 346f41ad..53c12db1 100644 --- a/server/utils/cronUtils.test.js +++ b/server/utils/cronUtils.test.js @@ -1,3 +1,4 @@ +// cronUtils.test.js const cron = require("node-cron"); const logger = require("./logger"); const cronJobManager = require("./cronUtils"); @@ -14,86 +15,135 @@ jest.mock("./logger", () => ({ })); describe("cronUtils", () => { - const mockJobFunction = jest.fn().mockResolvedValue(); + let mockJobFunction; beforeEach(() => { + mockJobFunction = jest.fn().mockResolvedValue(); cron.validate.mockClear(); cron.schedule.mockClear(); logger.info.mockClear(); logger.warn.mockClear(); logger.error.mockClear(); - cronJobManager.stopAllJobs(); + cronJobManager.jobs.clear(); }); afterEach(() => { - cronJobManager.stopAllJobs(); jest.clearAllMocks(); }); - test("should create an instance of CronJobManager", () => { + it("create instance of CronJobManager", () => { expect(cronJobManager).toBeDefined(); - expect(cronJobManager.jobs instanceof Map).toBe(true); + expect(cronJobManager.jobs).toEqual(new Map()); + expect(cronJobManager.logger).toEqual(logger); }); - test("should register job with runOnInit = true", () => { - cron.validate.mockReturnValue(true); - cron.schedule.mockReturnValue({ stop: jest.fn() }); - - const result = cronJobManager.registerJob( - "Job1", - "*/1 * * * *", - mockJobFunction, - { runOnInit: true } - ); - - expect(result).toBe(true); - expect(logger.info).toHaveBeenCalledWith( - `Running job Job1 immediately on init` - ); + describe("registerJob", () => { + + beforeEach(() => { + cron.validate.mockClear(); + cron.schedule.mockClear(); + logger.info.mockClear(); + logger.warn.mockClear(); + logger.error.mockClear(); + }) + + it("registerJob succeeds with runOnInit", () => { + cron.validate.mockReturnValue(true); + cron.schedule.mockReturnValue({ stop: jest.fn() }); + const result = cronJobManager.registerJob( + "testJob", + "*/5 * * * *", + mockJobFunction, + { runOnInit: true } + ); + // Check scedule and logger.info + expect(result).toBe(true); + expect(cron.schedule).toHaveBeenCalledTimes(1); + expect(cron.schedule).toHaveBeenCalledWith( + "*/5 * * * *", + expect.any(Function), + expect.objectContaining({ scheduled: true }) + ); + expect(logger.info).toHaveBeenCalledWith( + `Running job testJob immediately on init` + ); + }); + + it("registerJob succeeds without runOnInit", () => { + cron.validate.mockReturnValue(true); + cron.schedule.mockReturnValue({ stop: jest.fn() }); + const result = cronJobManager.registerJob( + "testJob", + "*/5 * * * *", + mockJobFunction, + { timezone: "UTC" } + ); + expect(result).toBe(true); + expect(cron.schedule).toHaveBeenCalledTimes(1); + expect(cron.schedule).toHaveBeenCalledWith( + "*/5 * * * *", + expect.any(Function), + expect.objectContaining({ scheduled: true, timezone: "UTC" }) + ); + expect(logger.info).not.toHaveBeenCalledWith( + `Running job testJob immediately on init` + ); + }); + + it("registerJob errors with invalid cron", () => { + cron.validate.mockReturnValue(false); + const result = cronJobManager.registerJob( + "invalidJob", + "invalid-cron-expression", + mockJobFunction + ); + // Check the correct logs were sent + expect(result).toBe(false); + expect(logger.error).toHaveBeenCalledWith( + "Invalid cron expression: invalid-cron-expression" + ); + expect(logger.info).toHaveBeenCalledTimes(0); + }); + + it("registerJob warns & replaces duplicate jobs", () => { + cron.validate.mockReturnValue(true); + cron.schedule.mockReturnValue({ stop: jest.fn() }); + // Create Job to check against + cronJobManager.registerJob("testJob", "*/5 * * * *", mockJobFunction); + // attempt to register with same job name + const result = cronJobManager.registerJob( + "testJob", + "*/10 * * * *", + mockJobFunction + ); + // Check correct logs were sent + expect(result).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + "Job 'testJob' already exists. Replacing it..." + ); + expect(logger.info).toHaveBeenCalledWith("Stopped job: testJob"); + }); }); - test("should register job with runOnInit = false", () => { + it("stopJob stops a given job", () => { cron.validate.mockReturnValue(true); cron.schedule.mockReturnValue({ stop: jest.fn() }); - - const result = cronJobManager.registerJob( - "Job2", - "*/1 * * * *", - mockJobFunction - ); - - expect(result).toBe(true); + // Create Job to stop + cronJobManager.registerJob("testJob", "*/5 * * * *", mockJobFunction); + // Check that it exists + expect(cronJobManager.jobs.has("testJob")).toBe(true); + // remove it + cronJobManager.stopJob("testJob"); + // check that its gone + expect(logger.info).toHaveBeenCalledWith("Stopped job: testJob"); }); - test("should not register job with invalid cron", () => { - cron.validate.mockReturnValue(false); - - const result = cronJobManager.registerJob("InvalidJob", "invalid", mockJobFunction); - - expect(result).toBe(false); - expect(logger.error).toHaveBeenCalledWith( - "Invalid cron expression: invalid" - ); - }); - - test("should stop a specific job", () => { + it("listJobs prints out jobs", () => { cron.validate.mockReturnValue(true); - const stopFn = jest.fn(); - cron.schedule.mockReturnValue({ stop: stopFn }); - - cronJobManager.registerJob("Job3", "*/1 * * * *", mockJobFunction); - cronJobManager.stopJob("Job3"); - - expect(stopFn).toHaveBeenCalled(); + // Create Job to list + cronJobManager.registerJob("testJob", "*/5 * * * *", mockJobFunction); + const result = cronJobManager.listJobs(); + expect(result[0].name).toBe("testJob"); }); - test("should list registered jobs", () => { - cron.validate.mockReturnValue(true); - cron.schedule.mockReturnValue({ stop: jest.fn() }); - - cronJobManager.registerJob("Job4", "*/1 * * * *", mockJobFunction); - - const jobs = cronJobManager.listJobs(); - expect(jobs.length).toBeGreaterThan(0); - }); });