diff --git a/package-lock.json b/package-lock.json index 59bd610a4..48a4aba04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1172,7 +1172,6 @@ "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", "license": "MIT", - "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -1234,7 +1233,6 @@ "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz", "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==", "license": "MIT", - "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -1402,7 +1400,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -4460,7 +4457,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -5596,7 +5592,6 @@ "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "form-data": "^4.0.4" @@ -5774,7 +5769,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5806,7 +5800,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6863,7 +6856,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -7827,7 +7819,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.21.0" }, @@ -8641,7 +8632,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8821,7 +8811,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8879,7 +8868,6 @@ "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -8910,7 +8898,6 @@ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -8944,7 +8931,6 @@ "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -9863,7 +9849,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -18976,7 +18961,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index bb763d516..104d04bff 100644 --- a/package.json +++ b/package.json @@ -140,4 +140,4 @@ "/src/**/*" ] } -} +} \ No newline at end of file diff --git a/src/controllers/badgeController.js b/src/controllers/badgeController.js index f333af518..fdf221d82 100644 --- a/src/controllers/badgeController.js +++ b/src/controllers/badgeController.js @@ -3,7 +3,7 @@ const UserProfile = require('../models/userProfile'); const helper = require('../utilities/permissions'); const escapeRegex = require('../utilities/escapeRegex'); const cacheClosure = require('../utilities/nodeCache'); -// const userHelper = require('../helpers/userHelper')(); +const userHelper = require('../helpers/userHelper')(); const badgeController = function (Badge) { /** @@ -13,10 +13,10 @@ const badgeController = function (Badge) { */ const cache = cacheClosure(); - // const awardBadgesTest = async function (req, res) { - // await userHelper.awardNewBadges(); - // res.status(200).send('Badges awarded'); - // }; + const awardNewBadges = async function (req, res) { + await userHelper.awardNewBadges(); + res.status(200).send('Badges awarded'); + }; const getAllBadges = async function (req, res) { // console.log(req.body.requestor); // Retain logging from development branch for debugging @@ -341,7 +341,7 @@ const badgeController = function (Badge) { }; return { - // awardBadgesTest, + awardNewBadges, getAllBadges, assignBadges, postBadge, diff --git a/src/controllers/bmdashboard/__tests__/bmInventoryTypeController.test.js b/src/controllers/bmdashboard/__tests__/bmInventoryTypeController.test.js index 50ceace7e..690e25d71 100644 --- a/src/controllers/bmdashboard/__tests__/bmInventoryTypeController.test.js +++ b/src/controllers/bmdashboard/__tests__/bmInventoryTypeController.test.js @@ -33,6 +33,11 @@ const mockEquipType = { exec: jest.fn(), }; +const mockInvTypeHistory = { + find: jest.fn(), + create: jest.fn(), + exec: jest.fn(), +}; const mockInvType = { find: jest.fn(), findById: jest.fn(), @@ -64,6 +69,7 @@ describe('Building Materials Inventory Controller', () => { mockReusType, mockToolType, mockEquipType, + mockInvTypeHistory, ); req = { @@ -173,36 +179,4 @@ describe('Building Materials Inventory Controller', () => { expect(res.send).toHaveBeenCalledWith(mockTools); }); }); - - describe('updateNameAndUnit', () => { - it('should update name and unit successfully', async () => { - req.params = { invtypeId: 'inv123' }; - req.body = { name: 'Updated Name', unit: 'Updated Unit' }; - - const updatedDoc = { ...req.body, _id: 'inv123' }; - mockInvType.findByIdAndUpdate.mockResolvedValue(updatedDoc); - - await controller.updateNameAndUnit(req, res); - - expect(mockInvType.findByIdAndUpdate).toHaveBeenCalledWith( - 'inv123', - { name: 'Updated Name', unit: 'Updated Unit' }, - { new: true, runValidators: true }, - ); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json).toHaveBeenCalledWith(updatedDoc); - }); - - it('should handle non-existent inventory type', async () => { - req.params = { invtypeId: 'nonexistent' }; - req.body = { name: 'Updated Name' }; - - mockInvType.findByIdAndUpdate.mockResolvedValue(null); - - await controller.updateNameAndUnit(req, res); - - expect(res.status).toHaveBeenCalledWith(404); - expect(res.json).toHaveBeenCalledWith({ error: 'invType Material not found check Id' }); - }); - }); }); diff --git a/src/controllers/bmdashboard/bmInventoryTypeController.js b/src/controllers/bmdashboard/bmInventoryTypeController.js index 5d7cc1ef3..cd9b8cde6 100644 --- a/src/controllers/bmdashboard/bmInventoryTypeController.js +++ b/src/controllers/bmdashboard/bmInventoryTypeController.js @@ -1,6 +1,7 @@ const fs = require('fs'); const fsPromises = require('fs/promises'); const path = require('path'); +const mongoose = require('mongoose'); const filename = 'BuildingUnits.json'; const currentFilePath = __filename; @@ -9,7 +10,15 @@ const filepath = path.join(rootPath, filename); const { readFile } = fs; const { writeFile } = fs; -function bmInventoryTypeController(InvType, MatType, ConsType, ReusType, ToolType, EquipType) { +function bmInventoryTypeController( + InvType, + MatType, + ConsType, + ReusType, + ToolType, + EquipType, + invTypeHistory, +) { async function fetchMaterialTypes(req, res) { try { MatType.find() @@ -398,22 +407,98 @@ function bmInventoryTypeController(InvType, MatType, ConsType, ReusType, ToolTyp }; const updateNameAndUnit = async (req, res) => { - const { invtypeId } = req.params; - const { name, unit } = req.body; - try { - const updatedInvType = await InvType.findByIdAndUpdate( - invtypeId, - { name, unit }, - { new: true, runValidators: true }, - ); - - if (!updatedInvType) { - return res.status(404).json({ error: 'invType Material not found check Id' }); + const { invtypeId } = req.params; + const { + name, + unit, + type: rawType, + requestor: { requestorId }, + } = req.body; + const historyDocs = []; + const updateData = {}; + // Selection of Collection depending on Type + const allowedTypes = ['Material', 'Consumable']; + const itemTtype = allowedTypes.includes(rawType) ? rawType : 'Inventory'; + + // Validate invtypeId + if (!mongoose.Types.ObjectId.isValid(invtypeId)) { + return res.status(400).json({ message: 'Invalid inventory type ID' }); + } + // Sanitize name + const safeName = String(name).trim(); + if (!safeName) { + return res.status(400).json({ message: 'Invalid inventory name' }); + } + // Extract and sanitize + const safeUnit = String(unit).trim(); + if (!safeUnit || safeUnit.length > 50) { + return res.status(400).json({ message: 'Invalid unit value' }); + } + + let CollectionName = InvType; + if (itemTtype === 'Material') { + CollectionName = MatType; + } else if (itemTtype === 'Consumable') { + CollectionName = ConsType; + } + + // Fetch existing document + const invType = await CollectionName.findById(invtypeId); + if (!invType) { + return res.status(404).send('Inventory type not found check Id'); + } + + // Perform query using sanitized values + const existingInvType = await CollectionName.findOne({ + name: safeName, + _id: { $ne: mongoose.Types.ObjectId(invtypeId) }, + }); + + if (existingInvType) { + return res.status(409).json({ + message: 'Inventory type name already exists', + }); + } + + // Track name change + if (safeName && safeName !== invType.name) { + historyDocs.push({ + invtypeId, + field: 'name', + oldValue: invType.name, + newValue: safeName, + editedBy: requestorId, + }); + updateData.name = safeName; + } + + // Track unit change + if (safeUnit && safeUnit !== invType.unit) { + historyDocs.push({ + invtypeId, + field: 'unit', + oldValue: invType.unit, + newValue: safeUnit, + editedBy: requestorId, + }); + updateData.unit = safeUnit; + } + + // Save history (if any) + if (historyDocs.length > 0) { + await invTypeHistory.insertMany(historyDocs); } + // Update main document + const updatedInvType = await CollectionName.findByIdAndUpdate(invtypeId, updateData, { + new: true, + runValidators: true, + }); + res.status(200).json(updatedInvType); } catch (error) { + console.error(error); res.status(500).send(error); } }; @@ -638,6 +723,26 @@ function bmInventoryTypeController(InvType, MatType, ConsType, ReusType, ToolTyp } }; + const fetchInvTypeHistory = async (req, res) => { + try { + const { invtypeId } = req.params; + const safeInvTypeId = new mongoose.Types.ObjectId(invtypeId); + if (!mongoose.Types.ObjectId.isValid(invtypeId)) { + return res.status(400).json({ message: 'Invalid inventory type id' }); + } + + const history = await invTypeHistory + .find({ invtypeId: safeInvTypeId }) + .populate('editedBy', '_id firstName lastName email') + .sort({ editedAt: -1 }) + .lean(); + + res.status(200).json(history); + } catch (error) { + console.error('Fetch history error:', error); + res.status(500).json({ message: 'Failed to fetch inventory history' }); + } + }; return { fetchMaterialTypes, fetchConsumableTypes, @@ -657,6 +762,7 @@ function bmInventoryTypeController(InvType, MatType, ConsType, ReusType, ToolTyp deleteInvUnit, updateSingleInvType, deleteSingleInvType, + fetchInvTypeHistory, }; } diff --git a/src/controllers/bmdashboard/bmToolController.js b/src/controllers/bmdashboard/bmToolController.js index 9c4cd725c..090f70df1 100644 --- a/src/controllers/bmdashboard/bmToolController.js +++ b/src/controllers/bmdashboard/bmToolController.js @@ -255,11 +255,79 @@ const bmToolController = (BuildingTool, ToolType) => { return res.status(200).send({ errors, results }); }; + // --- UPDATED FIX FOR EDIT TOOL FEATURE --- + const updateToolById = async (req, res) => { + const { toolId } = req.params; + const { name, status, condition } = req.body; + const requestorId = req.body.requestor?.requestorId || '6868351899da83323cc7a695'; + + try { + // 1. Update the Tool Item (Condition + Log) + const newUpdateEntry = { + date: new Date(), + createdBy: requestorId, + condition, + }; + + const tool = await BuildingTool.findByIdAndUpdate( + toolId, + { + $set: { condition }, + $push: { updateRecord: newUpdateEntry }, + }, + { new: true }, + ); + + if (!tool) return res.status(404).send({ message: 'Tool not found.' }); + + // 2. Update the Parent Tool Type in 'buildinginventorytypes' + if (tool.itemType) { + // Resolve the parent ID + const typeId = tool.itemType._id ? tool.itemType._id : tool.itemType; + const typeObjectId = mongoose.Types.ObjectId(typeId); + const toolObjectId = mongoose.Types.ObjectId(toolId); + + // ACCESS RAW COLLECTION DIRECTLY to bypass Model naming mismatch + // (Fixes the bug where updating ToolType updated 0 documents) + // Get the collection name dynamically from the model if possible, or fallback + const collectionName = ToolType.collection.name || 'buildingInventoryTypes'; + const collection = mongoose.connection.collection(collectionName); + + // A. Update Name if provided + if (name) { + await collection.updateOne({ _id: typeObjectId }, { $set: { name } }); + } + + // B. Atomic Swap: Remove from BOTH arrays + await collection.updateOne( + { _id: typeObjectId }, + { $pull: { using: toolObjectId, available: toolObjectId } }, + ); + + // C. Add to the CORRECT array + if (status === 'Using') { + await collection.updateOne({ _id: typeObjectId }, { $addToSet: { using: toolObjectId } }); + } else if (status === 'Available') { + await collection.updateOne( + { _id: typeObjectId }, + { $addToSet: { available: toolObjectId } }, + ); + } + } + + res.status(200).send({ message: 'Tool updated successfully', tool }); + } catch (error) { + console.error('Backend Error:', error); + res.status(500).send({ message: 'Internal Error', error: error.message }); + } + }; + return { fetchAllTools, fetchSingleTool, bmPurchaseTools, bmLogTools, + updateToolById, }; }; diff --git a/src/controllers/communityController.js b/src/controllers/communityController.js index 469e2c17f..8e7cad87e 100644 --- a/src/controllers/communityController.js +++ b/src/controllers/communityController.js @@ -3,29 +3,42 @@ const FormResponse = require('../models/hgnFormResponse'); const communityMemberController = function () { const getCommunityMembers = async function (req, res) { try { - const query = {}; - const { search, skills, sortOrder = 'asc' } = req.query; + const { search, skills } = req.query; + + // Validate sortOrder against an allowlist to prevent injection + const sortOrder = req.query.sortOrder === 'desc' ? 'desc' : 'asc'; + const query = {}; if (search) { - query['userInfo.name'] = { $regex: search, $options: 'i' }; + // Escape regex special characters to prevent ReDoS + const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + query['userInfo.name'] = { $regex: escapedSearch, $options: 'i' }; } - const formResponses = await FormResponse.find(query).sort({ - 'userInfo.name': sortOrder === 'asc' ? 1 : -1, - }); + + // Use .lean() to get plain JS objects so Object.entries() works correctly on subdocuments + const formResponses = await FormResponse.find(query) + .lean() + .sort({ 'userInfo.name': sortOrder === 'asc' ? 1 : -1 }); const skillFilters = skills ? skills.split(',').map((s) => s.trim().toLowerCase()) : []; - const structuredMembers = formResponses.map((member) => { - const { userInfo, frontend, backend, general } = member; - const extractSkills = (section) => - Object.entries(section || {}).reduce((acc, [key, val]) => { - const num = parseFloat(val); - if (key.toLowerCase() !== 'overall' && !Number.isNaN(num)) { - acc[key] = num; - } + // Extract skill keys that have a numeric rating value, excluding 'overall' and internal fields + const extractSkills = (section) => { + if (!section || typeof section !== 'object') return {}; + return Object.entries(section).reduce((acc, [key, val]) => { + if (key.toLowerCase() === 'overall' || key.startsWith('$') || key.startsWith('_')) { return acc; - }, {}); + } + const num = parseFloat(val); + if (!Number.isNaN(num)) { + acc[key] = num; + } + return acc; + }, {}); + }; + const structuredMembers = formResponses.map((member) => { + const { userInfo, frontend, backend, general } = member; return { _id: member._id, name: userInfo?.name || 'N/A', @@ -42,12 +55,11 @@ const communityMemberController = function () { const filteredMembers = skillFilters.length > 0 ? structuredMembers.filter((member) => { - const allSkills = { - ...member.skills.frontend, - ...member.skills.backend, - }; - const lowercased = Object.keys(allSkills).map((s) => s.toLowerCase()); - return skillFilters.every((filterSkill) => lowercased.includes(filterSkill)); + const allSkillKeys = [ + ...Object.keys(member.skills.frontend), + ...Object.keys(member.skills.backend), + ].map((s) => s.toLowerCase()); + return skillFilters.every((filterSkill) => allSkillKeys.includes(filterSkill)); }) : structuredMembers; diff --git a/src/controllers/jobAnalytics/JobHitsAndApplicationsController.js b/src/controllers/jobAnalytics/JobHitsAndApplicationsController.js new file mode 100644 index 000000000..013025b54 --- /dev/null +++ b/src/controllers/jobAnalytics/JobHitsAndApplicationsController.js @@ -0,0 +1,75 @@ +const JobHitsAndApplications = require('../../models/jobAnalytics/JobHitsAndApplications'); + +const jobHitsAndApplicationsController = function () { + const mapHitsAndApplicationsToRole = (data) => { + const roleMap = data.reduce((acc, item) => { + const { role, hit, applied } = item; + + if (!acc[role]) { + acc[role] = { + role, + hits: 0, + applications: 0, + }; + } + + if (hit) acc[role].hits += 1; + if (applied) acc[role].applications += 1; + + return acc; + }, {}); + + return Object.values(roleMap); + }; + + const createJobHitsAndApplications = async (req, res) => { + try { + const rows = req.body; + + rows.forEach(async (row) => { + const newJobHitsAndApplications = new JobHitsAndApplications({ + ...row, + date: new Date(row.date), + }); + await newJobHitsAndApplications.save(); + }); + + return res.status(201).json({ message: 'Data added successfully' }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + message: error.message, + }); + } + }; + + const getJobHitsAndApplications = async (req, res) => { + try { + const { startDate, endDate, roles } = req.query; + + const query = {}; + + if (startDate) query.date = { $gte: new Date(startDate) }; + if (endDate) query.date = { ...query.date, $lte: new Date(endDate) }; + if (roles) query.role = { $in: roles.split(',') }; + + const jobHitsAndApplicationsData = await JobHitsAndApplications.find(query).sort({ date: 1 }); + + const jobResults = mapHitsAndApplicationsToRole(jobHitsAndApplicationsData); + + return res.status(200).json(jobResults); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + message: error.message, + }); + } + }; + + return { + createJobHitsAndApplications, + getJobHitsAndApplications, + }; +}; + +module.exports = jobHitsAndApplicationsController; diff --git a/src/controllers/taskCommentController.js b/src/controllers/taskCommentController.js new file mode 100644 index 000000000..376555bd4 --- /dev/null +++ b/src/controllers/taskCommentController.js @@ -0,0 +1,124 @@ +const TaskComment = require('../models/taskComment'); +const StudentTask = require('../models/studentTask'); +const User = require('../models/userTask'); + +const findAndValidateUser = async (userId, allowedRole) => { + if (!userId) { + return { error: { status: 403, message: 'userId required' } }; + } + + const user = await User.findById(userId); + if (!user) { + return { error: { status: 403, message: 'Invalid userId' } }; + } + + if (user.role !== allowedRole) { + return { + error: { + status: 403, + message: + allowedRole === 'student' + ? 'Only students can access this data' + : 'Only educators can access this data', + }, + }; + } + + return { user }; +}; + +const findTaskByTaskId = async (taskId) => { + const task = await StudentTask.findOne({ taskId }); + if (!task) { + return { error: { status: 404, message: 'Task does not exist' } }; + } + + return { task }; +}; + +const formatComment = (comment) => ({ + commentId: comment._id.toString(), + taskId: comment.taskId, + userId: comment.userId?.toString(), + commentText: comment.commentText, + created_at: comment.created_at, +}); + +const getComments = async (filter) => { + const comments = await TaskComment.find(filter, { isDeleted: 0, __v: 0 }) + .sort({ created_at: 1 }) + .lean(); + + return comments.map(formatComment); +}; + +const handleServerError = (res, err) => { + console.error(err); + return res.status(500).json({ message: 'Server error' }); +}; + +exports.postStudentComments = async (req, res) => { + try { + const { taskId } = req.params; + const { commentText } = req.body; + const { userId } = req.query; + + const userResult = await findAndValidateUser(userId, 'student'); + if (userResult.error) { + return res.status(userResult.error.status).json({ message: userResult.error.message }); + } + + if (!commentText || commentText.trim() === '') { + return res.status(400).json({ message: 'commentText cannot be empty' }); + } + + const taskResult = await findTaskByTaskId(taskId); + if (taskResult.error) { + return res.status(taskResult.error.status).json({ message: taskResult.error.message }); + } + + const comment = await TaskComment.create({ + taskId, + userId: userResult.user._id, + commentText, + }); + + return res.status(201).json({ + ...formatComment(comment), + isDeleted: false, + }); + } catch (err) { + return handleServerError(res, err); + } +}; + +const getStudentComments = (allowedRole) => async (req, res) => { + try { + const { taskId } = req.params; + const { userId } = req.query; + + const userResult = await findAndValidateUser(userId, allowedRole); + if (userResult.error) { + return res.status(userResult.error.status).json({ message: userResult.error.message }); + } + + const taskResult = await findTaskByTaskId(taskId); + if (taskResult.error) { + return res.status(taskResult.error.status).json({ message: taskResult.error.message }); + } + + const filter = { + taskId, + isDeleted: false, + ...(allowedRole === 'student' && { userId: userResult.user._id }), + }; + + const comments = await getComments(filter); + return res.json(comments); + } catch (err) { + return handleServerError(res, err); + } +}; + +exports.getStudentCommentsbyStudent = getStudentComments('student'); +exports.getStudentCommentsbyEducator = getStudentComments('educator'); diff --git a/src/controllers/userProfileController.js b/src/controllers/userProfileController.js index 412b0a42d..8a98c31c6 100644 --- a/src/controllers/userProfileController.js +++ b/src/controllers/userProfileController.js @@ -42,6 +42,64 @@ const reportsController = require('./reportsController')(); const SEARCH_RESULT_LIMIT = 10; const MAX_WEEKS_FOR_CACHE_INVALIDATION = 3; const MAX_WEEKS_FOR_CACHE_CLEAR = 10; +const HOURS_TO_ADD_FOR_END_DATE = 7; +const WEEKS_BEFORE_END_DATE_FOR_EMAIL = 3; +const MAX_WEEKLY_SUMMARIES = 4; + +const getCurrentWeekSummaryTemplate = () => ({ + dueDate: moment().tz('America/Los_Angeles').endOf('week').toDate(), + summary: '', +}); + +const normalizeWeeklySummaries = (weeklySummaries) => { + const currentWeekDueDate = moment().tz('America/Los_Angeles').endOf('week'); + const summaries = Array.isArray(weeklySummaries) ? weeklySummaries : []; + + const normalizedSummaries = summaries + .filter((entry) => entry && typeof entry === 'object') + .map((entry) => { + const normalizedDueDate = moment(entry.dueDate); + const summary = typeof entry.summary === 'string' ? entry.summary : ''; + const normalizedEntry = { + ...entry, + summary, + }; + + if (normalizedDueDate.isValid()) { + normalizedEntry.dueDate = normalizedDueDate.toDate(); + } else { + normalizedEntry.dueDate = currentWeekDueDate.toDate(); + } + + if (entry.uploadDate) { + const normalizedUploadDate = moment(entry.uploadDate); + if (normalizedUploadDate.isValid()) { + normalizedEntry.uploadDate = normalizedUploadDate.toDate(); + } else { + delete normalizedEntry.uploadDate; + } + } + + return normalizedEntry; + }) + .sort((left, right) => moment(right.dueDate).valueOf() - moment(left.dueDate).valueOf()); + + const hasCurrentWeekEntry = normalizedSummaries.some((entry) => + moment(entry.dueDate).isSame(currentWeekDueDate, 'week'), + ); + + if (!hasCurrentWeekEntry) { + normalizedSummaries.unshift(getCurrentWeekSummaryTemplate()); + } else { + const currentWeekEntryIndex = normalizedSummaries.findIndex((entry) => + moment(entry.dueDate).isSame(currentWeekDueDate, 'week'), + ); + const [currentWeekEntry] = normalizedSummaries.splice(currentWeekEntryIndex, 1); + normalizedSummaries.unshift(currentWeekEntry); + } + + return normalizedSummaries.slice(0, MAX_WEEKLY_SUMMARIES); +}; const { COMPANY_TZ } = require('../constants/company'); const { InactiveReason, @@ -282,7 +340,7 @@ const createControllerMethods = function (UserProfile, Project, cache) { up.createdDate = req.body.createdDate; up.startDate = req.body.startDate ? req.body.startDate : req.body.createdDate; up.email = req.body.email; - up.weeklySummaries = req.body.weeklySummaries || [{ summary: '' }]; + up.weeklySummaries = normalizeWeeklySummaries(req.body.weeklySummaries); up.weeklySummariesCount = req.body.weeklySummariesCount || 0; up.weeklySummaryOption = req.body.weeklySummaryOption; up.mediaUrl = req.body.mediaUrl || ''; @@ -400,7 +458,10 @@ const createControllerMethods = function (UserProfile, Project, cache) { commonFields.forEach((fieldName) => { if (req.body[fieldName] !== undefined) { - record[fieldName] = req.body[fieldName]; + record[fieldName] = + fieldName === 'weeklySummaries' + ? normalizeWeeklySummaries(req.body[fieldName]) + : req.body[fieldName]; } }); record.lastModifiedDate = Date.now(); @@ -518,7 +579,10 @@ const createControllerMethods = function (UserProfile, Project, cache) { importantFields.forEach((fieldName) => { if (req.body[fieldName] !== undefined) { - record[fieldName] = req.body[fieldName]; + record[fieldName] = + fieldName === 'weeklySummaries' + ? normalizeWeeklySummaries(req.body[fieldName]) + : req.body[fieldName]; } }); diff --git a/src/helpers/__tests__/checkPersonalMax.spec.js b/src/helpers/__tests__/checkPersonalMax.spec.js new file mode 100644 index 000000000..a33187878 --- /dev/null +++ b/src/helpers/__tests__/checkPersonalMax.spec.js @@ -0,0 +1,246 @@ +const mongoose = require('mongoose'); + +/* ======================= + MOCKS (MUST COME FIRST) + ======================= */ + +jest.mock('../../models/userProfile', () => ({ + updateOne: jest.fn(), + findByIdAndUpdate: jest.fn(), +})); + +jest.mock('../../models/badge', () => ({ + find: jest.fn(), +})); + +// Mock all heavy dependencies userHelper pulls in +jest.mock('../../models/team', () => ({})); +jest.mock('../../models/timeentry', () => ({})); +jest.mock('../../models/timeOffRequest', () => ({})); +jest.mock('../../models/profileInitialSetupToken', () => ({})); +jest.mock('../../models/BlueSquareEmailAssignment', () => ({})); +jest.mock('../../helpers/dashboardhelper', () => () => ({})); +jest.mock('../../helpers/helperModels/myTeam', () => ({})); +jest.mock('../../utilities/emailSender', () => ({})); +jest.mock('../../utilities/timeUtils', () => ({})); +jest.mock('../../services/notificationService', () => ({})); +jest.mock('../../constants/message', () => ({ NEW_USER_BLUE_SQUARE_NOTIFICATION_MESSAGE: '' })); +jest.mock('../../utilities/nodeCache', () => () => ({ + hasCache: jest.fn(() => false), + removeCache: jest.fn(), + getCache: jest.fn(), + setCache: jest.fn(), +})); +jest.mock('../../startup/logger', () => ({ + logException: jest.fn(), +})); +jest.mock('sharp', () => ({})); + +/* ======================= + IMPORTS AFTER MOCKS + ======================= */ + +const userProfile = require('../../models/userProfile'); +const badge = require('../../models/badge'); +const userHelperFactory = require('../userHelper'); + +const { checkPersonalMax } = userHelperFactory(); + +/* ======================= + HELPERS + ======================= */ + +const masterBadgeId = new mongoose.Types.ObjectId(); +const personId = new mongoose.Types.ObjectId(); + +const makeBadge = (overrides = {}) => ({ + _id: new mongoose.Types.ObjectId(), + badge: { _id: masterBadgeId, type: 'Personal Max' }, + count: 1, + earnedDate: ['Jan-01-25'], + ...overrides, +}); + +const makeUser = (overrides = {}) => ({ + lastWeekTangibleHrs: 10, + savedTangibleHrs: [10], + personalBestMaxHrs: 0, + ...overrides, +}); + +beforeEach(() => { + jest.clearAllMocks(); + badge.find.mockResolvedValue([{ _id: masterBadgeId, type: 'Personal Max' }]); + userProfile.updateOne.mockResolvedValue({}); + userProfile.findByIdAndUpdate.mockImplementation((id, update, cb) => { + if (typeof cb === 'function') cb(null); + return Promise.resolve({}); + }); +}); + +/* ======================= + TESTS + ======================= */ + +describe('checkPersonalMax', () => { + // 1. No master badge in DB — should bail out early + test('does nothing if no master Personal Max badge exists', async () => { + badge.find.mockResolvedValue([]); + const user = makeUser(); + await checkPersonalMax(personId, user, []); + expect(userProfile.updateOne).not.toHaveBeenCalled(); + expect(userProfile.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + // 2. New user — no badge yet, first week logging hours + test('adds badge for new user and sets initial personal max', async () => { + const user = makeUser({ + lastWeekTangibleHrs: 10, + savedTangibleHrs: [10], + personalBestMaxHrs: 0, + }); + await checkPersonalMax(personId, user, []); + // badge should be added + expect(userProfile.findByIdAndUpdate).toHaveBeenCalled(); + // record should be set since previousMax = 0 and lastWeek = 10 + expect(userProfile.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ _id: personId }), + expect.objectContaining({ $set: expect.objectContaining({ personalBestMaxHrs: 10 }) }), + ); + }); + + // 3. New user logs 0 hours — badge added but no record set + test('adds badge for new user but does not set record if 0 hours logged', async () => { + const user = makeUser({ lastWeekTangibleHrs: 0, savedTangibleHrs: [0], personalBestMaxHrs: 0 }); + await checkPersonalMax(personId, user, []); + expect(userProfile.findByIdAndUpdate).toHaveBeenCalled(); + expect(userProfile.updateOne).not.toHaveBeenCalled(); + }); + + // 4. Existing user breaks the record + test('updates earnedDate and personalBestMaxHrs when record is broken', async () => { + const user = makeUser({ + lastWeekTangibleHrs: 20, + savedTangibleHrs: [10, 8, 15, 12, 20], + personalBestMaxHrs: 15, + }); + await checkPersonalMax(personId, user, [makeBadge()]); + expect(userProfile.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ _id: personId }), + expect.objectContaining({ $set: expect.objectContaining({ personalBestMaxHrs: 20 }) }), + ); + }); + + // 5. Existing user does not break the record + test('does not update badge when record is not broken', async () => { + const user = makeUser({ + lastWeekTangibleHrs: 10, + savedTangibleHrs: [10, 8, 15, 12, 10], + personalBestMaxHrs: 15, + }); + await checkPersonalMax(personId, user, [makeBadge()]); + expect(userProfile.updateOne).not.toHaveBeenCalled(); + }); + + // 6. Existing user ties the record — should NOT update + test('does not update badge when hours tie the previous record', async () => { + const user = makeUser({ + lastWeekTangibleHrs: 15, + savedTangibleHrs: [10, 8, 15, 12, 15], + personalBestMaxHrs: 15, + }); + await checkPersonalMax(personId, user, [makeBadge()]); + expect(userProfile.updateOne).not.toHaveBeenCalled(); + }); + + // 7. Existing user logs 0 hours + test('does not update badge when user logs 0 hours', async () => { + const user = makeUser({ + lastWeekTangibleHrs: 0, + savedTangibleHrs: [10, 8, 0], + personalBestMaxHrs: 10, + }); + await checkPersonalMax(personId, user, [makeBadge()]); + expect(userProfile.updateOne).not.toHaveBeenCalled(); + }); + + // 8. User has duplicate Personal Max badges — duplicates removed, record not broken + test('removes duplicate badges when record is not broken', async () => { + const badge1 = makeBadge(); + const badge2 = makeBadge(); + const user = makeUser({ + lastWeekTangibleHrs: 5, + savedTangibleHrs: [10, 5], + personalBestMaxHrs: 10, + }); + await checkPersonalMax(personId, user, [badge1, badge2]); + // removeDupBadge calls findByIdAndUpdate with $pull + expect(userProfile.findByIdAndUpdate).toHaveBeenCalledWith( + personId, + expect.objectContaining({ $pull: expect.anything() }), + expect.anything(), + expect.anything(), + ); + expect(userProfile.updateOne).not.toHaveBeenCalled(); + }); + + // 9. User has duplicate badges AND breaks record + test('removes duplicates and updates badge when record is broken', async () => { + const badge1 = makeBadge(); + const badge2 = makeBadge(); + const user = makeUser({ + lastWeekTangibleHrs: 25, + savedTangibleHrs: [10, 8, 20, 25], + personalBestMaxHrs: 20, + }); + await checkPersonalMax(personId, user, [badge1, badge2]); + expect(userProfile.findByIdAndUpdate).toHaveBeenCalled(); + expect(userProfile.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ _id: personId }), + expect.objectContaining({ $set: expect.objectContaining({ personalBestMaxHrs: 25 }) }), + ); + }); + + // 10. Full savedTangibleHrs array (200 entries), last entry is current week + test('correctly identifies record break with full 200-entry history', async () => { + const history = Array(199).fill(20); // previous 199 weeks all at 20hrs + const savedTangibleHrs = [...history, 25]; // current week = 25 + const user = makeUser({ + lastWeekTangibleHrs: 25, + savedTangibleHrs, + personalBestMaxHrs: 20, + }); + await checkPersonalMax(personId, user, [makeBadge()]); + expect(userProfile.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ _id: personId }), + expect.objectContaining({ $set: expect.objectContaining({ personalBestMaxHrs: 25 }) }), + ); + }); + + // 11. Full 200-entry history, current week does NOT break record + test('does not update when current week does not beat full 200-entry history', async () => { + const history = Array(199).fill(20); + history[100] = 30; // a previous week had 30hrs + const savedTangibleHrs = [...history, 25]; // current week = 25, previous max = 30 + const user = makeUser({ + lastWeekTangibleHrs: 25, + savedTangibleHrs, + personalBestMaxHrs: 30, + }); + await checkPersonalMax(personId, user, [makeBadge()]); + expect(userProfile.updateOne).not.toHaveBeenCalled(); + }); + + // 12. earnedDate is replaced (not appended) when record is broken + test('replaces earnedDate array rather than appending', async () => { + const user = makeUser({ + lastWeekTangibleHrs: 30, + savedTangibleHrs: [10, 20, 30], + personalBestMaxHrs: 20, + }); + await checkPersonalMax(personId, user, [makeBadge({ earnedDate: ['Jan-01-25', 'Feb-01-25'] })]); + const setArg = userProfile.updateOne.mock.calls[0][1].$set; + expect(Array.isArray(setArg['badgeCollection.$.earnedDate'])).toBe(true); + expect(setArg['badgeCollection.$.earnedDate']).toHaveLength(1); + }); +}); diff --git a/src/helpers/userHelper.js b/src/helpers/userHelper.js index 63028fea8..f3c0f1a03 100644 --- a/src/helpers/userHelper.js +++ b/src/helpers/userHelper.js @@ -288,6 +288,7 @@ const userHelper = function () { try { const results = await reportHelper.weeklySummaries(weekIndex, weekIndex); + const activeResults = results.filter((user) => user.isActive === true); // checks for userProfiles who are eligible to receive the weeklySummary Reports const userProfileResults = await userProfile.find( { getWeeklyReport: true }, @@ -306,12 +307,12 @@ const userHelper = function () { const weeklySummaryNotRequiredMessage = '