diff --git a/src/controllers/currentWarningsController.js b/src/controllers/currentWarningsController.js index 53bd54fb8..b23d1ad34 100644 --- a/src/controllers/currentWarningsController.js +++ b/src/controllers/currentWarningsController.js @@ -2,6 +2,7 @@ const mongoose = require('mongoose'); const userProfile = require('../models/userProfile'); const helper = require('../utilities/permissions'); +const { setOutdatedWarningsFlag } = require('../utilities/warningsCache'); const currentWarningsController = function (currentWarnings) { const normalizeWarningTitle = (warningTitle /*: string */) => warningTitle.toLowerCase().trim(); @@ -9,15 +10,45 @@ const currentWarningsController = function (currentWarnings) { return !/^[a-zA-Z][a-zA-Z0-9,+-]*(?: [a-zA-Z0-9,+-]+)*$/.test(warning); }; + const addMissingOrderValues = async (warnings) => { + const updates = []; + warnings.forEach((warning, index) => { + if (warning.order === null || warning.order === undefined || warning.order === -1) { + updates.push({ + updateOne: { + filter: { _id: warning._id }, + update: { $set: { order: index } }, + }, + }); + } + }); + + if (updates.length > 0) { + await currentWarnings.bulkWrite(updates); + } + // only updates warnings missing an order value + return warnings.map((warning, index) => ({ + ...warning, + order: warning.order ?? index, + })); + }; + const getCurrentWarnings = async (req, res) => { try { - const response = await currentWarnings.find({}); + const response = await currentWarnings.find({}).sort({ order: 1 }); if (response.length === 0) { return res.status(400).send({ message: 'No records', response: response }); } - return res.status(200).send({ currentWarningDescriptions: response }); + + const missingOrder = response.some( + (warning) => warning.order === null || warning.order === undefined || warning.order === -1, + ); + + const updatedWarnings = missingOrder ? await addMissingOrderValues(response) : response; + return res.status(200).send({ currentWarningDescriptions: updatedWarnings }); } catch (error) { + console.log('Entered error of fetch warnings'); res.status(401).send({ message: error.message || error }); } }; @@ -55,6 +86,7 @@ const currentWarningsController = function (currentWarnings) { isPermanent, }).save(); + setOutdatedWarningsFlag(); const updatedWarnings = await currentWarnings.find({}); return res.status(201).send({ newWarnings: updatedWarnings }); } catch (error) { @@ -63,6 +95,14 @@ const currentWarningsController = function (currentWarnings) { }; const editWarningDescription = async (req, res) => { + if ( + !(await helper.hasPermission(req.body.requestor, 'addWarningTracker')) && + !(await helper.hasPermission(req.body.requestor, 'deleteWarningTracker')) + ) { + res.status(403).send('You are not authorized to edit a WarningTracker.'); + return; + } + try { const { editedWarning } = req.body; const normalizedWarningTitle = normalizeWarningTitle(editedWarning.warningTitle); @@ -91,11 +131,49 @@ const currentWarningsController = function (currentWarnings) { warning.warningTitle = trimmedWarning; await warning.save(); + setOutdatedWarningsFlag(); res.status(201).send({ message: 'warning description was updated' }); } catch (error) { res.status(401).send({ message: error.message || error }); } }; + + const reorderWarningDescriptions = async (req, res) => { + if ( + !(await helper.hasPermission(req.body.requestor, 'addWarningTracker')) && + !(await helper.hasPermission(req.body.requestor, 'deleteWarningTracker')) + ) { + res.status(403).send('You are not authorized to edit the order of the WarningTrackers.'); + return; + } + try { + const reorderedWarningDescriptions = req.body.warningDescriptions; + const response = await currentWarnings.find({}).sort({ order: 1 }); + + reorderedWarningDescriptions.map((warning, index) => (warning.order = index)); + await currentWarnings.bulkWrite( + response.map((warning, index) => ({ + updateOne: { + filter: { _id: warning._id }, + update: { + $set: { + order: reorderedWarningDescriptions.findIndex( + (warn) => warn.warningTitle === warning.warningTitle, + ), + }, + }, + }, + })), + ); + + setOutdatedWarningsFlag(); + res.status(201).send({ + reorderedWarningDescriptions: reorderedWarningDescriptions, + }); + } catch (error) { + res.status(401).send({ message: error.message || error }); + } + }; const updateWarningDescription = async (req, res) => { if ( !(await helper.hasPermission(req.body.requestor, 'reactivateWarningTracker')) && @@ -116,7 +194,7 @@ const currentWarningsController = function (currentWarnings) { [{ $set: { activeWarning: { $not: '$activeWarning' } } }], { new: true }, ); - + setOutdatedWarningsFlag(); res.status(201).send({ message: 'warning description was updated' }); } catch (error) { res.status(401).send({ message: error.message || error }); @@ -148,7 +226,7 @@ const currentWarningsController = function (currentWarnings) { }, }, ); - + setOutdatedWarningsFlag(); return res.status(200); } catch (error) { res.status(401).send({ message: error.message || error }); @@ -161,6 +239,7 @@ const currentWarningsController = function (currentWarnings) { updateWarningDescription, deleteWarningDescription, editWarningDescription, + reorderWarningDescriptions, }; }; module.exports = currentWarningsController; diff --git a/src/controllers/currentWarningsController.spec.js b/src/controllers/currentWarningsController.spec.js new file mode 100644 index 000000000..d18576e26 --- /dev/null +++ b/src/controllers/currentWarningsController.spec.js @@ -0,0 +1,129 @@ +const helper = require('../utilities/permissions'); +const UserProfile = require('../models/userProfile'); +const currentWarnings = require('../models/currentWarnings'); +const { mockReq, mockRes } = require('../test'); +const currentWarningsController = require('./currentWarningsController'); + +const makeSut = () => { + const { + getCurrentWarnings, + postNewWarningDescription, + updateWarningDescription, + deleteWarningDescription, + editWarningDescription, + reorderWarningDescriptions, + } = currentWarningsController(currentWarnings); + + return { + getCurrentWarnings, + postNewWarningDescription, + updateWarningDescription, + deleteWarningDescription, + editWarningDescription, + reorderWarningDescriptions, + }; +}; + +// meant for failed response +const assertResMock = (statusCode, message, response) => { + expect(mockRes.status).toHaveBeenCalledWith(statusCode); + expect(mockRes.send).toHaveBeenCalledWith(message); + expect(response).toBeUndefined(); +}; + +const mockHasPermission = (value) => + jest.spyOn(helper, 'hasPermission').mockImplementation(() => Promise.resolve(value)); + +describe('current warnings controller module', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('get current warnings method', () => { + test('Ensure getCurrentWarnings returns error 401 if retrieving warnings fail', async () => { + const { getCurrentWarnings } = makeSut(); + const errorMessage = 'Database Error'; + jest.spyOn(currentWarnings, 'find').mockReturnValue({ + sort: jest.fn().mockRejectedValue(new Error('Database Error')), + }); + const res = await getCurrentWarnings(mockReq, mockRes); + assertResMock(401, { message: errorMessage }, res); + }); + + test('Ensure getCurrentWarnings returns error 400 if no warnings are found', async () => { + const { getCurrentWarnings } = makeSut(); + jest.spyOn(currentWarnings, 'find').mockReturnValue({ + sort: jest.fn().mockReturnValueOnce([]), + }); + const res = await getCurrentWarnings(mockReq, mockRes); + assertResMock(400, { message: 'No records', response: [] }, res); + }); + + test('Ensure getCurrentWarnings returns warning list', async () => { + const { getCurrentWarnings } = makeSut(); + const testWarnings = [{ order: 1 }, { order: 2 }]; + jest.spyOn(currentWarnings, 'find').mockReturnValue({ + sort: jest.fn().mockReturnValueOnce([{ order: 1 }, { order: 2 }]), + }); + const res = await getCurrentWarnings(mockReq, mockRes); + assertResMock(200, { currentWarningDescriptions: testWarnings }, res); + }); + }); + + describe('post new warning description method', () => { + test('Ensure postNewWarningDescription returns error 403 if user does not have required permissions', async () => { + const { postNewWarningDescription } = makeSut(); + const hasPermissionSpy = mockHasPermission(false); + const res = await postNewWarningDescription(mockReq, mockRes); + expect(hasPermissionSpy).toHaveBeenCalledWith(mockReq.body.requestor, 'addWarningTracker'); + assertResMock(403, 'You are not authorized to add a new WarningTracker.', res); + }); + + test('Ensure postNewWarningDescription returns error 400 if warning title provided has special character as their first letter', async () => { + mockReq.body.newWarning = '#new'; + mockReq.body.isPermanent = true; + mockReq.body.activeWarning = true; + const { postNewWarningDescription } = makeSut(); + const hasPermissionSpy = mockHasPermission(true); + const res = await postNewWarningDescription(mockReq, mockRes); + expect(hasPermissionSpy).toHaveBeenCalledWith(mockReq.body.requestor, 'addWarningTracker'); + assertResMock( + 400, + { error: 'Warnings cannot have special characters as the first letter' }, + res, + ); + }); + }); + + describe('update warning description method', () => { + test('Ensure updateWarningDescription returns error 403 if user does not have required permissions', async () => { + const { updateWarningDescription } = makeSut(); + const hasPermissionSpy = mockHasPermission(false); + const res = await updateWarningDescription(mockReq, mockRes); + expect(hasPermissionSpy).toHaveBeenCalledWith( + mockReq.body.requestor, + 'reactivateWarningTracker', + ); + expect(hasPermissionSpy).toHaveBeenCalledWith( + mockReq.body.requestor, + 'deactivateWarningTracker', + ); + assertResMock( + 403, + 'You are not authorized to reactivate a WarningTracker or deactivate warning tracker.', + res, + ); + }); + }); + describe('reorder new warning description method', () => { + test('Ensure reorderWarningDescriptions returns error 403 if user does not have required permissions', async () => { + const { reorderWarningDescriptions } = makeSut(); + const hasPermissionSpy = mockHasPermission(false); + const res = await reorderWarningDescriptions(mockReq, mockRes); + expect(hasPermissionSpy).toHaveBeenCalledWith(mockReq.body.requestor, 'addWarningTracker'); + expect(hasPermissionSpy).toHaveBeenCalledWith(mockReq.body.requestor, 'deleteWarningTracker'); + console.log('mockReq: ', mockReq); + assertResMock(403, 'You are not authorized to edit the order of the WarningTrackers.', res); + }); + }); +}); diff --git a/src/controllers/warningsController.js b/src/controllers/warningsController.js index b198958ca..a5a47b147 100644 --- a/src/controllers/warningsController.js +++ b/src/controllers/warningsController.js @@ -7,15 +7,27 @@ const currentWarnings = require('../models/currentWarnings'); const emailSender = require('../utilities/emailSender'); const userHelper = require('../helpers/userHelper')(); const BlueSquareEmailAssignment = require('../models/BlueSquareEmailAssignment'); +const { + clearOutdatedWarningsFlag, + areWarningsInfoOutdated, +} = require('../utilities/warningsCache'); let currentWarningDescriptions = null; async function getWarningDescriptions() { - currentWarningDescriptions = await currentWarnings.find( - { activeWarning: true }, - { warningTitle: 1, _id: 0, abbreviation: 1 }, - ); + currentWarningDescriptions = await currentWarnings + .find({ activeWarning: true }, { warningTitle: 1, _id: 1, abbreviation: 1, order: 1 }) + .sort({ order: 1 }); + clearOutdatedWarningsFlag(); } +const checkWarningDescriptions = async () => { + const warningsOutdated = areWarningsInfoOutdated(); + if (!currentWarningDescriptions || warningsOutdated) { + await getWarningDescriptions(); + } + return warningsOutdated; +}; + const convertObjectToArray = (obj) => { const arr = []; for (const key of obj) { @@ -173,6 +185,31 @@ const sortByColorAndDate = (a, b) => { return colorComparison; }; +const checkIfWarningDescriptionMatchesWarningTrackerTitle = (warnings) => { + for (const { warningTitle, _id } of currentWarningDescriptions) { + warnings = warnings.map((warning) => { + // If warning has a warningId but description of warning does not match tracker's title, then the warning description is updated + if (_id.toString() === warning?.warningId && warningTitle !== warning?.description) { + return { ...warning, description: warningTitle }; + } + return warning; + }); + } + return warnings; +}; + +const updateWarningsMissingTrackerId = (warnings) => { + for (const { warningTitle, _id } of currentWarningDescriptions) { + warnings = warnings.map((warning) => { + if (!warning?.warningId && warningTitle === warning?.description) { + return { ...warning, warningId: _id }; + } + return warning; + }); + } + return warnings; +}; + const filterWarnings = ( warningDescriptions, warnings, @@ -238,34 +275,71 @@ const filterWarnings = ( }); const completedData = []; - - for (const { warningTitle, abbreviation } of warningDescriptions) { + for (const { warningTitle, abbreviation, order } of warningDescriptions) { completedData.push({ title: warningTitle, warnings: warns[warningTitle] ? warns[warningTitle] : [], abbreviation: abbreviation || null, + order, }); } return { completedData, sendEmail, size }; }; +const updateAllWarnings = async (userId) => { + await checkWarningDescriptions(); + try { + let userWarningList = []; + const users = await userProfile + .find({ isActive: true, firstName: 'Anthony' }, '_id warnings firstName lastName') + .lean(); + + if (!users) { + return { msg: 'No user records retrieved from database' }; + } + + await Promise.all( + users.map(async (user) => { + const updatedUserWarnings = await checkIfWarningDescriptionMatchesWarningTrackerTitle( + user.warnings, + ); + const userWarnings = await updateWarningsMissingTrackerId(updatedUserWarnings); + await userProfile.findByIdAndUpdate( + user._id, + { $set: { warnings: userWarnings } }, + { new: true }, + ); + if (userId === user._id.toString()) { + userWarningList = userWarnings; + } + }), + ); + return userWarningList; + } catch (error) { + return { error }; + } +}; + const warningsController = function (UserProfile) { const getWarningsByUserId = async function (req, res) { - if (!currentWarningDescriptions) { - await getWarningDescriptions(); - } + const warningsOutdated = await checkWarningDescriptions(); const { userId } = req.params; try { - const record = await UserProfile.findById(userId); + const record = await UserProfile.findById(userId).lean(); if (!record || !record.warnings) { return res.status(400).send({ message: 'no valiud records' }); } - const { completedData } = filterWarnings(currentWarningDescriptions, record.warnings); + let warningsList = record.warnings; + if (warningsOutdated) { + warningsList = await updateAllWarnings(userId); + } + + const { completedData } = filterWarnings(currentWarningDescriptions, warningsList); return res.status(201).send({ warnings: completedData }); } catch (error) { return res.status(401).send({ message: error.message || error }); @@ -314,7 +388,12 @@ const warningsController = function (UserProfile) { const { userId } = req.params; const { warningsArray, issueBlueSquare, monitorData, iconId, color, date, description } = req.body; - + let warningId = ''; + for (const { warningTitle, _id } of currentWarningDescriptions) { + if (warningTitle === description) { + warningId = _id; + } + } const record = await UserProfile.findById(userId); if (!record || !record.warnings) { @@ -338,7 +417,7 @@ const warningsController = function (UserProfile) { const updateData = warningsArray ? { $push: { warnings: { $each: warningsArray } } } - : { $push: { warnings: { userId, iconId, color, date, description } } }; + : { $push: { warnings: { userId, iconId, color, date, description, warningId } } }; const updatedWarnings = await UserProfile.findByIdAndUpdate({ _id: userId }, updateData, { new: true, diff --git a/src/models/currentWarnings.js b/src/models/currentWarnings.js index b4ff0eea7..ce3ca0ac9 100644 --- a/src/models/currentWarnings.js +++ b/src/models/currentWarnings.js @@ -8,6 +8,7 @@ const currentWarnings = new Schema({ isPermanent: { type: Boolean, required: true }, isSpecial: { type: Boolean }, abbreviation: { type: String }, + order: { type: Number }, }); module.exports = mongoose.model('currentWarning', currentWarnings, 'currentWarnings'); diff --git a/src/models/userProfile.js b/src/models/userProfile.js index 700f5534f..06a1edbf9 100644 --- a/src/models/userProfile.js +++ b/src/models/userProfile.js @@ -194,6 +194,7 @@ const userProfileSchema = new Schema({ default: 'white', }, iconId: { type: String, required: false }, + warningId: { type: String, default: null }, }, ], location: { diff --git a/src/routes/curentWarningsRouter.js b/src/routes/curentWarningsRouter.js index f1a004493..3ea6a2726 100644 --- a/src/routes/curentWarningsRouter.js +++ b/src/routes/curentWarningsRouter.js @@ -8,7 +8,8 @@ const route = function (currentWarnings) { currentWarningsRouter .route('/currentWarnings') .get(controller.getCurrentWarnings) - .post(controller.postNewWarningDescription); + .post(controller.postNewWarningDescription) + .put(controller.reorderWarningDescriptions); currentWarningsRouter.route('/currentWarnings/edit').put(controller.editWarningDescription); diff --git a/src/utilities/warningsCache.js b/src/utilities/warningsCache.js new file mode 100644 index 000000000..e56508223 --- /dev/null +++ b/src/utilities/warningsCache.js @@ -0,0 +1,17 @@ +let outdated = false; + +const setOutdatedWarningsFlag = () => { + outdated = true; +}; + +const clearOutdatedWarningsFlag = () => { + outdated = false; +}; + +const areWarningsInfoOutdated = () => outdated; + +module.exports = { + setOutdatedWarningsFlag, + clearOutdatedWarningsFlag, + areWarningsInfoOutdated, +};