diff --git a/client/package.json b/client/package.json index f8e56288..cebf966c 100644 --- a/client/package.json +++ b/client/package.json @@ -15,7 +15,7 @@ "react-dom": "^19.0.0", "react-icons": "^5.5.0", "react-router-dom": "^7.4.1", - "react-scripts": "5.0.1", + "react-scripts": "^5.0.1", "react-signature-canvas": "^1.1.0-alpha.2", "react-toastify": "^11.0.5", "sweetalert2": "^11.17.2", diff --git a/client/src/pages/A3JobEvaluationForm.jsx b/client/src/pages/A3JobEvaluationForm.jsx index cb63ddf3..cc510f71 100644 --- a/client/src/pages/A3JobEvaluationForm.jsx +++ b/client/src/pages/A3JobEvaluationForm.jsx @@ -9,6 +9,7 @@ import { Modal, Tab, Nav, + Alert, } from "react-bootstrap"; import SignatureCanvas from "react-signature-canvas"; import "../styles/A3JobEvaluationForm.css"; @@ -41,22 +42,24 @@ const A3JobEvaluationForm = () => { interneeName: "", interneeID: "", interneeEmail: "", - advisorSignature: "", - advisorAgreement: false, + supervisorSignature: "", + supervisorAgreement: false, coordinatorSignature: "", coordinatorAgreement: false, + locked: false, //not locked with fresh form }); + const [supervisorDetails, setSupervisorDetails] = useState(null); const [errors, setErrors] = useState({}); - + // Ratings and comments const [ratings, setRatings] = useState({}); const [comments, setComments] = useState({}); // Modal state const [showModal, setShowModal] = useState(false); - const [activeSignatureTarget, setActiveSignatureTarget] = useState("advisor"); + const [activeSignatureTarget, setActiveSignatureTarget] = useState("supervisor"); const [typedSignatures, setTypedSignatures] = useState({ - advisor: "", + supervisor: "", coordinator: "", }); const [selectedFont, setSelectedFont] = useState(fonts[0]); @@ -68,7 +71,7 @@ const A3JobEvaluationForm = () => { if (!formData.interneeName?.trim()) newErrors.interneeName = "Name is required."; if (!/^\d{9}$/.test(formData.interneeID || "")) newErrors.interneeID = "Enter a valid 9-digit Sooner ID."; if (!/\S+@\S+\.\S+/.test(formData.interneeEmail || "")) newErrors.interneeEmail = "Invalid email."; - if (!formData.advisorSignature) newErrors.advisorSignature = "Signature is required."; + if (!formData.supervisorSignature) newErrors.supervisorSignature = "Signature is required."; if (!formData.coordinatorSignature) newErrors.coordinatorSignature = "Signature is required."; evaluationItems.forEach((item) => { if (!ratings[item]) { @@ -109,8 +112,8 @@ const A3JobEvaluationForm = () => { // Handle inserting signature from modal const handleSignatureInsert = () => { const targetField = - activeSignatureTarget === "advisor" - ? "advisorSignature" + activeSignatureTarget === "supervisor" + ? "supervisorSignature" : "coordinatorSignature"; if (activeTab === "type" && typedSignatures[activeSignatureTarget].trim()) { //handleChange(targetField, JSON.stringify({ type: 'text', value: typedSignatures[activeSignatureTarget], font: selectedFont })); @@ -143,7 +146,7 @@ const A3JobEvaluationForm = () => { // Submit the form to the backend const handleSubmit = async (e) => { e.preventDefault(); - if (!validateForm() || !formData.advisorAgreement || !formData.coordinatorAgreement) { + if (!validateForm() || !formData.supervisorAgreement || !formData.coordinatorAgreement) { alert("Please confirm internee details and both signature agreements before submitting."); return; } @@ -157,9 +160,9 @@ const A3JobEvaluationForm = () => { interneeName: formData.interneeName, interneeID: formData.interneeID, interneeEmail: formData.interneeEmail, - advisorSignature: formData.advisorSignature, + supervisorSignature: formData.supervisorSignature, coordinatorSignature: formData.coordinatorSignature, - advisorAgreement: formData.advisorAgreement, + supervisorAgreement: formData.supervisorAgreement, coordinatorAgreement: formData.coordinatorAgreement, ratings, comments, @@ -172,14 +175,18 @@ const A3JobEvaluationForm = () => { interneeName: "", interneeID: "", interneeEmail: "", - advisorSignature: "", - advisorAgreement: false, + supervisorName: "", + supervisorJobTitle: "", + supervisorEmail: "", + supervisorSignature: "", + supervisorAgreement: false, coordinatorSignature: "", coordinatorAgreement: false, + locked: true, //locked when properly approved }); setRatings({}); setComments({}); - setTypedSignatures({ advisor: "", coordinator: "" }); + setTypedSignatures({ supervisor: "", coordinator: "" }); sigCanvasRef.current?.clear(); } else { const err = await response.json(); @@ -231,29 +238,44 @@ const A3JobEvaluationForm = () => { style={{ backgroundColor: "#fff", maxWidth: "900px", width: "100%" }} >
- +
Internee Details
Name - handleChange("interneeName", e.target.value)} isInvalid={!!errors.interneeName} placeholder="Enter full name" style={{ maxWidth: "300px" }}/> + handleChange("interneeName", e.target.value)} isInvalid={!!errors.interneeName} placeholder="Enter full name" style={{ maxWidth: "300px" }} disabled={formData.locked}/> {errors.interneeName} - Sooner ID - handleChange("interneeID", e.target.value)} isInvalid={!!errors.interneeID} placeholder="Enter 9-digit student ID" style={{ maxWidth: "300px" }}/> + handleChange("interneeID", e.target.value)} isInvalid={!!errors.interneeID} placeholder="Enter 9-digit student ID" style={{ maxWidth: "300px" }} disabled={formData.locked}/> {errors.interneeID} Email - handleChange("interneeEmail", e.target.value)} isInvalid={!!errors.interneeEmail} placeholder="Enter student email" style={{ maxWidth: "300px" }}/> + handleChange("interneeEmail", e.target.value)} isInvalid={!!errors.interneeEmail} placeholder="Enter student email" style={{ maxWidth: "300px" }} disabled={formData.locked}/> {errors.interneeEmail}
+
+
Internship Supervisor Details
+ + Name + + + + Job Title + + + + Email + + +
+ @@ -280,6 +302,7 @@ const A3JobEvaluationForm = () => { checked={ratings[item] === "Satisfactory"} onChange={() => handleRatingChange(item, "Satisfactory")} isInvalid={!!errors[`${item}_rating`]} + disabled={formData.locked} /> { checked={ratings[item] === "Unsatisfactory"} onChange={() => handleRatingChange(item, "Unsatisfactory")} isInvalid={!!errors[`${item}_rating`]} + disabled={formData.locked} /> @@ -318,10 +342,10 @@ const A3JobEvaluationForm = () => {
{/* Signature section */} - - + + - Internship Advisor Signature + Internship Supervisor Signature
{ padding: "6px 0", }} onClick={() => { - setActiveSignatureTarget("advisor"); - setShowModal(true); + if (!formData.locked) { + setActiveSignatureTarget("supervisor"); + setShowModal(true); + } }} > - {renderSignaturePreview("advisorSignature")} + {renderSignaturePreview("supervisorSignature")}
- {errors.advisorSignature} + {errors.supervisorSignature} - handleChange("advisorAgreement", e.target.checked) + handleChange("supervisorAgreement", e.target.checked) } required + disabled={formData.locked} />
- + Internship Coordinator Signature
{ padding: "6px 0", }} onClick={() => { - setActiveSignatureTarget("coordinator"); - setShowModal(true); + if (!formData.locked) { + setActiveSignatureTarget("coordinator"); + setShowModal(true); + } }} > {renderSignaturePreview("coordinatorSignature")} @@ -376,21 +405,29 @@ const A3JobEvaluationForm = () => { handleChange("coordinatorAgreement", e.target.checked) } required + disabled={formData.locked} /> {/* Submit button */} -
- -
+
+ {formData.locked ? ( + + This form has been finalized and is locked for editing. + + ) : ( + + )} +
diff --git a/client/src/pages/CoordinatorDashboard.js b/client/src/pages/CoordinatorDashboard.js index ed712155..afbd122f 100644 --- a/client/src/pages/CoordinatorDashboard.js +++ b/client/src/pages/CoordinatorDashboard.js @@ -25,10 +25,19 @@ const CoordinatorDashboard = () => { setLoadingRequests(false); } }; - // Group D's Weekly Report Review Logic - const [reportGroups, setReportGroups] = useState([]); - const [loadingReports, setLoadingReports] = useState(true); + const approveForm = async (formId) => { + try { + await axios.post( + `${process.env.REACT_APP_API_URL}/api/approval/form/${formId}/approve` + ); + alert("Form approved successfully!"); + fetchRequests(); // refresh the list after approving + } catch (err) { + console.error("Failed to approve form:", err); + alert("Failed to approve form!"); + } + }; useEffect(() => { if (activeTab === "reports") { @@ -69,53 +78,28 @@ const CoordinatorDashboard = () => {
- {/* Tab: Internship Requests */} - {activeTab === "requests" && ( - <> - {loadingRequests ?

Loading...

: ( - - - - - - - - - - - {requests.map(req => ( - - - - - - - ))} - -
Student NameStudent IDCompanyStatus
{req.studentName}{req.studentId}{req.companyName}{req.status}
- )} - - )} - - {/* Tab: Weekly Reports Review */} - {activeTab === "reports" && ( - <> - {loadingReports ?

Loading reports...

: ( - reportGroups.length === 0 - ?

No reports to review

- : reportGroups.map(group => ( -
-

Weeks: {group.weeks?.join(", ")}

-
    - {group.reports.map((r, i) => ( -
  • Week {r.week} — Hours: {r.hours} — Tasks: {r.tasks}
  • - ))} -
- -
- )) - )} - + {requests.length === 0 ? ( +

No Pending Requests

+ ) : ( + requests.map((req) => ( +
+

Company: {req.workplace.name}

+
+ + +
+
+ )) )} ); diff --git a/client/src/router.js b/client/src/router.js index 2dba3de2..53a9397e 100644 --- a/client/src/router.js +++ b/client/src/router.js @@ -1,6 +1,5 @@ import React from "react"; import { createBrowserRouter } from "react-router-dom"; -import A1InternshipRequestForm from "./pages/A1InternshipRequestForm"; // Layout import Layout from "./components/Layout"; @@ -10,6 +9,7 @@ import Home from "./pages/Home"; import SignUp from "./pages/SignUp"; import NotFound from "./pages/NotFound"; import WeeklyProgressReportForm from "./pages/WeeklyProgressReportForm"; +import A1InternshipRequestForm from "./pages/A1InternshipRequestForm"; import A3JobEvaluationForm from "./pages/A3JobEvaluationForm"; import ActivateAccount from "./pages/ActivateAccount"; import A4PresentationEvaluationForm from "./pages/A4PresentationEvaluationForm"; diff --git a/package.json b/package.json new file mode 100644 index 00000000..a1ada9bb --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "cors": "^2.8.5" + } +} diff --git a/server/Submission.js b/server/Submission.js new file mode 100644 index 00000000..c75c9d0a --- /dev/null +++ b/server/Submission.js @@ -0,0 +1,10 @@ +const mongoose = require('mongoose'); + +const SubmissionSchema = new mongoose.Schema({ + student: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + internshipRequest: { type: mongoose.Schema.Types.ObjectId, ref: 'InternshipRequest' }, + status: { type: String, default: 'pending' }, + submittedAt: { type: Date, default: Date.now }, +}); + +module.exports = mongoose.model('Submission', SubmissionSchema); diff --git a/server/app.js b/server/app.js index d55ce7cc..35fafe74 100644 --- a/server/app.js +++ b/server/app.js @@ -1,8 +1,17 @@ +const express = require('express'); +const app = express(); + +app.use(express.json()); + const cronJobRoutes = require("./routes/cronJobRoutes"); const outcomeRoutes = require('./routes/outcomeRoutes'); +const approvalRoutes = require('./routes/approvalRoutes'); // Add cron job routes app.use("/api/cron-jobs", cronJobRoutes); -// Add outcomeRoutes +// Add outcome routes app.use('/api', outcomeRoutes); + +// Add approval routes +app.use('/api/approval', approvalRoutes); diff --git a/server/controllers/approvalController.js b/server/controllers/approvalController.js index 3687d0d4..12f0f082 100644 --- a/server/controllers/approvalController.js +++ b/server/controllers/approvalController.js @@ -1,6 +1,5 @@ const InternshipRequest = require("../models/InternshipRequest"); -const WeeklyReport = require("../models/WeeklyReport"); -const Evaluation = require("../models/Evaluation"); +const Evaluation = require("../models/Evaluation"); // 🔥 Added for Form A.3 approval const EmailService = require("../services/emailService"); const UserTokenRequest = require("../models/TokenRequest"); @@ -215,6 +214,32 @@ exports.coordinatorRejectRequest = async (req, res) => { res.json({ message: "Request Rejected Successfully" }); } catch (err) { - res.status(500).json({ message: "Rejection failed", error: err.message }); + res.status(500).json({ message: "Rejection failed" }); + } +}; + +// Coordinator Approval for Form A.3 +exports.approveFormA3 = async (req, res) => { + try { + const { formId } = req.params; + + const form = await Evaluation.findById(formId); + + if (!form) { + return res.status(404).json({ message: "Form not found" }); + } + + if (form.status === "approved") { + return res.status(400).json({ message: "Form already approved" }); + } + + form.status = "approved"; + form.approvedAt = new Date(); + await form.save(); + + res.status(200).json({ message: "Form A.3 approved successfully." }); + } catch (err) { + console.error(err); + res.status(500).json({ message: "Server Error" }); } }; diff --git a/server/index.js b/server/index.js index 80b7fe97..0a94c037 100644 --- a/server/index.js +++ b/server/index.js @@ -1,35 +1,37 @@ require("dotenv").config(); -const weeklyReportRoutes = require("./routes/weeklyReportRoutes"); - const express = require("express"); const mongoose = require("mongoose"); const cors = require("cors"); -const User = require("./models/User"); -const formRoutes = require("./routes/formRoutes"); +const weeklyReportRoutes = require("./routes/weeklyReportRoutes"); +const formRoutes = require("./routes/formRoutes"); const emailRoutes = require("./routes/emailRoutes"); const tokenRoutes = require("./routes/token"); -const approvalRoutes = require("./routes/approvalRoutes"); -const studentRoutes = require("./routes/studentRoutes"); - +const approvalRoutes = require("./routes/approvalRoutes"); const outcomeRoutes = require("./routes/outcomeRoutes"); +const presentationRoutes = require("./routes/presentationRoutes"); -// Import cron job manager and register jobs -const cronJobManager = require("./utils/cronUtils").cronJobManager; -const { registerAllJobs } = require("./jobs/registerCronJobs"); +const User = require("./models/User"); const Evaluation = require("./models/Evaluation"); -const fourWeekReportRoutes = require("./routes/fourWeekReportRoutes"); -const path = require("path"); +// Import cron job manager and register jobs +const cronJobManager = require("./utils/cronUtils"); +const { registerAllJobs } = require("./jobs/registerCronJobs"); const app = express(); app.use(express.json()); app.use(cors()); -app.use("/api/form", formRoutes); + +// Mount routes +app.use("/api/form", formRoutes); // for form submissions app.use("/api/email", emailRoutes); app.use("/api/token", tokenRoutes); +app.use("/api/approval", approvalRoutes); app.use("/api", outcomeRoutes); +app.use("/api/reports", weeklyReportRoutes); +app.use("/api/presentation", presentationRoutes); +// Connect MongoDB const mongoConfig = { serverSelectionTimeoutMS: 5000, autoIndex: true, @@ -43,7 +45,7 @@ mongoose .then(async () => { console.log("Connected to Local MongoDB"); try { - await registerAllJobs(); + await registerAllJobs(); // Register cronjobs console.log("Cron jobs initialized successfully"); } catch (error) { console.error("Failed to initialize cron jobs:", error); @@ -68,6 +70,7 @@ mongoose.connection.on("disconnected", () => { } }); +// Test endpoints app.get("/", (req, res) => { res.send("IPMS Backend Running"); }); @@ -76,14 +79,7 @@ app.get("/api/message", (req, res) => { res.json({ message: "Hello from the backend!" }); }); -app.use("/api/email", emailRoutes); -app.use("/api/token", tokenRoutes); -app.use("/api", approvalRoutes); - -app.use("/api/reports", weeklyReportRoutes); -app.use("/api/student", studentRoutes); -app.use("/api/fourWeekReports", fourWeekReportRoutes); - +// Temporary API for creating a user app.post("/api/createUser", async (req, res) => { try { const { userName, email, password, role } = req.body; @@ -100,20 +96,26 @@ app.post("/api/createUser", async (req, res) => { } }); +// Temporary API for saving an evaluation app.post("/api/evaluation", async (req, res) => { try { - const { - interneeName, - interneeID, - interneeEmail, - advisorSignature, - advisorAgreement, - coordinatorSignature, - coordinatorAgreement, - ratings, - comments, - } = req.body; - + const { interneeName, interneeID, interneeEmail, supervisorSignature, supervisorAgreement, coordinatorSignature, coordinatorAgreement, ratings, comments } = req.body; + + //check if there's an existing evaluation for the given interneeID and email + const existingEvaluation = await Evaluation.findOne({ interneeID, interneeEmail }); + + if (existingEvaluation) { + //If evaluation is locked, prevent update + if (existingEvaluation.locked) { + return res.status(400).json({ error: "Evaluation is locked and cannot be modified." }); + } + + //If evaluation is not in 'draft' status, prevent update + if (existingEvaluation.status !== 'draft') { + return res.status(400).json({ error: "This evaluation has already been finalized and cannot be modified." }); + } + } + const evaluations = Object.keys(ratings).map((category) => ({ category, rating: ratings[category], @@ -124,11 +126,12 @@ app.post("/api/evaluation", async (req, res) => { interneeName, interneeID, interneeEmail, - advisorSignature, - advisorAgreement, + supervisorSignature, + supervisorAgreement, coordinatorSignature, coordinatorAgreement, evaluations, + locked: false, }); await newEvaluation.save(); @@ -139,24 +142,19 @@ app.post("/api/evaluation", async (req, res) => { } }); - - - -//Form A.4 -const presentationRoutes = require("./routes/presentationRoutes"); -app.use("/api/presentation", presentationRoutes); - +// Graceful shutdown process.on("SIGINT", async () => { try { cronJobManager.stopAllJobs(); await mongoose.connection.close(); - console.log("✅ MongoDB connection closed through app termination"); + console.log("MongoDB connection closed through app termination"); process.exit(0); } catch (err) { - console.error("❌ Error during shutdown:", err); + console.error("Error during shutdown:", err); process.exit(1); } }); +// Start server 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/cronJobsConfig.js b/server/jobs/cronJobsConfig.js index 69829511..c90ae9e9 100644 --- a/server/jobs/cronJobsConfig.js +++ b/server/jobs/cronJobsConfig.js @@ -1,7 +1,5 @@ const CronJob = require("../models/CronJob"); -const { coordinatorReminder, supervisorReminder } = require("./reminderEmail"); -const { checkAndSendReminders } = require("./tokenExpiryCheck"); -const autoDeactivateCronjobs = require("./autoDeactivateCronjobs"); +const { coordinatorReminder, supervisorReminder, evaluationReminder } = require("./reminderEmail"); // Map of job names to actual handler functions const jobFunctions = { @@ -9,8 +7,7 @@ const jobFunctions = { supervisorApprovalReminder: supervisorReminder, // Add future cron jobs here supervisorApprovalReminder: supervisorReminder, - tokenExpiryReminder: checkAndSendReminders, - autoDeactivateCronjobs: autoDeactivateCronjobs, + evaluationReminderJob: evaluationReminder, // Add more job functions here as needed }; diff --git a/server/jobs/reminderEmail.js b/server/jobs/reminderEmail.js index c0435d25..519ebb68 100644 --- a/server/jobs/reminderEmail.js +++ b/server/jobs/reminderEmail.js @@ -115,44 +115,74 @@ const supervisorReminder = async () => { text: `Your submission "${submission.name}" is still awaiting supervisor review.`, }); - await NotificationLog.create({ - submissionId: submission._id, - type: "studentEscalation", - recipientEmail: student.email, - message: `Student notified about supervisor inaction for "${submission.name}".`, - }); + // Log notification in database + await NotificationLog.create({ + submissionId: submission._id, + type: "studentEscalation", + recipientEmail: student.email, + message: `Student notified about supervisor status on: "${submission.name}"`, + }); + + console.log(`Returned to student for resubmit/delete: "${submission.name}"`); + } else if (shouldRemindAgain) { + // Gentle reminder to supervisor + await emailService.sendEmail({ + to: supervisor.email, + subject: `Reminder: Please Review Submission "${submission.name}"`, + html: `

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

`, + text: `Reminder to review submission "${submission.name}".`, + }); + + // Update the document + submission.supervisor_reminder_count = reminderCount + 1; + submission.last_supervisor_reminder_at = new Date(); + await submission.save(); + + console.log(`Reminder sent to supervisor for "${submission.name}"`); + } + } + } catch (err) { + console.error("Error in supervisorReminder:", err); + } +}; + +const Evaluation = require("../models/Evaluation"); + +const evaluationReminder = async () => { + try { + const pendingEvals = await Evaluation.find({ + evaluations: { $exists: false }, + advisorAgreement: true, + }); + + for (const evalDoc of pendingEvals) { + const emailHtml = ` +

Dear Supervisor,

+

This is a reminder to complete the Final Job Performance Evaluation (Form A.3) for:

+
    +
  • Name: ${evalDoc.interneeName}
  • +
  • Sooner ID: ${evalDoc.interneeID}
  • +
+

The deadline is approaching. Please use the link below to complete the evaluation:

+

Complete A.3 Evaluation

+

Thanks,
IPMS Team

+ `; + + await emailService.sendEmail({ + to: evalDoc.interneeEmail, // or supervisor's email if you have it + subject: "Reminder: Pending A.3 Evaluation Submission", + html: emailHtml, + }); - logger.info(`[Escalated] Student notified for: "${submission.name}"`); - } else if (shouldRemindAgain) { - for (const sup of supervisors) { - await emailService.sendEmail({ - to: sup.ouEmail, - subject: `Reminder: Please Review Submission "${submission._id}"`, - html: `

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

`, - text: `Reminder to review submission "${submission._id}".`, - }); - } - - submission.supervisor_reminder_count = reminderCount + 1; - submission.last_supervisor_reminder_at = new Date(); - - try { - await submission.save(); - } catch (err) { - logger.error(`Failed to save submission: ${err.message}`); - } - - logger.info( - `[Reminder Sent] Supervisor: "${supervisor.email}" for "${submission.name}"` - ); - } + console.log(`✅ Reminder sent for: ${evalDoc.interneeName}`); } - } catch (err) { - logger.error("[SupervisorReminder Error]:", err.message || err); + } catch (error) { + console.error("❌ Error sending A.3 reminders:", error); } }; module.exports = { - coordinatorReminder, - supervisorReminder, + coordinatorReminder, + supervisorReminder, + evaluationReminder, }; diff --git a/server/jobs/reminderEmail.test.js b/server/jobs/reminderEmail.test.js new file mode 100644 index 00000000..28f71cc1 --- /dev/null +++ b/server/jobs/reminderEmail.test.js @@ -0,0 +1,180 @@ +const emailService = require("../services/emailService"); +const { coordinatorReminder, supervisorReminder } = require('./reminderEmail'); +const mockingoose = require("mockingoose"); +const Submission = require("../models/Submission"); +const NotificationLog = require("../models/NotifLog"); +const User = require("../models/User"); +const mongoose = require("mongoose"); + +jest.mock("../services/emailService"); + +describe("reminderEmail", () => { + beforeEach( () => { + emailService.sendEmail.mockClear(); + }); + + it("coordinatorReminder sends email", async () => { + await coordinatorReminder(); + // Check sendEmail was called + expect(emailService.sendEmail).toHaveBeenCalledTimes(1); + expect(emailService.sendEmail).toHaveBeenCalledWith({to: process.env.EMAIL_DEFAULT_SENDER, + subject: "Reminder: Coordinator Approval Pending", + html: "

This is a cron-based reminder email from IPMS.

", + text: "Reminder: Coordinator Approval Pending",}) + }); +}) + +// Supervisor reminder test + +describe("supervisorReminder", () => { + beforeEach(() => { + mockingoose.resetAll(); + jest.clearAllMocks(); + }); + + it("should send a reminder to the supervisor", async () => { + + const submissionId = new mongoose.Types.ObjectId(); + const studentId = new mongoose.Types.ObjectId(); + const studentMail = "student@example.com" + const supervisorId = new mongoose.Types.ObjectId(); + const supervisorMail = "supervisor@example.com" + + const fakeSubmission = { + _id: submissionId, + name: "Test Submission", + student_id: studentId, + supervisor_id: supervisorId, + createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000), + supervisor_status: "pending", + supervisor_reminder_count: 0, + last_supervisor_reminder_at: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000), + save: jest.fn(), + }; + + // Mocking the Submission model + mockingoose(Submission).toReturn([fakeSubmission], "find"); + jest.spyOn(User, "findById").mockImplementation((id) => { + if (id.equals(studentId)) { + return Promise.resolve({ _id: studentId, email: studentMail }); + } + if (id.equals(supervisorId)) { + return Promise.resolve({ _id: supervisorId, email: supervisorMail }); + } + return Promise.resolve(null); + }); + mockingoose(NotificationLog).toReturn({}, "save"); + jest.spyOn(Submission.prototype, "save").mockResolvedValue(true); + + // Function to be tested + await supervisorReminder(); + + // Expectations + expect(emailService.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: expect.any(String), + subject: expect.stringContaining("Reminder") + }) + ); + + expect(Submission.prototype.save).toHaveBeenCalled(); + }); +}); + +describe("supervisorReminder escalation", () => { + beforeEach(() => { + mockingoose.resetAll(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it("should return to the student after multiple reminders", async () => { + const submissionId = new mongoose.Types.ObjectId(); + const studentId = new mongoose.Types.ObjectId(); + const studentMail = "student@example.com" + const supervisorId = new mongoose.Types.ObjectId(); + const supervisorMail = "supervisor@example.com" + + const fakeSubmissionData = { + _id: submissionId, + name: "Escalation Case", + student_id: studentId, + supervisor_id: supervisorId, + createdAt: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000), // 12 days ago + supervisor_status: "pending", + supervisor_reminder_count: 2, // trigger escalation + last_supervisor_reminder_at: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000), // older than 5 days + }; + + mockingoose(Submission).toReturn([fakeSubmissionData], "find"); + + jest.spyOn(User, "findById").mockImplementation((id) => { + if (id.equals(studentId)) { + return Promise.resolve({ _id: studentId, email: studentMail }); + } + if (id.equals(supervisorId)) { + return Promise.resolve({ _id: supervisorId, email: supervisorMail }); + } + return Promise.resolve(null); + }); + + const saveSpy = jest.spyOn(Submission.prototype, "save").mockResolvedValue(true); + const notifLogSpy = jest.spyOn(NotificationLog, "create").mockResolvedValue(true); + mockingoose(NotificationLog).toReturn({}, "save"); + + await supervisorReminder(); + + // Confirm student escalation email was sent + expect(emailService.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: studentMail, + subject: expect.stringContaining("Supervisor Not Responding"), + }) + ); + + // Confirm student escalation notification was logged + expect(notifLogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + submissionId: submissionId, + type: "studentEscalation", + recipientEmail: studentMail, + }) + ); + + // Should NOT save the submission (unless you track escalations) + expect(saveSpy).not.toHaveBeenCalled(); + }); +}); + +const Evaluation = require("../models/Evaluation"); + +describe("evaluationReminder", () => { + beforeEach(() => { + mockingoose.resetAll(); + emailService.sendEmail.mockClear(); + }); + + it("should send evaluation reminder emails to pending A.3 entries", async () => { + const fakeEval = { + _id: new mongoose.Types.ObjectId(), + interneeName: "Test Student", + interneeID: "113689712", + interneeEmail: "student@example.com", + advisorAgreement: true, + }; + + // Mock Evaluation.find to return one pending evaluation + mockingoose(Evaluation).toReturn([fakeEval], "find"); + + const { evaluationReminder } = require("./reminderEmail"); + await evaluationReminder(); + + expect(emailService.sendEmail).toHaveBeenCalledTimes(1); + expect(emailService.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: "student@example.com", + subject: expect.stringContaining("Reminder: Pending A.3 Evaluation"), + }) + ); + }); +}); diff --git a/server/models/Evaluation.js b/server/models/Evaluation.js index 4f838107..cbf5692e 100644 --- a/server/models/Evaluation.js +++ b/server/models/Evaluation.js @@ -1,29 +1,46 @@ const mongoose = require('mongoose'); const formMetadata = require('./FormMetadata'); +// Signature schema for both supervisor and coordinator const signatureSchema = new mongoose.Schema({ - type: { type: String, enum: ['text', 'draw'], required: true }, - value: { type: String, required: true }, - font: { type: String } + type: { + type: String, + enum: ['text', 'draw'], + required: true + }, + value: { + type: String, + required: true + }, + font: { + type: String // used only if type is 'text' + } }, { _id: false }); +// Individual evaluation item schema const evaluationItemSchema = new mongoose.Schema({ category: { type: String, - required: true, + required: true }, rating: { type: String, enum: ['Satisfactory', 'Unsatisfactory'], required: true }, - comment: { type: String, maxlength: 500 } + comment: { + type: String, + maxlength: 500 + } }, { _id: false }); +// Main evaluation schema const evaluationSchema = new mongoose.Schema({ - ...formMetadata, - interneeId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: false }, - internshipId: { type: mongoose.Schema.Types.ObjectId, ref: 'Internship', required: false }, + internshipId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Internship', + required: false + }, interneeName: { type: String, @@ -36,29 +53,60 @@ const evaluationSchema = new mongoose.Schema({ interneeID: { type: String, required: true, - match: [/^\d{9}$/, 'Sooner ID must be a 9-digit number'] // Sooner ID validation + match: [/^\d{9}$/, 'Sooner ID must be a 9-digit number'] }, interneeEmail: { type: String, required: true, - match: [/\S+@\S+\.\S+/, 'Invalid email format'], // Email format validation + match: [/\S+@\S+\.\S+/, 'Invalid email format'], lowercase: true, trim: true }, evaluations: { type: [evaluationItemSchema], - validate: [arr => arr.length > 0, 'At least one evaluation item is required'] + validate: [arr => arr.length === 3, 'Exactly 3 evaluation items are required'] + }, + + supervisorSignature: { + type: signatureSchema, + required: true + }, + + supervisorAgreement: { + type: Boolean, + required: true + }, + + coordinatorSignature: { + type: signatureSchema, + required: true + }, + + coordinatorAgreement: { + type: Boolean, + required: true + }, + + status: { + type: String, + enum: ['draft', 'submitted', 'approved'], + default: 'draft' + }, + + submittedAt: { + type: Date }, - advisorSignature: { type: signatureSchema, required: true }, - advisorAgreement: { type: Boolean, required: true }, - coordinatorSignature: { type: signatureSchema, required: true }, - coordinatorAgreement: { type: Boolean, required: true } + locked: { + type: Boolean, + default: false + } }, { timestamps: true }); +// Unique index to prevent duplicate submissions evaluationSchema.index({ interneeID: 1, internshipId: 1 }); module.exports = mongoose.model('Evaluation', evaluationSchema); \ No newline at end of file diff --git a/server/models/Submission.js b/server/models/Submission.js new file mode 100644 index 00000000..c75c9d0a --- /dev/null +++ b/server/models/Submission.js @@ -0,0 +1,10 @@ +const mongoose = require('mongoose'); + +const SubmissionSchema = new mongoose.Schema({ + student: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + internshipRequest: { type: mongoose.Schema.Types.ObjectId, ref: 'InternshipRequest' }, + status: { type: String, default: 'pending' }, + submittedAt: { type: Date, default: Date.now }, +}); + +module.exports = mongoose.model('Submission', SubmissionSchema); diff --git a/server/routes/approvalRoutes.js b/server/routes/approvalRoutes.js index 0913ac09..1a2a7bb0 100644 --- a/server/routes/approvalRoutes.js +++ b/server/routes/approvalRoutes.js @@ -8,22 +8,21 @@ const { getCoordinatorRequestDetails, coordinatorApproveRequest, coordinatorRejectRequest, + approveFormA3, } = require("../controllers/approvalController"); const { isSupervisor, isCoordinator } = require("../middleware/authMiddleware"); -// =========================================== // -// Supervisor Approval Routes // -// =========================================== // +// Import InternshipRequest model to manually handle basic form approval +const InternshipRequest = require("../models/InternshipRequest"); // Supervisor APIs router.get("/supervisor/forms", isSupervisor, (req, res) => { - // const supervisorId = req.user._id, return getSupervisorForms(req, res, { - // supervisor_id: supervisorId, supervisor_status: { $in: ["pending"] }, - }) + }); }); + // Approve route router.post("/supervisor/form/:type/:id/approve", isSupervisor, (req, res) => handleSupervisorFormAction(req, res, "approve") @@ -40,20 +39,33 @@ router.post("/supervisor/form/:type/:id/reject", isSupervisor, (req, res) => // 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); + +// NEW Coordinator API: Approve Form A.3 +router.post("/coordinator/form-a3/:formId/approve", isCoordinator, approveFormA3); + +// NEW SIMPLE Approve Form API (ONLY status update: submitted -> approved) +router.post("/form/:formId/approve", async (req, res) => { + const { formId } = req.params; + + try { + const form = await InternshipRequest.findById(formId); + + if (!form) { + return res.status(404).json({ message: 'Form not found' }); + } + + form.status = 'approved'; + form.approvedAt = new Date(); + await form.save(); + + res.status(200).json({ message: 'Form approved successfully!' }); + } catch (error) { + console.error('Error approving form:', error); + res.status(500).json({ message: 'Something went wrong' }); + } +}); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/server/routes/evaluationRoutes.js b/server/routes/evaluationRoutes.js new file mode 100644 index 00000000..aaae5463 --- /dev/null +++ b/server/routes/evaluationRoutes.js @@ -0,0 +1,43 @@ +const express = require('express'); +const router = express.Router(); +const Evaluation = require('../models/Evaluation'); + +// POST: Submit Evaluation Form A.3 +router.post('/submit', async (req, res) => { + try { + const data = req.body; + + // Basic validation: required fields check + if ( + !data.interneeName || + !data.interneeID || + !data.interneeEmail || + !data.evaluations || + data.evaluations.length !== 3 + ) { + return res.status(400).json({ message: 'Missing required fields or invalid number of evaluations' }); + } + + const evaluation = new Evaluation({ + interneeName: data.interneeName, + interneeID: data.interneeID, + interneeEmail: data.interneeEmail, + evaluations: data.evaluations, + advisorSignature: data.advisorSignature, + advisorAgreement: data.advisorAgreement, + coordinatorSignature: data.coordinatorSignature, + coordinatorAgreement: data.coordinatorAgreement, + status: 'submitted', + submittedAt: new Date() + }); + + await evaluation.save(); + res.status(201).json({ message: 'Evaluation submitted successfully' }); + + } catch (err) { + console.error('Error submitting evaluation:', err); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/server/routes/formRoutes.js b/server/routes/formRoutes.js index 8fe44896..c80f3ebe 100644 --- a/server/routes/formRoutes.js +++ b/server/routes/formRoutes.js @@ -1,31 +1,12 @@ -const express = require("express"); +const express = require('express'); const router = express.Router(); -const InternshipRequest = require("../models/InternshipRequest"); -const { insertFormData } = require("../services/insertData"); +const { insertFormData } = require('../services/insertData'); -// router.post("/internshiprequests/:id/approve", approveSubmission); -// router.post("/internshiprequests/:id/reject", rejectSubmission); - -// UPDATED: GET route to fetch internship requests pending supervisor action -router.get("/internshiprequests", async (req, res) => { - try { - const requests = await InternshipRequest.find({ - supervisor_status: "pending", - // approvals: "advisor", // advisor has approved - supervisor_status: { $in: [null, "pending"] } // not yet reviewed by supervisor - }).sort({ createdAt: 1 }) .populate("student", "userName") // oldest first - - res.status(200).json(requests); - } catch (err) { - console.error("Error fetching internship requests:", err); - res.status(500).json({ message: "Server error while fetching internship requests" }); - } -}); +let status = ''; // Validate required fields function validateFormData(formData) { const requiredFields = [ - 'soonerId', 'workplaceName', 'website', 'phone', @@ -44,9 +25,6 @@ function validateFormData(formData) { } } - if (!/^[0-9]{9}$/.test(formData.soonerId)) - return `Sooner ID must be a 9-digit number, not ${formData.soonerId}`; - if (!Array.isArray(formData.tasks) || formData.tasks.length === 0) { return 'Tasks must be a non-empty array'; } @@ -63,7 +41,7 @@ function validateFormData(formData) { const tasks = formData.tasks; console.log(tasks); - if (tasks.filter((task) => task.description && task.description.trim() !== '').length < 3) + if (tasks.filter((task) => task.description).length < 3) return 'At least 3 tasks must be provided'; const uniqueOutcomes = new Set(); tasks.forEach((task) => { @@ -75,7 +53,6 @@ function validateFormData(formData) { return null; } - router.post('/submit', async (req, res) => { const formData = req.body; const validationError = validateFormData(formData); @@ -85,7 +62,7 @@ router.post('/submit', async (req, res) => { try { await insertFormData(formData); - res.status(200).json({ message: 'Form received and handled!', manual: formData.status !== 'submitted'}); + res.status(200).json({ message: 'Form received and handled!', status, manual: formData.status !== 'submitted'}); } catch (error) { console.error('Error handling form data:', error); res.status(500).json({ message: 'Something went wrong' }); diff --git a/server/services/insertData.js b/server/services/insertData.js index 99348e1c..3059a332 100644 --- a/server/services/insertData.js +++ b/server/services/insertData.js @@ -1,29 +1,20 @@ const mongoose = require("mongoose"); const InternshipRequest = require("../models/InternshipRequest"); +const User = require("../models/User"); // Make sure User model is imported properly +const Submission = require("../models/Submission"); async function insertFormData(formData) { try { console.log("Received Form Data:\n", JSON.stringify(formData, null, 2)); - // Assumes global mongoose connection is already established elsewhere in app - if (formData.status === "submitted") { - // if tasks are aligned , form will be sent to the supervisor. - formData.supervisor_status="pending" - formData.coordinator_status="not submitted" //TBD - console.log("Submission sent to Supervisor Dashboard."); - } else if (formData.status === "pending manual review") { - //if tasks are not aligned, form will be sent to coordinator. coordinator approves -> coordinator should forward to supervisor for further approval - formData.coordinator_status="pending" - formData.supervisor_status="not submitted" - console.log("Task not aligned with CS Outcomes. Sent to coordinator for manual review."); + const student = await User.findOne({ email: formData.email }); + + if (!student) { + throw new Error("Student not found in users collection"); } const formattedData = { - // student: new mongoose.Types.ObjectId(), // TODO: Replace with actual signed-in student ID - student:{ - name:formData.interneeName, - email:formData.interneeEmail - }, + student: student._id, workplace: { name: formData.workplaceName, website: formData.website, @@ -37,32 +28,33 @@ async function insertFormData(formData) { creditHours: parseInt(formData.creditHours), startDate: new Date(formData.startDate), endDate: new Date(formData.endDate), - tasks: formData.tasks - .map(task => ({ + tasks: formData.tasks.map(task => ({ description: task.description, outcomes: task.outcomes, - })).filter(task => task.description.trim() !== ''), // remove empty tasks - // status: "submitted", // Default status — adjust as needed - // status: formData.status, // Default status — adjust as needed - - supervisor_status: formData.supervisor_status ,//function based on if tasks are aligned/not aligned with outcomes - coordinator_status: formData.coordinator_status, - approvals: ["advisor", "coordinator"], // TODO: Might be dynamic later - reminders: [], // Placeholder for future reminder logic - completedHours: parseInt(formData.creditHours) * 60, // Assuming 1 credit = 60 hours + })).filter(task => task.description.trim() !== ''), + status: formData.status, + approvals: ["advisor", "coordinator"], + reminders: [], + completedHours: parseInt(formData.creditHours) * 60 }; const savedForm = await InternshipRequest.create(formattedData); console.log("Form saved successfully with ID:", savedForm._id); - console.log("saved form",savedForm) + + if (formData.status === "submitted") { + console.log("Submission sent to Supervisor Dashboard."); + } else if (formData.status === "pending manual review") { + console.log("Task not aligned with CS Outcomes. Sent to coordinator for manual review."); + } + return savedForm; } catch (error) { - console.error("Error saving form:", error.message); + console.error("Full Error Stack:", error); throw error; } } module.exports = { insertFormData, -}; \ No newline at end of file +};