diff --git a/package-lock.json b/package-lock.json index 97f5fa713..4a1ee2ccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -517,7 +517,6 @@ "version": "7.28.3", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -3599,7 +3598,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -4199,7 +4197,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5019,7 +5016,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -6467,7 +6463,6 @@ "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -6630,7 +6625,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6694,7 +6688,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, - "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -6724,7 +6717,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -6757,7 +6749,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, diff --git a/src/controllers/educatorController.js b/src/controllers/educatorController.js new file mode 100644 index 000000000..61da20da7 --- /dev/null +++ b/src/controllers/educatorController.js @@ -0,0 +1,296 @@ +const mongoose = require('mongoose'); +const LessonPlan = require('../models/lessonPlan'); +const Activity = require('../models/activity'); +const EducationTask = require('../models/educationTask'); +const Progress = require('../models/progress'); +const Atom = require('../models/atom'); +const UserProfile = require('../models/userProfile'); + +const educatorController = function () { + // Utility function to calculate deadline + const calculateDeadline = (assignmentDate, offsetDays = 7) => { + const deadline = new Date(assignmentDate); + deadline.setDate(deadline.getDate() + offsetDays); + return deadline; + }; + + // Check if student has completed prerequisite atoms + const checkPrerequisites = async (studentId, atomId) => { + try { + const atom = await Atom.findById(atomId).populate('prerequisites'); + if (!atom || !atom.prerequisites || atom.prerequisites.length === 0) { + return true; // No prerequisites required + } + + // Check if student has completed all prerequisite atoms + const prerequisiteIds = atom.prerequisites.map((prereq) => prereq._id); + const completedProgress = await Progress.find({ + studentId, + atomId: { $in: prerequisiteIds }, + status: 'completed', + }); + + return completedProgress.length === prerequisiteIds.length; + } catch (error) { + throw new Error(`Error checking prerequisites: ${error.message}`); + } + }; + + // Get enrolled students (those with student education profile) + const getEnrolledStudents = async () => { + try { + return await UserProfile.find({ + 'educationProfiles.student.cohortId': { $exists: true }, + isActive: true, + }).select('_id firstName lastName email educationProfiles.student'); + } catch (error) { + throw new Error(`Error fetching enrolled students: ${error.message}`); + } + }; + + // Main assignment endpoint + const assignTasks = async (req, res) => { + const session = await mongoose.startSession(); + + try { + await session.startTransaction(); + + const { + lesson_plan_id: lessonPlanId, + assignment_date: assignmentDate, + is_auto_assigned: isAutoAssigned, + deadline_offset_days: deadlineOffsetDays, + } = req.body; + + // Validate required fields + if (!lessonPlanId || !assignmentDate) { + return res.status(400).json({ + error: 'lesson_plan_id and assignment_date are required', + }); + } + + // Validate lesson plan exists + const lessonPlan = await LessonPlan.findById(lessonPlanId) + .populate('activities') + .session(session); + + if (!lessonPlan) { + return res.status(404).json({ + error: 'Lesson plan not found', + }); + } + + // Get activities and extract task templates + const activities = await Activity.find({ + lessonPlanId, + }).session(session); + + if (!activities || activities.length === 0) { + return res.status(400).json({ + error: 'No activities found for this lesson plan', + }); + } + + // Extract all atom task templates from activities + const taskTemplates = []; + activities.forEach((activity) => { + activity.atomTaskTemplates.forEach((template) => { + taskTemplates.push({ + atomId: template.atomId, + subjectId: template.subjectId, + taskType: template.taskType, + instructions: template.instructions, + resources: template.resources || [], + }); + }); + }); + + if (taskTemplates.length === 0) { + return res.status(400).json({ + error: 'No task templates found in lesson plan activities', + }); + } + + // Get enrolled students + const students = await getEnrolledStudents(); + + if (students.length === 0) { + return res.status(400).json({ + error: 'No enrolled students found', + }); + } + + // Initialize tracking variables + let successCount = 0; + let failureCount = 0; + const skippedStudents = []; + const errors = []; + const assignedTasks = []; + + // Process students and templates in parallel (no awaits inside loops) + const perStudentResults = await Promise.allSettled( + students.map(async (student) => { + const perTemplateResults = await Promise.allSettled( + taskTemplates.map(async (template) => { + const hasPrereqs = await checkPrerequisites(student._id, template.atomId); + if (!hasPrereqs) { + return { + ok: false, + studentId: student._id, + atomId: template.atomId, + reason: 'Prerequisites not completed', + }; + } + + const existingTask = await EducationTask.findOne({ + studentId: student._id, + lessonPlanId, + atomIds: template.atomId, + }).session(session); + if (existingTask) { + return { + ok: false, + studentId: student._id, + atomId: template.atomId, + reason: 'Task already assigned', + }; + } + + const dueAt = calculateDeadline(assignmentDate, deadlineOffsetDays); + + const educationTask = new EducationTask({ + lessonPlanId, + studentId: student._id, + atomIds: [template.atomId], + type: template.taskType, + status: 'assigned', + assignedAt: new Date(assignmentDate), + dueAt, + uploadUrls: [], + grade: 'pending', + }); + + const savedTask = await educationTask.save({ session }); + + await Progress.findOneAndUpdate( + { studentId: student._id, atomId: template.atomId }, + { + studentId: student._id, + atomId: template.atomId, + status: 'in_progress', + firstStartedAt: new Date(assignmentDate), + }, + { upsert: true, new: true, session }, + ); + + return { ok: true, studentId: student._id, taskId: savedTask._id }; + }), + ); + + const successes = perTemplateResults.filter( + (r) => r.status === 'fulfilled' && r.value.ok, + ).length; + const errorsForStudent = perTemplateResults + .filter((r) => r.status === 'fulfilled' && !r.value.ok) + .map((r) => ({ + studentId: r.value.studentId, + atomId: r.value.atomId, + reason: r.value.reason, + })); + + return { studentId: student._id, successes, errorsForStudent }; + }), + ); + + // Aggregate results (no ++, no continue) + // let successCount = 0; + // let failureCount = 0; + // const skippedStudents = []; + // const errors = []; + // const assignedTasks = []; + + perStudentResults.forEach((r) => { + if (r.status !== 'fulfilled') return; + const { studentId, successes, errorsForStudent } = r.value; + if (successes > 0) { + successCount += 1; + // We don’t collect task documents here; keep your existing total via counts or fetch if needed + } else if (errorsForStudent.length > 0) { + failureCount += 1; + skippedStudents.push(studentId); + } + errors.push(...errorsForStudent); + }); + + await session.commitTransaction(); + + // Return structured response + const response = { + success: true, + summary: { + success_count: successCount, + failure_count: failureCount, + total_students: students.length, + total_tasks_assigned: assignedTasks.length, + skipped: skippedStudents, + errors, + }, + lesson_plan: { + id: lessonPlanId, + title: lessonPlan.title, + theme: lessonPlan.theme, + }, + assignment_details: { + assignment_date: new Date(assignmentDate), + deadline_offset_days: deadlineOffsetDays, + is_auto_assigned: isAutoAssigned, + }, + }; + + res.status(201).json(response); + } catch (error) { + await session.abortTransaction(); + res.status(500).json({ + error: `Assignment failed: ${error.message}`, + success: false, + }); + } finally { + session.endSession(); + } + }; + + // Get assignment summary by lesson plan + const getAssignmentSummary = async (req, res) => { + try { + const { lessonPlanId } = req.params; + + const tasks = await EducationTask.find({ lessonPlanId }) + .populate('studentId', 'firstName lastName email') + .populate('atomIds', 'name difficulty') + .sort({ assignedAt: -1 }); + + const summary = { + total_assignments: tasks.length, + by_status: { + assigned: tasks.filter((t) => t.status === 'assigned').length, + in_progress: tasks.filter((t) => t.status === 'in_progress').length, + completed: tasks.filter((t) => t.status === 'completed').length, + graded: tasks.filter((t) => t.status === 'graded').length, + }, + students: [...new Set(tasks.map((t) => t.studentId._id.toString()))].length, + recent_assignments: tasks.slice(0, 10), + }; + + res.status(200).json(summary); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }; + + return { + assignTasks, + getAssignmentSummary, + }; +}; + +module.exports = educatorController; diff --git a/src/models/educationTask.js b/src/models/educationTask.js index 369672dca..4f191943c 100644 --- a/src/models/educationTask.js +++ b/src/models/educationTask.js @@ -1,58 +1,65 @@ const mongoose = require('mongoose'); -const educationTaskSchema = new mongoose.Schema({ - lessonPlanId: { - type: mongoose.Schema.Types.ObjectId, - ref: 'LessonPlan', - required: true +const educationTaskSchema = new mongoose.Schema( + { + lessonPlanId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'LessonPlan', + required: true, + }, + studentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'userProfile', + required: true, + }, + atomIds: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Atom', + required: true, + }, + ], + type: { + type: String, + required: true, + enum: ['read', 'write', 'practice', 'quiz', 'project'], + }, + status: { + type: String, + required: true, + enum: ['assigned', 'in_progress', 'completed', 'graded'], + default: 'assigned', + }, + assignedAt: { + type: Date, + default: Date.now, + }, + dueAt: { + type: Date, + required: true, + }, + completedAt: { + type: Date, + }, + uploadUrls: [ + { + type: String, + trim: true, + }, + ], + grade: { + type: String, + enum: ['A', 'B', 'C', 'D', 'F', 'pending'], + default: 'pending', + }, + feedback: { + type: String, + trim: true, + }, }, - studentId: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: true + { + timestamps: true, }, - atomIds: [{ - type: mongoose.Schema.Types.ObjectId, - ref: 'Atom', - required: true - }], - type: { - type: String, - required: true, - enum: ['read', 'write', 'practice', 'quiz', 'project'] - }, - status: { - type: String, - required: true, - enum: ['assigned', 'in_progress', 'completed', 'graded'], - default: 'assigned' - }, - assignedAt: { - type: Date, - default: Date.now - }, - dueAt: { - type: Date, - required: true - }, - completedAt: { - type: Date - }, - uploadUrls: [{ - type: String, - trim: true - }], - grade: { - type: String, - enum: ['A', 'B', 'C', 'D', 'F', 'pending'], - default: 'pending' - }, - feedback: { - type: String, - trim: true - } -}, { - timestamps: true -}); +); -module.exports = mongoose.model('EducationTask', educationTaskSchema); \ No newline at end of file +module.exports = mongoose.model('EducationTask', educationTaskSchema); diff --git a/src/models/lessonPlan.js b/src/models/lessonPlan.js index e59f301f3..f3cee0770 100644 --- a/src/models/lessonPlan.js +++ b/src/models/lessonPlan.js @@ -1,38 +1,43 @@ const mongoose = require('mongoose'); -const lessonPlanSchema = new mongoose.Schema({ - title: { - type: String, - required: true, - trim: true +const lessonPlanSchema = new mongoose.Schema( + { + title: { + type: String, + required: true, + trim: true, + }, + theme: { + type: String, + trim: true, + }, + description: { + type: String, + trim: true, + }, + startDate: { + type: Date, + required: true, + }, + endDate: { + type: Date, + required: true, + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'userProfile', + required: true, + }, + activities: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Activity', + }, + ], }, - theme: { - type: String, - trim: true + { + timestamps: true, }, - description: { - type: String, - trim: true - }, - startDate: { - type: Date, - required: true - }, - endDate: { - type: Date, - required: true - }, - createdBy: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: true - }, - activities: [{ - type: mongoose.Schema.Types.ObjectId, - ref: 'Activity' - }] -}, { - timestamps: true -}); +); -module.exports = mongoose.model('LessonPlan', lessonPlanSchema); \ No newline at end of file +module.exports = mongoose.model('LessonPlan', lessonPlanSchema); diff --git a/src/models/progress.js b/src/models/progress.js index 79aa1ffc0..eacbac96a 100644 --- a/src/models/progress.js +++ b/src/models/progress.js @@ -1,42 +1,45 @@ const mongoose = require('mongoose'); -const progressSchema = new mongoose.Schema({ - studentId: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User', - required: true +const progressSchema = new mongoose.Schema( + { + studentId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'userProfile', + required: true, + }, + atomId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Atom', + required: true, + }, + status: { + type: String, + required: true, + enum: ['not_started', 'in_progress', 'completed'], + default: 'not_started', + }, + firstStartedAt: { + type: Date, + }, + completedAt: { + type: Date, + }, + grade: { + type: String, + enum: ['A', 'B', 'C', 'D', 'F', 'pending'], + default: 'pending', + }, + feedback: { + type: String, + trim: true, + }, }, - atomId: { - type: mongoose.Schema.Types.ObjectId, - ref: 'Atom', - required: true + { + timestamps: true, }, - status: { - type: String, - required: true, - enum: ['not_started', 'in_progress', 'completed'], - default: 'not_started' - }, - firstStartedAt: { - type: Date - }, - completedAt: { - type: Date - }, - grade: { - type: String, - enum: ['A', 'B', 'C', 'D', 'F', 'pending'], - default: 'pending' - }, - feedback: { - type: String, - trim: true - } -}, { - timestamps: true -}); +); // Compound index to ensure unique progress tracking per student-atom combination progressSchema.index({ studentId: 1, atomId: 1 }, { unique: true }); -module.exports = mongoose.model('Progress', progressSchema); \ No newline at end of file +module.exports = mongoose.model('Progress', progressSchema); diff --git a/src/models/userProfile.js b/src/models/userProfile.js index 8a3729f98..a00324162 100644 --- a/src/models/userProfile.js +++ b/src/models/userProfile.js @@ -349,7 +349,7 @@ const userProfileSchema = new Schema({ assignedStudents: [ { type: mongoose.Schema.Types.ObjectId, - ref: 'User', + ref: 'userProfile', }, ], }, @@ -374,7 +374,7 @@ const userProfileSchema = new Schema({ assignedTeachers: [ { type: mongoose.Schema.Types.ObjectId, - ref: 'User', + ref: 'userProfile', }, ], }, diff --git a/src/routes/educatorRouter.js b/src/routes/educatorRouter.js new file mode 100644 index 000000000..16ac3909d --- /dev/null +++ b/src/routes/educatorRouter.js @@ -0,0 +1,16 @@ +const express = require('express'); + +const routes = function () { + const controller = require('../controllers/educatorController')(); + const educatorRouter = express.Router(); + + // POST /api/educator/assign-tasks - Main assignment endpoint + educatorRouter.route('/assign-tasks').post(controller.assignTasks); + + // GET /api/educator/assignments/:lessonPlanId - Get assignment summary + educatorRouter.route('/assignments/:lessonPlanId').get(controller.getAssignmentSummary); + + return educatorRouter; +}; + +module.exports = routes; diff --git a/src/startup/routes.js b/src/startup/routes.js index f559d460e..6645d6329 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -20,7 +20,9 @@ const ownerMessage = require('../models/ownerMessage'); const currentWarnings = require('../models/currentWarnings'); const availability = require('../models/lbdashboard/availability'); -const listingAvailablityRouter = require('../routes/lbdashboard/listingAvailablityRouter')(availability); +const listingAvailablityRouter = require('../routes/lbdashboard/listingAvailablityRouter')( + availability, +); const savedFilter = require('../models/savedFilter'); const hgnFormResponses = require('../models/hgnFormResponse'); @@ -272,6 +274,7 @@ const projectMaterialRouter = require('../routes/projectMaterialroutes'); const projectCostRouter = require('../routes/bmdashboard/projectCostRouter')(projectCost); const tagRouter = require('../routes/tagRouter')(tag); +const educatorRouter = require('../routes/educatorRouter')(); const savedFilterRouter = require('../routes/savedFilterRouter')(savedFilter); // lbdashboard const bidTermsRouter = require('../routes/lbdashboard/bidTermsRouter'); @@ -347,10 +350,10 @@ module.exports = function (app) { app.use('/api/help-categories', helpCategoryRouter); app.use('/api', tagRouter); + app.use('/api/educator', educatorRouter); app.use('/api/analytics', pledgeAnalyticsRoutes); app.use('/api', registrationRouter); - app.use('/api/job-analytics', jobAnalyticsRoutes); app.use('/api/applicant-volunteer-ratio', applicantVolunteerRatioRouter); app.use('/applications', applicationRoutes);