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 = '
Weekly Summary: Not required for this user
'; - results.sort((a, b) => + activeResults.sort((a, b) => `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`), ); - for (let i = 0; i < results.length; i += 1) { - const result = results[i]; + for (let i = 0; i < activeResults.length; i += 1) { + const result = activeResults[i]; const { firstName, lastName, @@ -2325,61 +2326,46 @@ const userHelper = function () { // 'Personal Max', const checkPersonalMax = async function (personId, user, badgeCollection) { - let badgeOfType; - const duplicateBadges = []; const currentDate = moment().tz('America/Los_Angeles').format('MMM-DD-YY'); + const lastWeek = user.lastWeekTangibleHrs; const masterBadges = await badge.find({ type: 'Personal Max' }); - console.log(`[DEBUG] Found master badges: `); + if (!masterBadges.length) return; - // Check for existing badge in badgeCollection - for (let i = 0; i < badgeCollection.length; i += 1) { - const b = badgeCollection[i]; - if (b.badge?.type === 'Personal Max') { - console.log(`[DEBUG] Found Personal Max badge at index $`); - if (!badgeOfType) { - badgeOfType = b; - } else { - duplicateBadges.push(b); - console.log(`[DEBUG] Found duplicate Personal Max badge:)}`); - } - break; - } - } + const masterBadgeId = masterBadges[0]._id; - // Remove duplicate badges - for (const b of duplicateBadges) { - // console.log(`[DEBUG] Removing duplicate badge with ID: ${b._id}`); - await removeDupBadge(personId, b._id); + // Collect all Personal Max badges from the user's collection + const personalMaxBadges = badgeCollection.filter((b) => b.badge?.type === 'Personal Max'); + + // Remove all duplicates beyond the first + for (let i = 1; i < personalMaxBadges.length; i += 1) { + await removeDupBadge(personId, personalMaxBadges[i]._id); } - // Add new badge if missing - if (!badgeOfType && masterBadges.length > 0) { - const newBadgeId = masterBadges[0]._id; - console.log(`[DEBUG] No existing badge found. Adding new badge ID: ${newBadgeId}`); - await addBadge(personId, newBadgeId); + const badgeOfType = personalMaxBadges[0] || null; + + // Add badge if user doesn't have one yet + if (!badgeOfType) { + await addBadge(personId, masterBadgeId); } - const lastWeek = user.lastWeekTangibleHrs; + // Compare against all previous weeks (exclude last entry which is the current week) const savedHrs = user.savedTangibleHrs || []; - const lastSaved = savedHrs[savedHrs.length - 1]; - const personalBest = user.personalBestMaxHrs; - - if ( - lastWeek && - lastSaved > lastWeek && - lastWeek >= personalBest && - !badgeOfType?.earnedDate?.includes(currentDate) - ) { - console.log(`[DEBUG] Conditions met to increase badge count`); - if (badgeOfType) { - await increaseBadgeCount(personId, mongoose.Types.ObjectId(badgeOfType.badge._id)); - } - } + const previousMax = savedHrs.length > 1 ? Math.max(...savedHrs.slice(0, -1)) : 0; - console.log(`[DEBUG] Updating personal max...`); - await updatePersonalMax(personId, user); - console.log(`[DEBUG] checkPersonalMax complete for personId: ${personId}`); + // If last week's hours broke the personal record, update the badge's earnedDate and personalBestMaxHrs + if (lastWeek && lastWeek > previousMax) { + await userProfile.updateOne( + { _id: personId, 'badgeCollection.badge': masterBadgeId }, + { + $set: { + 'badgeCollection.$.earnedDate': [currentDate], + 'badgeCollection.$.lastModified': Date.now().toString(), + personalBestMaxHrs: lastWeek, + }, + }, + ); + } }; // 'Most Hrs in Week' @@ -3426,6 +3412,7 @@ const userHelper = function () { sendUserSeparatedEmail, sendUserReactivatedAfterSeparation, weeklyCompanySummaryEmail, + checkPersonalMax, }; }; diff --git a/src/models/bmdashboard/buildingInventoryType.js b/src/models/bmdashboard/buildingInventoryType.js index 7dcaa38dc..ff4a0298c 100644 --- a/src/models/bmdashboard/buildingInventoryType.js +++ b/src/models/bmdashboard/buildingInventoryType.js @@ -23,10 +23,13 @@ const invTypeBase = mongoose.model('invTypeBase', invTypeBaseSchema, 'buildingIn // ex: sand, stone, brick, lumber -const materialType = invTypeBase.discriminator('material_type', new mongoose.Schema({ - category: { type: String, enum: ['Material'] }, - unit: { type: String, required: true }, // unit of measurement -})); +const materialType = invTypeBase.discriminator( + 'material_type', + new mongoose.Schema({ + category: { type: String, enum: ['Material'] }, + unit: { type: String, required: true }, // unit of measurement + }), +); //--------------------------- // CONSUMABLE TYPE @@ -34,11 +37,14 @@ const materialType = invTypeBase.discriminator('material_type', new mongoose.Sch // ex: screws, nails, staples -const consumableType = invTypeBase.discriminator('consumable_type', new mongoose.Schema({ - category: { type: String, enum: ['Consumable'] }, - unit: { type: String, required: true }, - size: { type: String, required: false }, -})); +const consumableType = invTypeBase.discriminator( + 'consumable_type', + new mongoose.Schema({ + category: { type: String, enum: ['Consumable'] }, + unit: { type: String, required: true }, + size: { type: String, required: false }, + }), +); //--------------------------- // REUSABLE TYPE @@ -46,9 +52,12 @@ const consumableType = invTypeBase.discriminator('consumable_type', new mongoose // ex: gloves, brushes, hammers, screwdrivers -const reusableType = invTypeBase.discriminator('reusable_type', new mongoose.Schema({ - category: { type: String, enum: ['Reusable'] }, -})); +const reusableType = invTypeBase.discriminator( + 'reusable_type', + new mongoose.Schema({ + category: { type: String, enum: ['Reusable'] }, + }), +); //--------------------------- // TOOL TYPE @@ -56,26 +65,29 @@ const reusableType = invTypeBase.discriminator('reusable_type', new mongoose.Sch // ex: shovels, wheelbarrows, power drills, jackhammers -const toolType = invTypeBase.discriminator('tool_type', new mongoose.Schema({ - category: { type: String, enum: ['Tool'] }, - invoice: String, - purchaseRental: String, - fromDate: Date, - toDate:Date, - condition: String, - phoneNumber: String, - quantity: Number, - currency: String, - unitPrice: Number, - shippingFee: Number, - taxes: Number, - totalPriceWithShipping: Number, - images: String, - link: String, - - // isPowered: { type: Boolean, required: true }, - // powerSource: { type: String, required: () => this.isPowered }, // required if isPowered = true (syntax?) -})); +const toolType = invTypeBase.discriminator( + 'tool_type', + new mongoose.Schema({ + category: { type: String, enum: ['Tool'] }, + invoice: String, + purchaseRental: String, + fromDate: Date, + toDate: Date, + condition: String, + phoneNumber: String, + quantity: Number, + currency: String, + unitPrice: Number, + shippingFee: Number, + taxes: Number, + totalPriceWithShipping: Number, + images: String, + link: String, + + // isPowered: { type: Boolean, required: true }, + // powerSource: { type: String, required: () => this.isPowered }, // required if isPowered = true (syntax?) + }), +); //--------------------------- // EQUIPMENT TYPE @@ -83,10 +95,49 @@ const toolType = invTypeBase.discriminator('tool_type', new mongoose.Schema({ // ex: tractors, excavators -const equipmentType = invTypeBase.discriminator('equipment_type', new mongoose.Schema({ - category: { type: String, enum: ['Equipment'] }, - fuelType: { type: String, enum: ['Diesel', 'Biodiesel', 'Gasoline', 'Natural Gas', 'Ethanol'], required: true }, -})); +const equipmentType = invTypeBase.discriminator( + 'equipment_type', + new mongoose.Schema({ + category: { type: String, enum: ['Equipment'] }, + fuelType: { + type: String, + enum: ['Diesel', 'Biodiesel', 'Gasoline', 'Natural Gas', 'Ethanol'], + required: true, + }, + }), +); +/* ========================= + INVENTORY TYPE HISTORY +========================= */ + +const invTypeHistorySchema = new Schema({ + invtypeId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'invTypeBase', + required: true, + }, + field: { + type: String, + required: true, + }, + oldValue: { + type: Schema.Types.Mixed, + }, + newValue: { + type: Schema.Types.Mixed, + }, + editedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'userProfile', + required: true, + }, + editedAt: { + type: Date, + default: Date.now, + }, +}); + +const invTypeHistory = mongoose.model('invTypeHistory', invTypeHistorySchema); module.exports = { invTypeBase, @@ -95,4 +146,5 @@ module.exports = { reusableType, toolType, equipmentType, + invTypeHistory, }; diff --git a/src/models/bmdashboard/injuryIssue.js b/src/models/bmdashboard/injuryIssue.js index 6ff3342fa..b24b4f41b 100644 --- a/src/models/bmdashboard/injuryIssue.js +++ b/src/models/bmdashboard/injuryIssue.js @@ -3,13 +3,12 @@ const mongoose = require('mongoose'); const { Schema } = mongoose; const injuryIssue = new Schema({ - projectId: { type: mongoose.SchemaTypes.ObjectId, ref: 'Project', required: true }, - name: { type: String, required: true }, - openDate: { type: Date, default: Date.now }, - category: { type: String, required: true }, - assignedTo: { type: mongoose.SchemaTypes.ObjectId, ref: 'userProfile' }, - totalCost: { type: Number } + projectId: { type: mongoose.SchemaTypes.ObjectId, ref: 'Project', required: true }, + name: { type: String, required: true }, + openDate: { type: Date, default: Date.now }, + category: { type: String, required: true }, + assignedTo: { type: mongoose.SchemaTypes.ObjectId, ref: 'userProfile' }, + totalCost: { type: Number }, }); - module.exports = mongoose.model('injuryIssue', injuryIssue, 'injuryIssue'); diff --git a/src/models/jobAnalytics/JobHitsAndApplications.js b/src/models/jobAnalytics/JobHitsAndApplications.js new file mode 100644 index 000000000..b25ca0671 --- /dev/null +++ b/src/models/jobAnalytics/JobHitsAndApplications.js @@ -0,0 +1,24 @@ +const mongoose = require('mongoose'); + +const jobHitsAndApplicationsSchema = new mongoose.Schema({ + role: { + type: String, + required: true, + }, + date: { + type: Date, + required: true, + }, + hit: { + type: Boolean, + required: true, + }, + applied: { + type: Boolean, + required: true, + }, +}); + +jobHitsAndApplicationsSchema.index({ role: 1, date: 1 }); + +module.exports = mongoose.model('JobHitsAndApplications', jobHitsAndApplicationsSchema); diff --git a/src/models/studentTask.js b/src/models/studentTask.js new file mode 100644 index 000000000..d2944035b --- /dev/null +++ b/src/models/studentTask.js @@ -0,0 +1,12 @@ +const mongoose = require('mongoose'); + +const studentTaskSchema = new mongoose.Schema({ + taskId: { type: String, required: true, unique: true }, + title: { type: String, required: true }, + description: { type: String }, + assigned_to: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true }, + due_date: { type: Date }, + created_at: { type: Date, default: Date.now }, +}); + +module.exports = mongoose.model('StudentTask', studentTaskSchema, 'studenttask'); diff --git a/src/models/taskComment.js b/src/models/taskComment.js new file mode 100644 index 000000000..5ed260f6e --- /dev/null +++ b/src/models/taskComment.js @@ -0,0 +1,37 @@ +const mongoose = require('mongoose'); + +const TaskCommentSchema = new mongoose.Schema({ + taskId: { + type: String, + ref: 'StudentTask', + required: true, + }, + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'UserTask', + required: true, + }, + commentText: { + type: String, + required: true, + trim: true, + }, + created_at: { + type: Date, + default: Date.now, + }, + + isDeleted: { + type: Boolean, + default: false, + }, +}); + +TaskCommentSchema.pre(/^find/, function (next) { + if (!this.getQuery().includeDeleted) { + this.where({ isDeleted: false }); + } + next(); +}); + +module.exports = mongoose.model('TaskComment', TaskCommentSchema, 'taskcomment'); diff --git a/src/models/userTask.js b/src/models/userTask.js new file mode 100644 index 000000000..52b02c8d7 --- /dev/null +++ b/src/models/userTask.js @@ -0,0 +1,9 @@ +const mongoose = require('mongoose'); + +const userTaskSchema = new mongoose.Schema({ + name: { type: String, required: true }, + email: { type: String, required: true, unique: true }, + role: { type: String, enum: ['student', 'educator'], required: true }, +}); + +module.exports = mongoose.model('UserTask', userTaskSchema, 'usertask'); diff --git a/src/routes/badgeRouter.js b/src/routes/badgeRouter.js index ea1a7c44d..a2d55dddf 100644 --- a/src/routes/badgeRouter.js +++ b/src/routes/badgeRouter.js @@ -5,7 +5,7 @@ const routes = function (badge) { const badgeRouter = express.Router(); - // badgeRouter.get('/badge/awardBadgesTest', controller.awardBadgesTest); + badgeRouter.post('/badge/awardNewBadges', controller.awardNewBadges); badgeRouter.route('/badge').get(controller.getAllBadges).post(controller.postBadge); diff --git a/src/routes/bmdashboard/bmInventoryTypeRouter.js b/src/routes/bmdashboard/bmInventoryTypeRouter.js index 58e3402fa..2c57438bb 100644 --- a/src/routes/bmdashboard/bmInventoryTypeRouter.js +++ b/src/routes/bmdashboard/bmInventoryTypeRouter.js @@ -1,6 +1,14 @@ const express = require('express'); -const routes = function (baseInvType, matType, consType, reusType, toolType, equipType) { +const routes = function ( + baseInvType, + matType, + consType, + reusType, + toolType, + equipType, + invTypeHistory, +) { const inventoryTypeRouter = express.Router(); const controller = require('../../controllers/bmdashboard/bmInventoryTypeController')( baseInvType, @@ -9,6 +17,7 @@ const routes = function (baseInvType, matType, consType, reusType, toolType, equ reusType, toolType, equipType, + invTypeHistory, ); // Route for fetching all material types @@ -32,6 +41,8 @@ const routes = function (baseInvType, matType, consType, reusType, toolType, equ inventoryTypeRouter.route('/invtypes/consumables').get(controller.fetchConsumableTypes); + inventoryTypeRouter.route('/invtypes/:invtypeId/history').get(controller.fetchInvTypeHistory); + // Combined routes for getting a single inventory type and updating its name and unit of measurement inventoryTypeRouter .route('/invtypes/material/:invtypeId') diff --git a/src/routes/bmdashboard/bmToolRouter.js b/src/routes/bmdashboard/bmToolRouter.js index 47ae62cbd..41c4647fe 100644 --- a/src/routes/bmdashboard/bmToolRouter.js +++ b/src/routes/bmdashboard/bmToolRouter.js @@ -9,7 +9,7 @@ const routes = function (BuildingTool, ToolType) { toolRouter.route('/tools').get(controller.fetchAllTools); - toolRouter.route('/tools/:toolId').get(controller.fetchSingleTool); + toolRouter.route('/tools/:toolId').get(controller.fetchSingleTool).put(controller.updateToolById); toolRouter.route('/tools/purchase').post(controller.bmPurchaseTools); diff --git a/src/routes/jobAnalytics/JobHitsAndApplicationsRoutes.js b/src/routes/jobAnalytics/JobHitsAndApplicationsRoutes.js new file mode 100644 index 000000000..483ee7d95 --- /dev/null +++ b/src/routes/jobAnalytics/JobHitsAndApplicationsRoutes.js @@ -0,0 +1,14 @@ +const express = require('express'); +const jobHitsAndApplicationsController = + require('../../controllers/jobAnalytics/JobHitsAndApplicationsController')(); + +const router = express.Router(); + +router + .route('/analytics/job-hits-and-applications') + .post(jobHitsAndApplicationsController.createJobHitsAndApplications); +router + .route('/analytics/job-hits-and-applications') + .get(jobHitsAndApplicationsController.getJobHitsAndApplications); + +module.exports = router; diff --git a/src/routes/taskCommentRouter.js b/src/routes/taskCommentRouter.js new file mode 100644 index 000000000..5aa4887dd --- /dev/null +++ b/src/routes/taskCommentRouter.js @@ -0,0 +1,25 @@ +const express = require('express'); +const taskCommentController = require('../controllers/taskCommentController'); + +const routes = function () { + const taskCommentRouter = express.Router(); + + taskCommentRouter.post( + '/student/tasks/:taskId/comments', + taskCommentController.postStudentComments, + ); + + taskCommentRouter.get( + '/student/tasks/:taskId/comments', + taskCommentController.getStudentCommentsbyStudent, + ); + + taskCommentRouter.get( + '/educator/tasks/:taskId/comments', + taskCommentController.getStudentCommentsbyEducator, + ); + + return taskCommentRouter; +}; + +module.exports = routes; diff --git a/src/startup/routes.js b/src/startup/routes.js index 1ec92c7e2..58839bfd9 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -79,6 +79,7 @@ const { reusableType, toolType, equipmentType, + invTypeHistory, } = require('../models/bmdashboard/buildingInventoryType'); const { buildingConsumable, @@ -166,6 +167,7 @@ const isEmailExistsRouter = require('../routes/isEmailExistsRouter')(); const jobNotificationListRouter = require('../routes/jobNotificationListRouter'); const helpCategoryRouter = require('../routes/helpCategoryRouter'); +const taskCommentRouter = require('../routes/taskCommentRouter')(); const materialUtilizationRouter = require('../routes/materialUtilizationRouter'); const userSkillsProfileRouter = require('../routes/userSkillsProfileRouter')(userProfile); @@ -224,6 +226,7 @@ const bmInventoryTypeRouter = require('../routes/bmdashboard/bmInventoryTypeRout reusableType, toolType, equipmentType, + invTypeHistory, ); const toolAvailabilityRoutes = require('../routes/bmdashboard/bmToolAvailabilityRoutes'); @@ -396,6 +399,8 @@ const actualCostRouter = require('../routes/actualCostRouter')(); const recipeRouter = require('../routes/kitchenInventory/recipeRouter')(); +const jobHitsAndApplicationsRoutes = require('../routes/jobAnalytics/JobHitsAndApplicationsRoutes'); + module.exports = function (app) { app.use('/api/bm/summary-dashboard', summaryDashboardRouter); app.use('/api', forgotPwdRouter); @@ -487,7 +492,9 @@ module.exports = function (app) { app.use('/api', weeklySummariesFilterRouter); app.use('/api/popularity', popularityTimelineRoutes); app.use('/applications', applicationRoutes); + app.use('/api', taskCommentRouter); app.use('/api/popularity-enhanced', popularityEnhancedRoutes); + app.use('/api', jobHitsAndApplicationsRoutes); // bm dashboard app.use('/api/bm', bmLoginRouter);