From e384f62c998d32ccd277484c85c4ad82b9d6609a Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 30 Sep 2020 09:39:52 +0100 Subject: [PATCH 001/144] Completed team updates to support roles --- src/resources/auth/auth.route.js | 117 ++++++++++++++++---------- src/resources/team/team.model.js | 3 +- src/resources/user/user.repository.js | 2 +- 3 files changed, 74 insertions(+), 48 deletions(-) diff --git a/src/resources/auth/auth.route.js b/src/resources/auth/auth.route.js index a661dd5b..fa238893 100644 --- a/src/resources/auth/auth.route.js +++ b/src/resources/auth/auth.route.js @@ -1,10 +1,10 @@ -import express from 'express' -import { to } from 'await-to-js' -import { verifyPassword } from '../auth/utils' -import { login } from '../auth/strategies/jwt' -import { getUserByEmail } from '../user/user.repository' -import { getRedirectUrl } from '../auth/utils' -import passport from "passport"; +import express from 'express'; +import { to } from 'await-to-js'; +import { verifyPassword } from '../auth/utils'; +import { login } from '../auth/strategies/jwt'; +import { getUserByEmail } from '../user/user.repository'; +import { getRedirectUrl } from '../auth/utils'; +import passport from 'passport'; const router = express.Router(); @@ -12,61 +12,86 @@ const router = express.Router(); // @desc login user // @access Public router.post('/login', async (req, res) => { - const { email, password } = req.body + const { email, password } = req.body; - const [err, user] = await to(getUserByEmail(email)) + const [err, user] = await to(getUserByEmail(email)); - const authenticationError = () => { - return res - .status(500) - .json({ success: false, data: "Authentication error!" }) - } + const authenticationError = () => { + return res + .status(500) + .json({ success: false, data: 'Authentication error!' }); + }; - if (!(await verifyPassword(password, user.password))) { - console.error('Passwords do not match') - return authenticationError() - } + if (!(await verifyPassword(password, user.password))) { + console.error('Passwords do not match'); + return authenticationError(); + } - const [loginErr, token] = await to(login(req, user)) + const [loginErr, token] = await to(login(req, user)); - if (loginErr) { - console.error('Log in error', loginErr) - return authenticationError() - } - - return res - .status(200) - .cookie('jwt', token, { - httpOnly: true - }) - .json({ - success: true, - data: getRedirectUrl(req.user.role) - }) + if (loginErr) { + console.error('Log in error', loginErr); + return authenticationError(); + } + return res + .status(200) + .cookie('jwt', token, { + httpOnly: true, + }) + .json({ + success: true, + data: getRedirectUrl(req.user.role), + }); }); // @router POST /api/auth/logout // @desc logout user // @access Private router.get('/logout', function (req, res) { - req.logout(); - res.clearCookie('jwt'); - return res.json({ success: true }); + req.logout(); + res.clearCookie('jwt'); + return res.json({ success: true }); }); // @router GET /api/auth/status // @desc Return the logged in status of the user and their role. // @access Private router.get('/status', function (req, res, next) { - passport.authenticate('jwt', function (err, user, info) { - if (err || !user) { - return res.json({ success: true, data: [{ role: "Reader", id: null, name: null, loggedIn: false }] }); - } - else { - return res.json({ success: true, data: [{ role: req.user.role, id: req.user.id, name: req.user.firstname + " " + req.user.lastname, loggedIn: true, teams: req.user.teams }] }); - } - })(req, res, next); + passport.authenticate('jwt', function (err, user, info) { + if (err || !user) { + return res.json({ + success: true, + data: [{ role: 'Reader', id: null, name: null, loggedIn: false }], + }); + } else { + // 1. Reformat teams array for frontend + let { teams } = req.user; + if(teams) { + teams = teams.map((team) => { + let { publisher, type, members } = team; + let member = members.find(member => { + return member.memberid.toString() === req.user._id.toString(); + }); + let { roles } = member; + return { publisher, type, roles }; + }); + } + // 2. Return user info + return res.json({ + success: true, + data: [ + { + role: req.user.role, + id: req.user.id, + name: req.user.firstname + ' ' + req.user.lastname, + loggedIn: true, + teams, + }, + ], + }); + } + })(req, res, next); }); - -module.exports = router \ No newline at end of file + +module.exports = router; diff --git a/src/resources/team/team.model.js b/src/resources/team/team.model.js index e1f537d3..4a5c3042 100644 --- a/src/resources/team/team.model.js +++ b/src/resources/team/team.model.js @@ -8,8 +8,9 @@ const TeamSchema = new Schema({ members: [{ memberid: {type: Schema.Types.ObjectId, ref: 'User'}, - roles: [String] + roles: { type: [String], enum: ['reviewer','manager'] } }], + type: String, active: { type: Boolean, default: true diff --git a/src/resources/user/user.repository.js b/src/resources/user/user.repository.js index 456111c3..472f4ece 100644 --- a/src/resources/user/user.repository.js +++ b/src/resources/user/user.repository.js @@ -3,7 +3,7 @@ import { UserModel } from './user.model'; export async function getUserById(id) { const user = await UserModel.findById(id).populate({ path: 'teams', - select: 'publisher type -_id', + select: 'publisher type members -_id', populate: { path: 'publisher', select: 'name' From 7a181760f631d5372a0487cf3e5ae8f039b26df7 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 30 Sep 2020 09:52:10 +0100 Subject: [PATCH 002/144] Flattened publisher object in user auth response --- src/resources/auth/auth.route.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/auth/auth.route.js b/src/resources/auth/auth.route.js index fa238893..bc5139cc 100644 --- a/src/resources/auth/auth.route.js +++ b/src/resources/auth/auth.route.js @@ -74,7 +74,7 @@ router.get('/status', function (req, res, next) { return member.memberid.toString() === req.user._id.toString(); }); let { roles } = member; - return { publisher, type, roles }; + return { ...publisher.toObject(), type, roles }; }); } // 2. Return user info From 787cf6a3f6b3875ee0ccf2925d24d5d94058a26b Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Wed, 30 Sep 2020 10:11:49 +0100 Subject: [PATCH 003/144] Forcing branch creation --- src/resources/googleanalytics/googleanalytics.router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/googleanalytics/googleanalytics.router.js b/src/resources/googleanalytics/googleanalytics.router.js index 7bfdb9f9..6f91a1fe 100644 --- a/src/resources/googleanalytics/googleanalytics.router.js +++ b/src/resources/googleanalytics/googleanalytics.router.js @@ -21,7 +21,7 @@ router.get('/userspermonth', async (req, res) => { }); //returns the total number of unique users -router.get('/totalusers', async (req, res) => { +router.get('/totalusers', async (req, res) => { var getTotalUsersGAPromise = WidgetAuth.getTotalUsersGA(); From ad55440fd10199392a1aeeb92167755587eba7fe Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 30 Sep 2020 12:24:10 +0100 Subject: [PATCH 004/144] Added two endpoints, one for generic team response and one for team members --- src/config/server.js | 1 + src/resources/team/team.controller.js | 65 +++++++++++++++++++++++++++ src/resources/team/team.route.js | 18 ++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/resources/team/team.controller.js create mode 100644 src/resources/team/team.route.js diff --git a/src/config/server.js b/src/config/server.js index 722d043a..a83aefb7 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -168,6 +168,7 @@ app.use('/api/v1/auth/register', require('../resources/user/user.register.route' app.use('/api/v1/users', require('../resources/user/user.route')); app.use('/api/v1/topics', require('../resources/topic/topic.route')); app.use('/api/v1/publishers', require('../resources/publisher/publisher.route')); +app.use('/api/v1/teams', require('../resources/team/team.route')); app.use('/api/v1/messages', require('../resources/message/message.route')); app.use('/api/v1/reviews', require('../resources/tool/review.route')); app.use('/api/v1/relatedobject/', require('../resources/relatedobjects/relatedobjects.route')); diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js new file mode 100644 index 00000000..9291533f --- /dev/null +++ b/src/resources/team/team.controller.js @@ -0,0 +1,65 @@ +import { TeamModel } from './team.model'; +import _ from 'lodash'; + +module.exports = { + // GET api/v1/teams/:id + getTeamById: async (req, res) => { + try { + // 1. Get the team from the database + const team = await TeamModel.findOne({ _id: req.params.id }); + if (!team) { + return res.status(404).json({ success: false }); + } + // 2. Check the current user is a member of the team + let { _id } = req.user; + let { members } = team; + let authorised = false; + if(members) { + authorised = members.some((el) => el.memberid.toString() === _id.toString()); + } + // 3. If not return unauthorised + if(!authorised) { + return res.status(401).json({ success: false }); + } + // 4. Return team + return res.status(200).json({ success: true, team }); + } catch (err) { + console.error(err.message); + return res.status(500).json(err); + } + }, + + // GET api/v1/teams/:id/members + getTeamMembers: async (req, res) => { + try { + // 1. Get the team from the database + const team = await TeamModel.findOne({ _id: req.params.id }).populate('users'); + if (!team) { + return res.status(404).json({ success: false }); + } + // 2. Check the current user is a member of the team + let { _id } = req.user; + let { members, users } = team; + let authorised = false; + if(members) { + authorised = members.some((el) => el.memberid.toString() === _id.toString()); + } + // 3. If not return unauthorised + if(!authorised) { + return res.status(401).json({ success: false }); + } + // 4. Format response to include user info + users = users.map((user) => { + let { firstname, lastname, id, _id, email } = user; + let userMember = members.find(el => el.memberid.toString() === user._id.toString()); + let { roles = [] } = userMember; + return { firstname, lastname, id, _id, email, roles }; + }); + // 5. Return team members + return res.status(200).json({ success: true, members: users }); + } catch (err) { + console.error(err.message); + return res.status(500).json(err); + } + }, +}; diff --git a/src/resources/team/team.route.js b/src/resources/team/team.route.js new file mode 100644 index 00000000..9f5f33a4 --- /dev/null +++ b/src/resources/team/team.route.js @@ -0,0 +1,18 @@ +import express from 'express'; +import passport from 'passport'; + +const teamController = require('./team.controller'); + +const router = express.Router(); + +// @route GET api/team/:id +// @desc GET A team by :id +// @access Public +router.get('/:id', passport.authenticate('jwt'), teamController.getTeamById); + +// @route GET api/team/:id/members +// @desc GET all team members for team +// @access Private +router.get('/:id/members', passport.authenticate('jwt'), teamController.getTeamMembers); + +module.exports = router From 5f009258d133fe96e21fa67fc07369eacc013e6b Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 1 Oct 2020 10:52:24 +0100 Subject: [PATCH 005/144] Added endpoints for getting workflow by Id and workflows by PublisherId --- src/config/server.js | 1 + .../publisher/publisher.controller.js | 112 ++++++++++++++---- src/resources/publisher/publisher.route.js | 5 + src/resources/workflow/workflow.controller.js | 65 ++++++++++ src/resources/workflow/workflow.model.js | 30 +++++ src/resources/workflow/workflow.route.js | 13 ++ 6 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 src/resources/workflow/workflow.controller.js create mode 100644 src/resources/workflow/workflow.model.js create mode 100644 src/resources/workflow/workflow.route.js diff --git a/src/config/server.js b/src/config/server.js index a83aefb7..4b72814c 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -169,6 +169,7 @@ app.use('/api/v1/users', require('../resources/user/user.route')); app.use('/api/v1/topics', require('../resources/topic/topic.route')); app.use('/api/v1/publishers', require('../resources/publisher/publisher.route')); app.use('/api/v1/teams', require('../resources/team/team.route')); +app.use('/api/v1/workflows', require('../resources/workflow/workflow.route')); app.use('/api/v1/messages', require('../resources/message/message.route')); app.use('/api/v1/reviews', require('../resources/tool/review.route')); app.use('/api/v1/relatedobject/', require('../resources/relatedobjects/relatedobjects.route')); diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 11daa450..ddcf22a7 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -1,6 +1,7 @@ import mongoose from 'mongoose'; import { PublisherModel } from './publisher.model'; import { DataRequestModel } from '../datarequest/datarequest.model'; +import { WorkflowModel } from '../workflow/workflow.model'; import { Data } from '../tool/data.model'; import _ from 'lodash'; @@ -13,12 +14,10 @@ module.exports = { // 1. Get the publisher from the database const publisher = await PublisherModel.findOne({ name: req.params.id }); if (!publisher) { - return res - .status(200) - .json({ - success: true, - publisher: { dataRequestModalContent: {}, allowsMessaging: false }, - }); + return res.status(200).json({ + success: true, + publisher: { dataRequestModalContent: {}, allowsMessaging: false }, + }); } // 2. Return publisher return res.status(200).json({ success: true, publisher }); @@ -68,7 +67,10 @@ module.exports = { return res.status(200).json({ success: true, datasets }); } catch (err) { console.error(err.message); - return res.status(500).json(err); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for custodian datasets', + }); } }, @@ -112,25 +114,93 @@ module.exports = { ], }) .sort({ updatedAt: -1 }) - .populate('datasets dataset mainApplicant'); - - // 6. Append projectName and applicants - let modifiedApplications = [...applications].map((app) => { - return datarequestController.createApplicationDTO(app.toObject()); - }).sort((a, b) => b.updatedAt - a.updatedAt); + .populate('datasets dataset mainApplicant'); + + // 6. Append projectName and applicants + let modifiedApplications = [...applications] + .map((app) => { + return datarequestController.createApplicationDTO(app.toObject()); + }) + .sort((a, b) => b.updatedAt - a.updatedAt); - let avgDecisionTime = datarequestController.calculateAvgDecisionTime(applications); + let avgDecisionTime = datarequestController.calculateAvgDecisionTime( + applications + ); // 7. Return all applications - return res.status(200).json({ success: true, data: modifiedApplications, avgDecisionTime }); + return res + .status(200) + .json({ success: true, data: modifiedApplications, avgDecisionTime }); } catch (err) { console.error(err); - return res - .status(500) - .json({ - success: false, - message: 'An error occurred searching for custodian applications', - }); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for custodian applications', + }); + } + }, + + // GET api/v1/publishers/:id/workflows + getPublisherWorkflows: async (req, res) => { + try { + // 1. Get the workflow from the database including the team members to check authorisation + let workflows = await WorkflowModel.find({ + publisher: req.params.id + }).populate([{ + path: 'publisher', + select: 'team', + populate: { + path: 'team', + select: 'members -_id', + }, + }, + { + path: 'steps.reviewers', + model: 'User', + select: '_id id firstname lastname' + }]); + if (_.isEmpty(workflows)) { + return res.status(200).json({ success: true, workflows: [] }); + } + // 2. Check the requesting user is a member of the team + let { _id: userId } = req.user; + let members = [], + authorised = false; + if (_.has(workflows[0].toObject(), 'publisher.team')) { + ({ + publisher: { + team: { members }, + }, + } = workflows[0]); + } + if (!_.isEmpty(members)) { + authorised = members.some( + (el) => el.memberid.toString() === userId.toString() + ); + } + // 3. If not return unauthorised + if (!authorised) { + return res.status(401).json({ success: false }); + } + // 4. Return workflows + workflows = workflows.map((workflow) => { + let { + active, + _id, + id, + workflowName, + version, + steps, + } = workflow.toObject(); + return { active, _id, id, workflowName, version, steps }; + }); + return res.status(200).json({ success: true, workflows }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for custodian workflows', + }); } }, }; diff --git a/src/resources/publisher/publisher.route.js b/src/resources/publisher/publisher.route.js index 2555af4d..cbbde1ad 100644 --- a/src/resources/publisher/publisher.route.js +++ b/src/resources/publisher/publisher.route.js @@ -20,4 +20,9 @@ router.get('/:id/datasets', passport.authenticate('jwt'), publisherController.ge // @access Private router.get('/:id/dataaccessrequests', passport.authenticate('jwt'), publisherController.getPublisherDataAccessRequests); +// @route GET api/publishers/:id/workflows +// @desc GET workflows for publisher +// @access Private +router.get('/:id/workflows', passport.authenticate('jwt'), publisherController.getPublisherWorkflows); + module.exports = router diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js new file mode 100644 index 00000000..b783cfcf --- /dev/null +++ b/src/resources/workflow/workflow.controller.js @@ -0,0 +1,65 @@ +import { WorkflowModel } from './workflow.model'; +import _ from 'lodash'; + +module.exports = { + // GET api/v1/workflows/:id + getWorkflowById: async (req, res) => { + try { + // 1. Get the workflow from the database including the team members to check authorisation + const workflow = await WorkflowModel.findOne({ + _id: req.params.id, + }).populate({ + path: 'publisher steps.reviewers', + select: 'team', + populate: { + path: 'team', + select: 'members -_id', + }, + }); + if (!workflow) { + return res.status(404).json({ success: false }); + } + // 2. Check the requesting user is a member of the team + let { _id: userId } = req.user; + let members = [], + authorised = false; + if (_.has(workflow.toObject(), 'publisher.team')) { + ({ + publisher: { + team: { members }, + }, + } = workflow); + } + if (!_.isEmpty(members)) { + authorised = members.some( + (el) => el.memberid.toString() === userId.toString() + ); + } + // 3. If not return unauthorised + if (!authorised) { + return res.status(401).json({ success: false }); + } + // 4. Return workflow + let { + active, + _id, + id, + workflowName, + version, + steps, + } = workflow.toObject(); + return res + .status(200) + .json({ + success: true, + workflow: { active, _id, id, workflowName, version, steps }, + }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for the specified workflow', + }); + } + }, +}; diff --git a/src/resources/workflow/workflow.model.js b/src/resources/workflow/workflow.model.js new file mode 100644 index 00000000..2f59535a --- /dev/null +++ b/src/resources/workflow/workflow.model.js @@ -0,0 +1,30 @@ +import { model, Schema } from 'mongoose' + +const WorkflowSchema = new Schema({ + id: { + type: Number + }, + workflowName: String, + version: Number, + publisher: { type : Schema.Types.ObjectId, ref: 'Publisher' }, + steps: [ + { + stepName: String, + reviewers: [{ type : Schema.Types.ObjectId, ref: 'User' }], + sections: [String], + deadline: Number + } + ], + active: { + type: Boolean, + default: true + }, + createdBy: { type : Schema.Types.ObjectId, ref: 'User' }, + updatedBy: { type : Schema.Types.ObjectId, ref: 'User' } +}, { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } +}); + +export const WorkflowModel = model('Workflow', WorkflowSchema) \ No newline at end of file diff --git a/src/resources/workflow/workflow.route.js b/src/resources/workflow/workflow.route.js new file mode 100644 index 00000000..7e3c81cf --- /dev/null +++ b/src/resources/workflow/workflow.route.js @@ -0,0 +1,13 @@ +import express from 'express'; +import passport from 'passport'; + +const workflowController = require('./workflow.controller'); + +const router = express.Router(); + +// @route GET api/v1/workflows/:id +// @desc GET A workflow by :id +// @access Private +router.get('/:id', passport.authenticate('jwt'), workflowController.getWorkflowById); + +module.exports = router From 14e4d1cb10c48f1b8025dde98a550c4056dc764e Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 2 Oct 2020 11:17:58 +0100 Subject: [PATCH 006/144] Add/edit/delete workflow endpoints in progress --- .../datarequest/datarequest.model.js | 4 + .../publisher/publisher.controller.js | 4 +- src/resources/utilities/helper.util.js | 9 +- src/resources/workflow/workflow.controller.js | 261 +++++++++++++++++- src/resources/workflow/workflow.model.js | 47 ++-- src/resources/workflow/workflow.route.js | 17 +- 6 files changed, 314 insertions(+), 28 deletions(-) diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index ccbe09da..e91f25b8 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -1,5 +1,7 @@ import { model, Schema } from 'mongoose'; +const { WorkflowSchema } = require('../workflow/workflow.model'); + const DataRequestSchema = new Schema({ version: Number, userId: Number, // Main applicant @@ -7,6 +9,8 @@ const DataRequestSchema = new Schema({ dataSetId: String, datasetIds: [{ type: String}], projectId: String, + workflowId: { type : Schema.Types.ObjectId, ref: 'Workflow' }, + workflow: { type: [ WorkflowSchema ] }, applicationStatus: { type: String, default: 'inProgress', diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index ddcf22a7..2f4f59df 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -1,10 +1,10 @@ import mongoose from 'mongoose'; import { PublisherModel } from './publisher.model'; -import { DataRequestModel } from '../datarequest/datarequest.model'; -import { WorkflowModel } from '../workflow/workflow.model'; import { Data } from '../tool/data.model'; import _ from 'lodash'; +const DataRequestModel = require('../datarequest/datarequest.model'); +const WorkflowModel = require('../workflow/workflow.model'); const datarequestController = require('../datarequest/datarequest.controller'); module.exports = { diff --git a/src/resources/utilities/helper.util.js b/src/resources/utilities/helper.util.js index d50d255f..23c54050 100644 --- a/src/resources/utilities/helper.util.js +++ b/src/resources/utilities/helper.util.js @@ -26,8 +26,13 @@ const _generateFriendlyId = (id) => { .join('-'); }; +const _generatedNumericId = () => { + return parseInt(Math.random().toString().replace('0.', '')); +} + export default { censorEmail: _censorEmail, - arraysEqual: _arraysEqual, - generateFriendlyId: _generateFriendlyId + arraysEqual: _arraysEqual, + generateFriendlyId: _generateFriendlyId, + generatedNumericId: _generatedNumericId }; diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index b783cfcf..cb88040f 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -1,5 +1,17 @@ -import { WorkflowModel } from './workflow.model'; +import { PublisherModel } from '../publisher/publisher.model'; +import { DataRequestModel } from '../datarequest/datarequest.model'; + +const { WorkflowModel } = require('./workflow.model'); + +import helper from '../utilities/helper.util'; + import _ from 'lodash'; +import mongoose from 'mongoose'; + +const teamRoles = { + MANAGER: 'manager', + REVIEWER: 'reviewer', +}; module.exports = { // GET api/v1/workflows/:id @@ -48,12 +60,10 @@ module.exports = { version, steps, } = workflow.toObject(); - return res - .status(200) - .json({ - success: true, - workflow: { active, _id, id, workflowName, version, steps }, - }); + return res.status(200).json({ + success: true, + workflow: { active, _id, id, workflowName, version, steps }, + }); } catch (err) { console.error(err.message); return res.status(500).json({ @@ -62,4 +72,241 @@ module.exports = { }); } }, + + // POST api/v1/workflows + createWorkflow: async (req, res) => { + try { + const { _id: userId } = req.user; + // 1. Look at the payload for the publisher passed + const { workflowName = '', publisher = '', steps = [] } = req.body; + if ( + _.isEmpty(workflowName.trim()) || + _.isEmpty(publisher.trim()) || + _.isEmpty(steps) + ) { + return res.status(400).json({ + success: false, + message: + 'You must supply a workflow name, publisher, and at least one step definition to create a workflow', + }); + } + // 2. Look up publisher and team + const publisherObj = await PublisherModel.findOne({ + _id: publisher, + }).populate('team', 'members'); + if (!publisherObj) { + return res.status(400).json({ + success: false, + message: + 'You must supply a valid publisher to create the workflow against', + }); + } + // 3. Check the requesting user is a manager of the custodian team + let authorised = module.exports.checkWorkflowPermissions( + teamRoles.MANAGER, + publisherObj.toObject(), + userId + ); + + // 4. Refuse access if not authorised + if (!authorised) { + return res + .status(401) + .json({ status: 'failure', message: 'Unauthorised' }); + } + // 5. Create new workflow model + const id = helper.generatedNumericId(); + let workflow = new WorkflowModel({ + id, + workflowName, + publisher, + steps, + createdBy: new mongoose.Types.ObjectId(userId), + }); + // 6. Submit save + workflow.save(function (err) { + if (err) { + console.error(err); + return res.status(400).json({ + success: false, + message: err.message, + }); + } else { + // 7. Return workflow payload + return res.status(201).json({ + success: true, + workflow, + }); + } + }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred creating the workflow', + }); + } + }, + + // PUT api/v1/workflows/:id + updateWorkflow: async (req, res) => { + try { + // 1. Look at the payload for the publisher passed + let workflow = new WorkflowModel({ test: 'lol' }); + + workflow.save(); + + // 2. Check if the user is a manager of that team + + // 3. Create new workflow model + + // 4. Return workflow payload + + // // 1. Get the workflow from the database including the team members to check authorisation + // const workflow = await WorkflowModel.findOne({ + // _id: req.params.id, + // }).populate({ + // path: 'publisher steps.reviewers', + // select: 'team', + // populate: { + // path: 'team', + // select: 'members -_id', + // }, + // }); + // if (!workflow) { + // return res.status(404).json({ success: false }); + // } + // // 2. Check the requesting user is a member of the team + // let { _id: userId } = req.user; + // let members = [], + // authorised = false; + // if (_.has(workflow.toObject(), 'publisher.team')) { + // ({ + // publisher: { + // team: { members }, + // }, + // } = workflow); + // } + // if (!_.isEmpty(members)) { + // authorised = members.some( + // (el) => el.memberid.toString() === userId.toString() + // ); + // } + // // 3. If not return unauthorised + // if (!authorised) { + // return res.status(401).json({ success: false }); + // } + // // 4. Return workflow + // let { + // active, + // _id, + // id, + // workflowName, + // version, + // steps, + // } = workflow.toObject(); + // return res + // .status(200) + // .json({ + // success: true, + // workflow, + // }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the workflow', + }); + } + }, + + // DELETE api/v1/workflows/:id + deleteWorkflow: async (req, res) => { + try { + const { _id: userId } = req.user; + const { id: workflowId } = req.params; + // 1. Look up workflow + const workflow = await WorkflowModel.findOne({ + _id: req.params.id, + }).populate({ + path: 'publisher steps.reviewers', + select: 'team', + populate: { + path: 'team', + select: 'members -_id', + }, + }); + if (!workflow) { + return res.status(404).json({ success: false }); + } + // 2. Check the requesting user is a manager of the custodian team + let authorised = module.exports.checkWorkflowPermissions( + teamRoles.MANAGER, + workflow.publisher.toObject(), + userId + ); + // 3. Refuse access if not authorised + if (!authorised) { + return res + .status(401) + .json({ status: 'failure', message: 'Unauthorised' }); + } + // 4. Ensure there are no in-review DARs with this workflow + const applications = await DataRequestModel.countDocuments({ workflowId, 'applicationStatus':'inReview' }); + if(applications > 0) { + return res.status(400).json({ + success: false, + message: 'A workflow which is attached to applications currently in review cannot be deleted', + }); + } + // 5. Delete workflow + WorkflowModel.deleteOne({ _id: workflowId }, function (err) { + if (err) { + console.error(err); + return res.status(400).json({ + success: false, + message: 'An error occurred deleting the workflow', + }); + } else { + // 7. Return workflow payload + return res.status(204).json({ + success: true + }); + } + }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred deleting the workflow', + }); + } + }, + + /** + * Check a users CRUD permissions for workflows + * + * @param {enum} role The role required for the action + * @param {object} publisher The publisher object containing the team and its members + * @param {objectId} userId The userId to check the permissions for + */ + checkWorkflowPermissions: (role, publisher, userId) => { + // 1. Ensure the publisher has a team and associated members defined + if (_.has(publisher, 'team.members')) { + // 2. Extract team members + let { members } = publisher.team; + // 3. Find the current user + let userMember = members.find( + (el) => el.memberid.toString() === userId.toString() + ); + // 4. If the user was found check they hold the minimum required role + if (userMember) { + let { roles } = userMember; + if (roles.includes(role) || roles.includes(roleTypes.MANAGER)) { + return true; + } + } + } + return false; + }, }; diff --git a/src/resources/workflow/workflow.model.js b/src/resources/workflow/workflow.model.js index 2f59535a..17a76193 100644 --- a/src/resources/workflow/workflow.model.js +++ b/src/resources/workflow/workflow.model.js @@ -1,25 +1,35 @@ -import { model, Schema } from 'mongoose' +import { model, Schema } from 'mongoose'; + +const minReviewers = (val) => { + return val.length > 0; +} + +const minSteps = (val) => { + return val.length > 0; +} + +const minSections = (val) => { + return val.length > 0; +} + +const StepSchema = new Schema({ + stepName: { type: String, required: true }, + reviewers: { type: [{ type : Schema.Types.ObjectId, ref: 'User' }], validate:[minReviewers, 'There must be at least one reviewer per phase'] }, + sections: { type: [String], validate:[minSections, 'There must be at least one section assigned to a phase'] }, + deadline: { type: Number, required: true } +}); const WorkflowSchema = new Schema({ - id: { - type: Number - }, - workflowName: String, + id: { type: Number, required: true }, + workflowName: { type: String, required: true }, version: Number, - publisher: { type : Schema.Types.ObjectId, ref: 'Publisher' }, - steps: [ - { - stepName: String, - reviewers: [{ type : Schema.Types.ObjectId, ref: 'User' }], - sections: [String], - deadline: Number - } - ], + publisher: { type : Schema.Types.ObjectId, ref: 'Publisher', required: true }, + steps: { type: [ StepSchema ], validate:[minSteps, 'There must be at least one phase in a workflow']}, active: { type: Boolean, default: true }, - createdBy: { type : Schema.Types.ObjectId, ref: 'User' }, + createdBy: { type : Schema.Types.ObjectId, ref: 'User', required: true }, updatedBy: { type : Schema.Types.ObjectId, ref: 'User' } }, { timestamps: true, @@ -27,4 +37,9 @@ const WorkflowSchema = new Schema({ toObject: { virtuals: true } }); -export const WorkflowModel = model('Workflow', WorkflowSchema) \ No newline at end of file +const WorkflowModel = model('Workflow', WorkflowSchema) + +module.exports = { + WorkflowSchema: WorkflowSchema, + WorkflowModel: WorkflowModel +} \ No newline at end of file diff --git a/src/resources/workflow/workflow.route.js b/src/resources/workflow/workflow.route.js index 7e3c81cf..7ffa4b5f 100644 --- a/src/resources/workflow/workflow.route.js +++ b/src/resources/workflow/workflow.route.js @@ -6,8 +6,23 @@ const workflowController = require('./workflow.controller'); const router = express.Router(); // @route GET api/v1/workflows/:id -// @desc GET A workflow by :id +// @desc Fetch a workflow by id // @access Private router.get('/:id', passport.authenticate('jwt'), workflowController.getWorkflowById); +// @route POST api/v1/workflows/ +// @desc Create a new workflow +// @access Private +router.post('/', passport.authenticate('jwt'), workflowController.createWorkflow); + +// @route PUT api/v1/workflows/:id +// @desc Edit a workflow by id +// @access Private +router.put('/', passport.authenticate('jwt'), workflowController.updateWorkflow); + +// @route DELETE api/v1/workflows/ +// @desc Delete a workflow by id +// @access Private +router.delete('/:id', passport.authenticate('jwt'), workflowController.deleteWorkflow); + module.exports = router From 95f9334568ab7ae942440303823d819b487ba698 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 2 Oct 2020 11:18:52 +0100 Subject: [PATCH 007/144] Continued update workflow endpoint --- src/resources/workflow/workflow.controller.js | 131 ++++++++++-------- 1 file changed, 70 insertions(+), 61 deletions(-) diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index cb88040f..9c2cf6fa 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -151,71 +151,80 @@ module.exports = { // PUT api/v1/workflows/:id updateWorkflow: async (req, res) => { try { - // 1. Look at the payload for the publisher passed - let workflow = new WorkflowModel({ test: 'lol' }); - - workflow.save(); - - // 2. Check if the user is a manager of that team - - // 3. Create new workflow model - - // 4. Return workflow payload - - // // 1. Get the workflow from the database including the team members to check authorisation - // const workflow = await WorkflowModel.findOne({ - // _id: req.params.id, - // }).populate({ - // path: 'publisher steps.reviewers', - // select: 'team', - // populate: { - // path: 'team', - // select: 'members -_id', - // }, - // }); - // if (!workflow) { - // return res.status(404).json({ success: false }); - // } - // // 2. Check the requesting user is a member of the team - // let { _id: userId } = req.user; - // let members = [], - // authorised = false; - // if (_.has(workflow.toObject(), 'publisher.team')) { - // ({ - // publisher: { - // team: { members }, - // }, - // } = workflow); - // } - // if (!_.isEmpty(members)) { - // authorised = members.some( - // (el) => el.memberid.toString() === userId.toString() - // ); - // } - // // 3. If not return unauthorised - // if (!authorised) { - // return res.status(401).json({ success: false }); - // } - // // 4. Return workflow - // let { - // active, - // _id, - // id, - // workflowName, - // version, - // steps, - // } = workflow.toObject(); - // return res - // .status(200) - // .json({ - // success: true, - // workflow, - // }); + const { _id: userId } = req.user; + const { id: workflowId } = req.params; + // 1. Look up workflow + let workflow = await WorkflowModel.findOne({ + _id: req.params.id, + }).populate({ + path: 'publisher steps.reviewers', + select: 'team', + populate: { + path: 'team', + select: 'members -_id', + }, + }); + if (!workflow) { + return res.status(404).json({ success: false }); + } + // 2. Check the requesting user is a manager of the custodian team + let authorised = module.exports.checkWorkflowPermissions( + teamRoles.MANAGER, + workflow.publisher.toObject(), + userId + ); + // 3. Refuse access if not authorised + if (!authorised) { + return res + .status(401) + .json({ status: 'failure', message: 'Unauthorised' }); + } + // 4. Ensure there are no in-review DARs with this workflow + const applications = await DataRequestModel.countDocuments({ workflowId, 'applicationStatus':'inReview' }); + if(applications > 0) { + return res.status(400).json({ + success: false, + message: 'A workflow which is attached to applications currently in review cannot be edited', + }); + } + // 5. Edit workflow + const { workflowName = '', steps = [] } = req.body; + let isDirty = false; + // Check if workflow name updated + if(!_.isEmpty(workflowName)) { + workflow.workflowName = workflowName; + isDirty = true; + } // Check if steps updated + if(!_.isEmpty(steps)) { + workflow.steps = steps; + isDirty = true; + } // Perform save if + if(isDirty) { + WorkflowModel.deleteOne({ _id: workflowId }, function (err) { + if (err) { + console.error(err); + return res.status(400).json({ + success: false, + message: 'An error occurred editing the workflow', + }); + } else { + // 7. Return workflow payload + return res.status(204).json({ + success: true + }); + } + }); + } else { + return res.status(200).json({ + success: true, + workflow + }); + } } catch (err) { console.error(err.message); return res.status(500).json({ success: false, - message: 'An error occurred updating the workflow', + message: 'An error occurred editing the workflow', }); } }, From 41bd5da14fb7b702d0e8d0df05398cff3b43e8a9 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 2 Oct 2020 11:46:49 +0100 Subject: [PATCH 008/144] Fixed mongoose export error --- src/resources/datarequest/datarequest.model.js | 3 +-- src/resources/publisher/publisher.controller.js | 5 ++--- src/resources/workflow/workflow.controller.js | 3 +-- src/resources/workflow/workflow.model.js | 9 ++------- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index e91f25b8..bc637f26 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -1,6 +1,5 @@ import { model, Schema } from 'mongoose'; - -const { WorkflowSchema } = require('../workflow/workflow.model'); +import { WorkflowSchema } from '../workflow/workflow.model'; const DataRequestSchema = new Schema({ version: Number, diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 2f4f59df..342f3fd8 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -2,9 +2,8 @@ import mongoose from 'mongoose'; import { PublisherModel } from './publisher.model'; import { Data } from '../tool/data.model'; import _ from 'lodash'; - -const DataRequestModel = require('../datarequest/datarequest.model'); -const WorkflowModel = require('../workflow/workflow.model'); +import { DataRequestModel } from '../datarequest/datarequest.model'; +import { WorkflowModel } from '../workflow/workflow.model'; const datarequestController = require('../datarequest/datarequest.controller'); module.exports = { diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 9c2cf6fa..5bf94acd 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -1,7 +1,6 @@ import { PublisherModel } from '../publisher/publisher.model'; import { DataRequestModel } from '../datarequest/datarequest.model'; - -const { WorkflowModel } = require('./workflow.model'); +import { WorkflowModel } from './workflow.model'; import helper from '../utilities/helper.util'; diff --git a/src/resources/workflow/workflow.model.js b/src/resources/workflow/workflow.model.js index 17a76193..eccea212 100644 --- a/src/resources/workflow/workflow.model.js +++ b/src/resources/workflow/workflow.model.js @@ -19,7 +19,7 @@ const StepSchema = new Schema({ deadline: { type: Number, required: true } }); -const WorkflowSchema = new Schema({ +export const WorkflowSchema = new Schema({ id: { type: Number, required: true }, workflowName: { type: String, required: true }, version: Number, @@ -37,9 +37,4 @@ const WorkflowSchema = new Schema({ toObject: { virtuals: true } }); -const WorkflowModel = model('Workflow', WorkflowSchema) - -module.exports = { - WorkflowSchema: WorkflowSchema, - WorkflowModel: WorkflowModel -} \ No newline at end of file +export const WorkflowModel = model('Workflow', WorkflowSchema); \ No newline at end of file From fe4605718e59f27179ad96a9d84c54cb102b482d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 2 Oct 2020 15:34:49 +0100 Subject: [PATCH 009/144] Finished workflow CRUD endpoints --- .../publisher/publisher.controller.js | 64 +++++--- src/resources/team/team.controller.js | 56 ++++++- src/resources/workflow/workflow.controller.js | 146 ++++++++++-------- src/resources/workflow/workflow.model.js | 6 + src/resources/workflow/workflow.route.js | 2 +- 5 files changed, 182 insertions(+), 92 deletions(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 342f3fd8..5ab4a23b 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -78,7 +78,6 @@ module.exports = { try { // 1. Deconstruct the request let { _id } = req.user; - // 2. Lookup publisher team const publisher = await PublisherModel.findOne({ name: req.params.id, @@ -86,7 +85,6 @@ module.exports = { if (!publisher) { return res.status(404).json({ success: false }); } - // 3. Check the requesting user is a member of the custodian team let found = false; if (_.has(publisher.toObject(), 'team.members')) { @@ -98,13 +96,11 @@ module.exports = { return res .status(401) .json({ status: 'failure', message: 'Unauthorised' }); - // 4. Find all datasets owned by the publisher (no linkage between DAR and publisher in historic data) let datasetIds = await Data.find({ type: 'dataset', 'datasetfields.publisher': req.params.id, }).distinct('datasetid'); - // 5. Find all applications where any datasetId exists let applications = await DataRequestModel.find({ $or: [ @@ -114,7 +110,6 @@ module.exports = { }) .sort({ updatedAt: -1 }) .populate('datasets dataset mainApplicant'); - // 6. Append projectName and applicants let modifiedApplications = [...applications] .map((app) => { @@ -125,7 +120,6 @@ module.exports = { let avgDecisionTime = datarequestController.calculateAvgDecisionTime( applications ); - // 7. Return all applications return res .status(200) @@ -144,20 +138,27 @@ module.exports = { try { // 1. Get the workflow from the database including the team members to check authorisation let workflows = await WorkflowModel.find({ - publisher: req.params.id - }).populate([{ - path: 'publisher', - select: 'team', - populate: { - path: 'team', - select: 'members -_id', + publisher: req.params.id, + }).populate([ + { + path: 'publisher', + select: 'team', + populate: { + path: 'team', + select: 'members -_id', + }, + }, + { + path: 'steps.reviewers', + model: 'User', + select: '_id id firstname lastname', + }, + { + path: 'applications', + select: 'aboutApplication', + match: { applicationStatus: 'inReview' }, }, - }, - { - path: 'steps.reviewers', - model: 'User', - select: '_id id firstname lastname' - }]); + ]); if (_.isEmpty(workflows)) { return res.status(200).json({ success: true, workflows: [] }); } @@ -181,7 +182,7 @@ module.exports = { if (!authorised) { return res.status(401).json({ success: false }); } - // 4. Return workflows + // 4. Build workflows workflows = workflows.map((workflow) => { let { active, @@ -190,9 +191,30 @@ module.exports = { workflowName, version, steps, + applications = [], } = workflow.toObject(); - return { active, _id, id, workflowName, version, steps }; + applications = applications.map((app) => { + const { aboutApplication, _id } = app; + const aboutApplicationObj = JSON.parse(aboutApplication) || {}; + let { projectName = 'No project name' } = aboutApplicationObj; + return { projectName, _id }; + }); + let canDelete = applications.length === 0, + canEdit = applications.length === 0; + return { + active, + _id, + id, + workflowName, + version, + steps, + applications, + appCount: applications.length, + canDelete, + canEdit, + }; }); + // 5. Return payload return res.status(200).json({ success: true, workflows }); } catch (err) { console.error(err.message); diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index 9291533f..4f55ff74 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -2,6 +2,11 @@ import { TeamModel } from './team.model'; import _ from 'lodash'; module.exports = { + roles: { + MANAGER: 'manager', + REVIEWER: 'reviewer', + }, + // GET api/v1/teams/:id getTeamById: async (req, res) => { try { @@ -14,11 +19,13 @@ module.exports = { let { _id } = req.user; let { members } = team; let authorised = false; - if(members) { - authorised = members.some((el) => el.memberid.toString() === _id.toString()); + if (members) { + authorised = members.some( + (el) => el.memberid.toString() === _id.toString() + ); } // 3. If not return unauthorised - if(!authorised) { + if (!authorised) { return res.status(401).json({ success: false }); } // 4. Return team @@ -33,7 +40,9 @@ module.exports = { getTeamMembers: async (req, res) => { try { // 1. Get the team from the database - const team = await TeamModel.findOne({ _id: req.params.id }).populate('users'); + const team = await TeamModel.findOne({ _id: req.params.id }).populate( + 'users' + ); if (!team) { return res.status(404).json({ success: false }); } @@ -41,17 +50,21 @@ module.exports = { let { _id } = req.user; let { members, users } = team; let authorised = false; - if(members) { - authorised = members.some((el) => el.memberid.toString() === _id.toString()); + if (members) { + authorised = members.some( + (el) => el.memberid.toString() === _id.toString() + ); } // 3. If not return unauthorised - if(!authorised) { + if (!authorised) { return res.status(401).json({ success: false }); } // 4. Format response to include user info users = users.map((user) => { let { firstname, lastname, id, _id, email } = user; - let userMember = members.find(el => el.memberid.toString() === user._id.toString()); + let userMember = members.find( + (el) => el.memberid.toString() === user._id.toString() + ); let { roles = [] } = userMember; return { firstname, lastname, id, _id, email, roles }; }); @@ -62,4 +75,31 @@ module.exports = { return res.status(500).json(err); } }, + + /** + * Check a users CRUD permissions for workflows + * + * @param {enum} role The role required for the action + * @param {object} team The publisher object containing its members + * @param {objectId} userId The userId to check the permissions for + */ + checkTeamPermissions: (role, team, userId) => { + // 1. Ensure the publisher has a team and associated members defined + if (_.has(team, 'members')) { + // 2. Extract team members + let { members } = team; + // 3. Find the current user + let userMember = members.find( + (el) => el.memberid.toString() === userId.toString() + ); + // 4. If the user was found check they hold the minimum required role + if (userMember) { + let { roles } = userMember; + if (roles.includes(role) || roles.includes(roleTypes.MANAGER)) { + return true; + } + } + } + return false; + }, }; diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 5bf94acd..ac33c709 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -1,56 +1,55 @@ import { PublisherModel } from '../publisher/publisher.model'; import { DataRequestModel } from '../datarequest/datarequest.model'; import { WorkflowModel } from './workflow.model'; - import helper from '../utilities/helper.util'; import _ from 'lodash'; import mongoose from 'mongoose'; -const teamRoles = { - MANAGER: 'manager', - REVIEWER: 'reviewer', -}; +const teamController = require('../team/team.controller'); module.exports = { // GET api/v1/workflows/:id getWorkflowById: async (req, res) => { try { - // 1. Get the workflow from the database including the team members to check authorisation + // 1. Get the workflow from the database including the team members to check authorisation and the number of in-flight applications const workflow = await WorkflowModel.findOne({ _id: req.params.id, - }).populate({ - path: 'publisher steps.reviewers', - select: 'team', - populate: { - path: 'team', - select: 'members -_id', + }).populate([ + { + path: 'publisher', + select: 'team', + populate: { + path: 'team', + select: 'members -_id', + }, }, - }); + { + path: 'steps.reviewers', + model: 'User', + select: '_id id firstname lastname', + }, + { + path: 'applications', + select: 'aboutApplication', + match: { applicationStatus: 'inReview' }, + }, + ]); if (!workflow) { return res.status(404).json({ success: false }); } - // 2. Check the requesting user is a member of the team + // 2. Check the requesting user is a manager of the custodian team let { _id: userId } = req.user; - let members = [], - authorised = false; - if (_.has(workflow.toObject(), 'publisher.team')) { - ({ - publisher: { - team: { members }, - }, - } = workflow); - } - if (!_.isEmpty(members)) { - authorised = members.some( - (el) => el.memberid.toString() === userId.toString() - ); - } + let authorised = module.exports.checkWorkflowPermissions( + teamController.roles.MANAGER, + workflow.publisher.toObject(), + userId + ); // 3. If not return unauthorised if (!authorised) { return res.status(401).json({ success: false }); } - // 4. Return workflow + // 4. Build workflow response let { active, _id, @@ -58,10 +57,32 @@ module.exports = { workflowName, version, steps, + applications = [], } = workflow.toObject(); + applications = applications.map((app) => { + const { aboutApplication, _id } = app; + const aboutApplicationObj = JSON.parse(aboutApplication) || {}; + let { projectName = 'No project name' } = aboutApplicationObj; + return { projectName, _id }; + }); + // Set operation permissions + let canDelete = applications.length === 0, + canEdit = applications.length === 0; + // 5. Return payload return res.status(200).json({ success: true, - workflow: { active, _id, id, workflowName, version, steps }, + workflow: { + active, + _id, + id, + workflowName, + version, + steps, + applications, + appCount: applications.length, + canDelete, + canEdit, + }, }); } catch (err) { console.error(err.message); @@ -102,7 +123,7 @@ module.exports = { } // 3. Check the requesting user is a manager of the custodian team let authorised = module.exports.checkWorkflowPermissions( - teamRoles.MANAGER, + teamController.roles.MANAGER, publisherObj.toObject(), userId ); @@ -168,7 +189,7 @@ module.exports = { } // 2. Check the requesting user is a manager of the custodian team let authorised = module.exports.checkWorkflowPermissions( - teamRoles.MANAGER, + teamController.roles.MANAGER, workflow.publisher.toObject(), userId ); @@ -179,44 +200,48 @@ module.exports = { .json({ status: 'failure', message: 'Unauthorised' }); } // 4. Ensure there are no in-review DARs with this workflow - const applications = await DataRequestModel.countDocuments({ workflowId, 'applicationStatus':'inReview' }); - if(applications > 0) { + const applications = await DataRequestModel.countDocuments({ + workflowId, + applicationStatus: 'inReview', + }); + if (applications > 0) { return res.status(400).json({ success: false, - message: 'A workflow which is attached to applications currently in review cannot be edited', + message: + 'A workflow which is attached to applications currently in review cannot be edited', }); } // 5. Edit workflow const { workflowName = '', steps = [] } = req.body; let isDirty = false; // Check if workflow name updated - if(!_.isEmpty(workflowName)) { + if (!_.isEmpty(workflowName)) { workflow.workflowName = workflowName; isDirty = true; } // Check if steps updated - if(!_.isEmpty(steps)) { + if (!_.isEmpty(steps)) { workflow.steps = steps; isDirty = true; - } // Perform save if - if(isDirty) { - WorkflowModel.deleteOne({ _id: workflowId }, function (err) { + } // Perform save if changes have been made + if (isDirty) { + workflow.save(async (err) => { if (err) { console.error(err); return res.status(400).json({ success: false, - message: 'An error occurred editing the workflow', + message: err.message, }); } else { // 7. Return workflow payload return res.status(204).json({ - success: true + success: true, }); } }); } else { return res.status(200).json({ success: true, - workflow + workflow, }); } } catch (err) { @@ -249,7 +274,7 @@ module.exports = { } // 2. Check the requesting user is a manager of the custodian team let authorised = module.exports.checkWorkflowPermissions( - teamRoles.MANAGER, + teamController.roles.MANAGER, workflow.publisher.toObject(), userId ); @@ -260,11 +285,15 @@ module.exports = { .json({ status: 'failure', message: 'Unauthorised' }); } // 4. Ensure there are no in-review DARs with this workflow - const applications = await DataRequestModel.countDocuments({ workflowId, 'applicationStatus':'inReview' }); - if(applications > 0) { + const applications = await DataRequestModel.countDocuments({ + workflowId, + applicationStatus: 'inReview', + }); + if (applications > 0) { return res.status(400).json({ success: false, - message: 'A workflow which is attached to applications currently in review cannot be deleted', + message: + 'A workflow which is attached to applications currently in review cannot be deleted', }); } // 5. Delete workflow @@ -278,7 +307,7 @@ module.exports = { } else { // 7. Return workflow payload return res.status(204).json({ - success: true + success: true, }); } }); @@ -299,22 +328,15 @@ module.exports = { * @param {objectId} userId The userId to check the permissions for */ checkWorkflowPermissions: (role, publisher, userId) => { - // 1. Ensure the publisher has a team and associated members defined + let authorised = false; + // 1. Pass the publisher team to check the users permissions if (_.has(publisher, 'team.members')) { - // 2. Extract team members - let { members } = publisher.team; - // 3. Find the current user - let userMember = members.find( - (el) => el.memberid.toString() === userId.toString() + authorised = teamController.checkTeamPermissions( + role, + publisher.team, + userId ); - // 4. If the user was found check they hold the minimum required role - if (userMember) { - let { roles } = userMember; - if (roles.includes(role) || roles.includes(roleTypes.MANAGER)) { - return true; - } - } } - return false; + return authorised; }, }; diff --git a/src/resources/workflow/workflow.model.js b/src/resources/workflow/workflow.model.js index eccea212..b7730fa1 100644 --- a/src/resources/workflow/workflow.model.js +++ b/src/resources/workflow/workflow.model.js @@ -37,4 +37,10 @@ export const WorkflowSchema = new Schema({ toObject: { virtuals: true } }); +WorkflowSchema.virtual('applications', { + ref: 'data_request', + foreignField: 'workflowId', + localField: '_id' +}); + export const WorkflowModel = model('Workflow', WorkflowSchema); \ No newline at end of file diff --git a/src/resources/workflow/workflow.route.js b/src/resources/workflow/workflow.route.js index 7ffa4b5f..6720a79c 100644 --- a/src/resources/workflow/workflow.route.js +++ b/src/resources/workflow/workflow.route.js @@ -18,7 +18,7 @@ router.post('/', passport.authenticate('jwt'), workflowController.createWorkflow // @route PUT api/v1/workflows/:id // @desc Edit a workflow by id // @access Private -router.put('/', passport.authenticate('jwt'), workflowController.updateWorkflow); +router.put('/:id', passport.authenticate('jwt'), workflowController.updateWorkflow); // @route DELETE api/v1/workflows/ // @desc Delete a workflow by id From b6102d889bc24062d9b0baa24a1ed2749fc5fb2c Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 2 Oct 2020 16:01:54 +0100 Subject: [PATCH 010/144] Refactor to endpoints --- .../publisher/publisher.controller.js | 21 ++++------ src/resources/workflow/workflow.controller.js | 42 +++++-------------- 2 files changed, 18 insertions(+), 45 deletions(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 5ab4a23b..2aa6df47 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -4,7 +4,9 @@ import { Data } from '../tool/data.model'; import _ from 'lodash'; import { DataRequestModel } from '../datarequest/datarequest.model'; import { WorkflowModel } from '../workflow/workflow.model'; + const datarequestController = require('../datarequest/datarequest.controller'); +const teamController = require('../team/team.controller'); module.exports = { // GET api/v1/publishers/:id @@ -164,20 +166,11 @@ module.exports = { } // 2. Check the requesting user is a member of the team let { _id: userId } = req.user; - let members = [], - authorised = false; - if (_.has(workflows[0].toObject(), 'publisher.team')) { - ({ - publisher: { - team: { members }, - }, - } = workflows[0]); - } - if (!_.isEmpty(members)) { - authorised = members.some( - (el) => el.memberid.toString() === userId.toString() - ); - } + let authorised = teamController.checkTeamPermissions( + teamController.roles.MANAGER, + workflow.publisher.team.toObject(), + userId + ); // 3. If not return unauthorised if (!authorised) { return res.status(401).json({ success: false }); diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index ac33c709..561e25d8 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -40,9 +40,9 @@ module.exports = { } // 2. Check the requesting user is a manager of the custodian team let { _id: userId } = req.user; - let authorised = module.exports.checkWorkflowPermissions( + let authorised = teamController.checkTeamPermissions( teamController.roles.MANAGER, - workflow.publisher.toObject(), + workflow.publisher.team.toObject(), userId ); // 3. If not return unauthorised @@ -122,9 +122,9 @@ module.exports = { }); } // 3. Check the requesting user is a manager of the custodian team - let authorised = module.exports.checkWorkflowPermissions( + let authorised = teamController.checkTeamPermissions( teamController.roles.MANAGER, - publisherObj.toObject(), + publisherObj.team.toObject(), userId ); @@ -188,9 +188,9 @@ module.exports = { return res.status(404).json({ success: false }); } // 2. Check the requesting user is a manager of the custodian team - let authorised = module.exports.checkWorkflowPermissions( + let authorised = teamController.checkTeamPermissions( teamController.roles.MANAGER, - workflow.publisher.toObject(), + workflow.publisher.team.toObject(), userId ); // 3. Refuse access if not authorised @@ -235,13 +235,13 @@ module.exports = { // 7. Return workflow payload return res.status(204).json({ success: true, + workflow }); } }); } else { return res.status(200).json({ - success: true, - workflow, + success: true }); } } catch (err) { @@ -273,9 +273,9 @@ module.exports = { return res.status(404).json({ success: false }); } // 2. Check the requesting user is a manager of the custodian team - let authorised = module.exports.checkWorkflowPermissions( + let authorised = teamController.checkTeamPermissions( teamController.roles.MANAGER, - workflow.publisher.toObject(), + workflow.publisher.team.toObject(), userId ); // 3. Refuse access if not authorised @@ -318,25 +318,5 @@ module.exports = { message: 'An error occurred deleting the workflow', }); } - }, - - /** - * Check a users CRUD permissions for workflows - * - * @param {enum} role The role required for the action - * @param {object} publisher The publisher object containing the team and its members - * @param {objectId} userId The userId to check the permissions for - */ - checkWorkflowPermissions: (role, publisher, userId) => { - let authorised = false; - // 1. Pass the publisher team to check the users permissions - if (_.has(publisher, 'team.members')) { - authorised = teamController.checkTeamPermissions( - role, - publisher.team, - userId - ); - } - return authorised; - }, + } }; From c4f12b63ba766c1513037efe5557b5c60295eaed Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 2 Oct 2020 17:18:09 +0100 Subject: [PATCH 011/144] Created new route for assigning a workflow to a DAR --- .../datarequest/datarequest.controller.js | 153 +++++++++--------- .../datarequest/datarequest.route.js | 5 + 2 files changed, 84 insertions(+), 74 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 38504eea..d39d0df0 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -523,6 +523,11 @@ module.exports = { } }, + //PUT api/v1/data-access-request/:id/assignworkflow + updateAccessRequestWorkflow: async (req, res) => { + + }, + //POST api/v1/data-access-request/:id submitAccessRequestById: async (req, res) => { try { @@ -923,81 +928,81 @@ module.exports = { }, extractApplicantNames: (questionAnswers) => { - let fullnames = [], autoCompleteLookups = {"fullname": ['email']}; - // spread questionAnswers to new var - let qa = { ...questionAnswers }; - // get object keys of questionAnswers - let keys = Object.keys(qa); - // loop questionAnswer keys - for (const key of keys) { - // get value of key - let value = qa[key]; - // split the key up for unique purposes - let [qId] = key.split('_'); - // check if key in lookup - let lookup = autoCompleteLookups[`${qId}`]; - // if key exists and it has an object do relevant data setting - if (typeof lookup !== 'undefined' && typeof value === 'object') { - switch (qId) { - case 'fullname': - fullnames.push(value.name); - break; + let fullnames = [], autoCompleteLookups = {"fullname": ['email']}; + // spread questionAnswers to new var + let qa = { ...questionAnswers }; + // get object keys of questionAnswers + let keys = Object.keys(qa); + // loop questionAnswer keys + for (const key of keys) { + // get value of key + let value = qa[key]; + // split the key up for unique purposes + let [qId] = key.split('_'); + // check if key in lookup + let lookup = autoCompleteLookups[`${qId}`]; + // if key exists and it has an object do relevant data setting + if (typeof lookup !== 'undefined' && typeof value === 'object') { + switch (qId) { + case 'fullname': + fullnames.push(value.name); + break; + } } } + return fullnames; + }, + + createApplicationDTO: (app) => { + let projectName = ''; + let applicants = ''; + + // Ensure backward compatibility with old single dataset DARs + if(_.isEmpty(app.datasets) || _.isUndefined(app.datasets)) { + app.datasets = [app.dataset]; + app.datasetIds = [app.datasetid]; + } + let { datasetfields : { publisher }, name} = app.datasets[0]; + let { aboutApplication, questionAnswers } = app; + + if (aboutApplication) { + let aboutObj = JSON.parse(aboutApplication); + ({ projectName } = aboutObj); } - return fullnames; - }, - - createApplicationDTO: (app) => { - let projectName = ''; - let applicants = ''; - - // Ensure backward compatibility with old single dataset DARs - if(_.isEmpty(app.datasets) || _.isUndefined(app.datasets)) { - app.datasets = [app.dataset]; - app.datasetIds = [app.datasetid]; - } - let { datasetfields : { publisher }, name} = app.datasets[0]; - let { aboutApplication, questionAnswers } = app; - - if (aboutApplication) { - let aboutObj = JSON.parse(aboutApplication); - ({ projectName } = aboutObj); - } - if(_.isEmpty(projectName)) { - projectName = `${publisher} - ${name}` - } - if (questionAnswers) { - let questionAnswersObj = JSON.parse(questionAnswers); - applicants = module.exports.extractApplicantNames(questionAnswersObj).join(', '); - } - if(_.isEmpty(applicants)) { - let { firstname, lastname } = app.mainApplicant; - applicants = `${firstname} ${lastname}`; - } - return { ...app, projectName, applicants, publisher } - }, - - calculateAvgDecisionTime: (applications) => { - // Extract dateSubmitted dateFinalStatus - let decidedApplications = applications.filter((app) => { - let { dateSubmitted = '', dateFinalStatus = '' } = app; - return (!_.isEmpty(dateSubmitted.toString()) && !_.isEmpty(dateFinalStatus.toString())); - }); - // Find difference between dates in milliseconds - if(!_.isEmpty(decidedApplications)) { - let totalDecisionTime = decidedApplications.reduce((count, current) => { - let { dateSubmitted, dateFinalStatus } = current; - let start = moment(dateSubmitted); - let end = moment(dateFinalStatus); - let diff = end.diff(start, 'seconds'); - count += diff; - return count; - }, 0); - // Divide by number of items - if(totalDecisionTime > 0) - return parseInt(totalDecisionTime/decidedApplications.length/86400); - } - return 0; - } + if(_.isEmpty(projectName)) { + projectName = `${publisher} - ${name}` + } + if (questionAnswers) { + let questionAnswersObj = JSON.parse(questionAnswers); + applicants = module.exports.extractApplicantNames(questionAnswersObj).join(', '); + } + if(_.isEmpty(applicants)) { + let { firstname, lastname } = app.mainApplicant; + applicants = `${firstname} ${lastname}`; + } + return { ...app, projectName, applicants, publisher } + }, + + calculateAvgDecisionTime: (applications) => { + // Extract dateSubmitted dateFinalStatus + let decidedApplications = applications.filter((app) => { + let { dateSubmitted = '', dateFinalStatus = '' } = app; + return (!_.isEmpty(dateSubmitted.toString()) && !_.isEmpty(dateFinalStatus.toString())); + }); + // Find difference between dates in milliseconds + if(!_.isEmpty(decidedApplications)) { + let totalDecisionTime = decidedApplications.reduce((count, current) => { + let { dateSubmitted, dateFinalStatus } = current; + let start = moment(dateSubmitted); + let end = moment(dateFinalStatus); + let diff = end.diff(start, 'seconds'); + count += diff; + return count; + }, 0); + // Divide by number of items + if(totalDecisionTime > 0) + return parseInt(totalDecisionTime/decidedApplications.length/86400); + } + return 0; + } }; diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index f165054d..9403d4be 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -36,6 +36,11 @@ router.patch('/:id', passport.authenticate('jwt'), datarequestController.updateA // @access Private router.put('/:id', passport.authenticate('jwt'), datarequestController.updateAccessRequestById); +// @route PUT api/v1/data-access-request/:id/assignworkflow +// @desc Update access request workflow +// @access Private +router.put('/:id', passport.authenticate('jwt'), datarequestController.updateAccessRequestWorkflow); + // @route POST api/v1/data-access-request/:id // @desc Submit request record // @access Private From 36837dc78fae76f18d7da91479b8fbb3be12f376 Mon Sep 17 00:00:00 2001 From: Alex Power Date: Mon, 5 Oct 2020 11:50:37 +0100 Subject: [PATCH 012/144] Defined the camunda endpoints in bpmnworkflow controller --- .../bpmnworkflow/bpmnworkflow.controller.js | 120 ++++++++++++++++-- 1 file changed, 112 insertions(+), 8 deletions(-) diff --git a/src/resources/bpmnworkflow/bpmnworkflow.controller.js b/src/resources/bpmnworkflow/bpmnworkflow.controller.js index 7efcd2a8..54bd3f42 100644 --- a/src/resources/bpmnworkflow/bpmnworkflow.controller.js +++ b/src/resources/bpmnworkflow/bpmnworkflow.controller.js @@ -6,10 +6,17 @@ axiosRetry(axios, { retries: 3, retryDelay: () => { return 3000; }}); +const bpmnBaseUrl = process.env.BPMNBASEURL; + module.exports = { - postCreateProcess: async (bmpContext) => { + //Generic Get Task Process Endpoints + getProcess: async (businessKey) => { + return await axios.get(`${bpmnBaseUrl}/engine-rest/task?processInstanceBusinessKey=${businessKey.toString()}`); + }, + //Simple Workflow Endpoints + postCreateProcess: async (bpmContext) => { // Create Axios requet to start Camunda process - let { applicationStatus, dateSubmitted, publisher, actioner, businessKey } = bmpContext; + let { applicationStatus, dateSubmitted, publisher, actioner, businessKey } = bpmContext; let data = { "variables": { "applicationStatus": { @@ -31,14 +38,14 @@ module.exports = { }, "businessKey": businessKey.toString() } - await axios.post(`${process.env.BPMNBASEURL}/engine-rest/process-definition/key/GatewayWorkflowSimple/start`, data) + await axios.post(`${bpmnBaseUrl}/engine-rest/process-definition/key/GatewayWorkflowSimple/start`, data) .catch((err) => { console.error(err); }); }, - postUpdateProcess: async (bmpContext) => { + postUpdateProcess: async (bpmContext) => { // Create Axios requet to start Camunda process - let { taskId, applicationStatus, dateSubmitted, publisher, actioner, archived } = bmpContext; + let { taskId, applicationStatus, dateSubmitted, publisher, actioner, archived } = bpmContext; let data = { "variables": { "applicationStatus": { @@ -63,12 +70,109 @@ module.exports = { } } } - await axios.post(`${process.env.BPMNBASEURL}/engine-rest/task/${taskId}/complete`, data) + await axios.post(`${bpmnBaseUrl}/engine-rest/task/${taskId}/complete`, data) .catch((err) => { console.error(err); }); }, - getProcess: async (businessKey) => { - return await axios.get(`${process.env.BPMNBASEURL}/engine-rest/task?processInstanceBusinessKey=${businessKey.toString()}`); + //Complex Workflow Endpoints + postStartPreReview: async (bpmContext) => { + //Start pre-review process + let { applicationStatus, dateSubmitted, publisher, businessKey } = bpmContext; + let data = { + "variables": { + "applicationStatus": { + "value": applicationStatus, + "type": "String" + }, + "dateSubmitted": { + "value": dateSubmitted, + "type": "String" + }, + "publisher": { + "value": publisher, + "type": "String" + } + }, + "businessKey": businessKey.toString() + } + await axios.post(`${bpmnBaseUrl}/engine-rest/process-definition/key/GatewayReviewWorkflowComplex/start`, data) + .catch((err) => { + console.error(err); + }); + }, + postStartManagerReview: async (bpmContext) => { + // Start manager-review process + let { applicationStatus, managerId, publisher, notifyManager } = bpmContext; + let data = { + "variables": { + "applicationStatus": { + "value": applicationStatus, + "type": "String" + }, + "userId": { + "value": managerId, + "type": "String" + }, + "publisher": { + "value": publisher, + "type": "String" + }, + "notifyManager": { + "value": notifyManager, + "type": "String" + } + } + } + await axios.post(`${bpmnBaseUrl}/engine-rest/task/${taskId}/complete`, data) + .catch((err) => { + console.error(err); + }); + }, + postManagerApproval: async (bpmContext) => { + // Manager has approved sectoin + let { applicationStatus, managerId, publisher } = bpmContext; + let data = { + "dataRequestStatus": applicationStatus, + "dataRequestManagerId": managerId, + "dataRequestPublisher": publisher, + "managerApproved": true + } + await axios.post(`${bpmnBaseUrl}/manager/completed/${businessKey}`) + .catch((err) => { + console.error(err); + }) + }, + postStartStepReview: async (bpmContext) => { + //Start Step-Review process + let { applicationStatus, userId, publisher, stepName, notifyReviewerSLA, reviewerList, businessKey } = bpmContext; + let data = { + "dataRequestStatus": applicationStatus, + "dataRequestUserId": userId, + "dataRequestPublisher": publisher, + "dataRequestStepName": stepName, + "notifyReviewerSLA": notifyReviewerSLA, + "reviewerList": reviewerList + } + await axios.post(`${bpmnBaseUrl}/complete/review/${businessKey}`, data) + .catch((err) => { + console.error(err); + }); + }, + postStartNextStep: async (bpmContext) => { + //Start Next-Step process + let { userId, publisher = "", stepName = "", notifyReviewerSLA = "", phaseApproved = false, reviewerList = [], businessKey } = bpmContext; + let data = { + "dataRequestUserId": userId, + "dataRequestPublisher": publisher, + "dataRequestStepName": stepName, + "notifyReviewerSLA": notifyReviewerSLA, + "phaseApproved": phaseApproved, + "reviewerList": reviewerList + } + await axios.post(`${bpmnBaseUrl}/reviewer/complete/${businessKey}`, data) + .catch((err) => { + console.error(err); + }); } } \ No newline at end of file From 0b0ede3c19928bd1b37d802d3f83ef5652e0b098 Mon Sep 17 00:00:00 2001 From: Alex Power Date: Mon, 5 Oct 2020 15:20:47 +0100 Subject: [PATCH 013/144] Modfied publisher controller to support new DAR --- .../datarequest/datarequest.model.js | 2 +- .../publisher/publisher.controller.js | 54 +++++++++++++++++-- src/resources/team/team.controller.js | 10 ++-- 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index bc637f26..21b59565 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -9,7 +9,7 @@ const DataRequestSchema = new Schema({ datasetIds: [{ type: String}], projectId: String, workflowId: { type : Schema.Types.ObjectId, ref: 'Workflow' }, - workflow: { type: [ WorkflowSchema ] }, + workflow: { type: WorkflowSchema }, applicationStatus: { type: String, default: 'inProgress', diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 2aa6df47..8f3dc312 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -98,6 +98,19 @@ module.exports = { return res .status(401) .json({ status: 'failure', message: 'Unauthorised' }); + + //Check if current use is a manager + let isManager = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + publisher.team.toObject(), + _id + ); + + let applicationStatus = ["inProgress"]; + //If the current user is not a manager then push 'Submitted' into the applicationStatus array + if(!isManager) { + applicationStatus.push("submitted"); + } // 4. Find all datasets owned by the publisher (no linkage between DAR and publisher in historic data) let datasetIds = await Data.find({ type: 'dataset', @@ -105,13 +118,44 @@ module.exports = { }).distinct('datasetid'); // 5. Find all applications where any datasetId exists let applications = await DataRequestModel.find({ - $or: [ - { dataSetId: { $in: datasetIds } }, - { datasetIds: { $elemMatch: { $in: datasetIds } } }, + $and: [ + { + $or: [ + { dataSetId: { $in: datasetIds } }, + { datasetIds: { $elemMatch: { $in: datasetIds } } }, + ], + }, + { applicationStatus: { $nin: applicationStatus } }, ], }) .sort({ updatedAt: -1 }) - .populate('datasets dataset mainApplicant'); + .populate("datasets dataset mainApplicant"); + + if (!isManager) { + applications = applications.filter((app) => { + let { workflow = {} } = app.toObject(); + if (_.isEmpty(workflow)) { + return app; + } + + let { steps = [] } = workflow; + if (_.isEmpty(steps)) { + return app; + } + + let activeStepIndex = _.findIndex(steps, function (step) { + return step.active === true; + }); + + let elapsedSteps = [...steps].slice(0, activeStepIndex+1); + let found = elapsedSteps.some((step) => step.reviewers.some((reviewer) => reviewer.equals(_id))); + + if (found) { + return app; + } + }); + } + // 6. Append projectName and applicants let modifiedApplications = [...applications] .map((app) => { @@ -167,7 +211,7 @@ module.exports = { // 2. Check the requesting user is a member of the team let { _id: userId } = req.user; let authorised = teamController.checkTeamPermissions( - teamController.roles.MANAGER, + teamController.roleTypes.MANAGER, workflow.publisher.team.toObject(), userId ); diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index 4f55ff74..29fd744a 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -1,12 +1,12 @@ import { TeamModel } from './team.model'; import _ from 'lodash'; -module.exports = { - roles: { - MANAGER: 'manager', - REVIEWER: 'reviewer', - }, +export const roleTypes = { + MANAGER: 'manager', + REVIEWER: 'reviewer', +} +module.exports = { // GET api/v1/teams/:id getTeamById: async (req, res) => { try { From 1de8c6ccfba5c18792f2f0800612c935f8ea5f82 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 5 Oct 2020 15:22:24 +0100 Subject: [PATCH 014/144] Continued build out of assign workflow endpoint --- .../datarequest/datarequest.controller.js | 408 +++++++++++++----- .../datarequest/datarequest.model.js | 2 +- .../datarequest/datarequest.route.js | 2 +- .../publisher/publisher.controller.js | 15 +- src/resources/team/team.controller.js | 15 +- src/resources/topic/topic.controller.js | 4 +- src/resources/workflow/workflow.controller.js | 19 +- src/resources/workflow/workflow.model.js | 11 +- 8 files changed, 351 insertions(+), 125 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index d39d0df0..4621ca37 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1,5 +1,6 @@ import emailGenerator from '../utilities/emailGenerator.util'; import { DataRequestModel } from './datarequest.model'; +import { WorkflowModel } from '../workflow/workflow.model'; import { Data as ToolModel } from '../tool/data.model'; import { DataRequestSchemaModel } from './datarequest.schemas.model'; import helper from '../utilities/helper.util'; @@ -8,10 +9,13 @@ import mongoose from 'mongoose'; import { UserModel } from '../user/user.model'; import inputSanitizer from '../utilities/inputSanitizer'; import moment from 'moment'; +import { application } from 'express'; const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); - +const teamController = require('../team/team.controller'); +const workflowController = require('../workflow/workflow.controller'); const notificationBuilder = require('../utilities/notificationBuilder'); + const userTypes = { CUSTODIAN: 'custodian', APPLICANT: 'applicant', @@ -27,10 +31,7 @@ module.exports = { let singleDatasetApplications = await DataRequestModel.find({ $and: [ { - $or: [ - { userId: parseInt(userId) }, - { authorIds: userId }, - ], + $or: [{ userId: parseInt(userId) }, { authorIds: userId }], }, { dataSetId: { $ne: null } }, ], @@ -39,10 +40,7 @@ module.exports = { let multiDatasetApplications = await DataRequestModel.find({ $and: [ { - $or: [ - { userId: parseInt(userId) }, - { authorIds: userId }, - ], + $or: [{ userId: parseInt(userId) }, { authorIds: userId }], }, { $and: [{ datasetIds: { $ne: [] } }, { datasetIds: { $ne: null } }], @@ -53,19 +51,25 @@ module.exports = { const applications = [ ...singleDatasetApplications, ...multiDatasetApplications, - ]; - - // 5. Append project name and applicants - let modifiedApplications = [...applications].map((app) => { - return module.exports.createApplicationDTO(app.toObject()); - }).sort((a, b) => b.updatedAt - a.updatedAt); - - let avgDecisionTime = module.exports.calculateAvgDecisionTime(applications); - - // 6. Return payload - return res.status(200).json({ success: true, data: modifiedApplications, avgDecisionTime }); - } catch(error) { - console.error(error); + ]; + + // 5. Append project name and applicants + let modifiedApplications = [...applications] + .map((app) => { + return module.exports.createApplicationDTO(app.toObject()); + }) + .sort((a, b) => b.updatedAt - a.updatedAt); + + let avgDecisionTime = module.exports.calculateAvgDecisionTime( + applications + ); + + // 6. Return payload + return res + .status(200) + .json({ success: true, data: modifiedApplications, avgDecisionTime }); + } catch (error) { + console.error(error); return res.status(500).json({ success: false, message: 'An error occurred searching for user applications', @@ -83,11 +87,12 @@ module.exports = { // 2. Find the matching record and include attached datasets records with publisher details let accessRecord = await DataRequestModel.findOne({ _id: requestId, - }).populate({ path: 'mainApplicant', select: 'firstname lastname -id'}) - .populate({ - path: 'datasets dataset authors', - populate: { path: 'publisher', populate: { path: 'team' } }, - }); + }) + .populate({ path: 'mainApplicant', select: 'firstname lastname -id' }) + .populate({ + path: 'datasets dataset authors', + populate: { path: 'publisher', populate: { path: 'team' } }, + }); // 3. If no matching application found, return 404 if (!accessRecord) { return res @@ -131,7 +136,9 @@ module.exports = { datasets: accessRecord.datasets, readOnly, userType, - projectId: accessRecord.projectId || helper.generateFriendlyId(accessRecord._id) + projectId: + accessRecord.projectId || + helper.generateFriendlyId(accessRecord._id), }, }); } catch (err) { @@ -157,7 +164,10 @@ module.exports = { dataSetId, userId, applicationStatus: 'inProgress', - }).populate({ path: 'mainApplicant', select: 'firstname lastname -id -_id'}) + }).populate({ + path: 'mainApplicant', + select: 'firstname lastname -id -_id', + }); // 4. Get dataset dataset = await ToolModel.findOne({ datasetid: dataSetId }).populate( 'publisher' @@ -207,7 +217,10 @@ module.exports = { await newApplication.save(); // 5. return record - data = { ...newApplication._doc, mainApplicant: { firstname, lastname } }; + data = { + ...newApplication._doc, + mainApplicant: { firstname, lastname }, + }; } else { data = { ...accessRecord.toObject() }; } @@ -221,7 +234,7 @@ module.exports = { aboutApplication: JSON.parse(data.aboutApplication), dataset, projectId: data.projectId || helper.generateFriendlyId(data._id), - userType: 'applicant' + userType: 'applicant', }, }); } catch (err) { @@ -248,7 +261,12 @@ module.exports = { datasetIds: { $all: arrDatasetIds }, userId, applicationStatus: 'inProgress', - }).populate({ path: 'mainApplicant', select: 'firstname lastname -id -_id'}).sort({ createdAt: 1 }); + }) + .populate({ + path: 'mainApplicant', + select: 'firstname lastname -id -_id', + }) + .sort({ createdAt: 1 }); // 4. Get datasets datasets = await ToolModel.find({ datasetid: { $in: arrDatasetIds }, @@ -296,7 +314,10 @@ module.exports = { ); await newApplication.save(); // 5. return record - data = { ...newApplication._doc, mainApplicant: { firstname, lastname } }; + data = { + ...newApplication._doc, + mainApplicant: { firstname, lastname }, + }; } else { data = { ...accessRecord.toObject() }; } @@ -310,7 +331,7 @@ module.exports = { aboutApplication: JSON.parse(data.aboutApplication), datasets, projectId: data.projectId || helper.generateFriendlyId(data._id), - userType: 'applicant' + userType: 'applicant', }, }); } catch (err) { @@ -374,7 +395,8 @@ module.exports = { } = req; // 2. Get the userId let { _id, id: userId } = req.user; - let applicationStatus = '', applicationStatusDesc = ''; + let applicationStatus = '', + applicationStatusDesc = ''; // 3. Find the relevant data request application let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ @@ -403,11 +425,17 @@ module.exports = { } // 5. Check if the user is permitted to perform update to application - let isDirty = false, statusChange = false, contributorChange = false; + let isDirty = false, + statusChange = false, + contributorChange = false; let { authorised, userType, - } = module.exports.getUserPermissionsForApplication(accessRecord, userId, _id); + } = module.exports.getUserPermissionsForApplication( + accessRecord, + userId, + _id + ); if (!authorised) { return res.status(401).json({ @@ -437,7 +465,7 @@ module.exports = { accessRecord.applicationStatus = applicationStatus; if (finalStatuses.includes(applicationStatus)) { - accessRecord.dateFinalStatus = new Date() + accessRecord.dateFinalStatus = new Date(); } isDirty = true; statusChange = true; @@ -446,14 +474,16 @@ module.exports = { applicationStatusDesc && applicationStatusDesc !== accessRecord.applicationStatusDesc ) { - accessRecord.applicationStatusDesc = inputSanitizer.removeNonBreakingSpaces(applicationStatusDesc); + accessRecord.applicationStatusDesc = inputSanitizer.removeNonBreakingSpaces( + applicationStatusDesc + ); isDirty = true; } // If applicant, allow update to contributors/authors } else if (userType === userTypes.APPLICANT) { // Extract new contributor/author IDs if (req.body.authorIds) { - ({ authorIds:newAuthors } = req.body); + ({ authorIds: newAuthors } = req.body); // Perform comparison between new and existing authors to determine if an update is required if (newAuthors && !helper.arraysEqual(newAuthors, currentAuthors)) { @@ -467,13 +497,13 @@ module.exports = { // 7. If a change has been made, notify custodian and main applicant if (isDirty) { await accessRecord.save(async (err) => { - if(err) { + if (err) { console.error(err); res.status(500).json({ status: 'error', message: err }); } else { // If save has succeeded - send notifications // Send notifications to added/removed contributors - if(contributorChange) { + if (contributorChange) { await module.exports.createNotifications( 'ContributorChange', { newAuthors, currentAuthors }, @@ -482,7 +512,7 @@ module.exports = { ); } // Send notifications to custodian team, main applicant and contributors regarding status change - if(statusChange) { + if (statusChange) { await module.exports.createNotifications( 'StatusChange', { applicationStatus, applicationStatusDesc }, @@ -495,13 +525,20 @@ module.exports = { // Call Camunda controller to get current workflow process for application let response = await bpmController.getProcess(id); let { data = {} } = response; - if(!_.isEmpty(data)) { + if (!_.isEmpty(data)) { let [obj] = data; - let { id:taskId } = obj; + let { id: taskId } = obj; // Call Camunda to update workflow process for application let { name: publisher } = accessRecord.datasets[0].publisher; - let bmpContext = { taskId, dateSubmitted: new Date(), applicationStatus, publisher, actioner: _id, archived: false }; + let bmpContext = { + taskId, + dateSubmitted: new Date(), + applicationStatus, + publisher, + actioner: _id, + archived: false, + }; await bpmController.postUpdateProcess(bmpContext); } } @@ -524,8 +561,148 @@ module.exports = { }, //PUT api/v1/data-access-request/:id/assignworkflow - updateAccessRequestWorkflow: async (req, res) => { - + assignWorkflow: async (req, res) => { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + let { _id: userId } = req.user; + let { workflowId = '' } = req.body; + if (_.isEmpty(workflowId)) { + return res.status(400).json({ + success: false, + message: + 'You must supply the unique identifier to assign a workflow to this application', + }); + } + // 2. Retrieve DAR from database + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ + path: 'datasets dataset mainApplicant authors', + populate: { + path: 'publisher additionalInfo', + populate: { + path: 'team' + }, + }, + }); + if (!accessRecord) { + return res + .status(404) + .json({ status: 'error', message: 'Application not found.' }); + } + // 3. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) + if (_.isEmpty(accessRecord.datasets)) { + accessRecord.datasets = [accessRecord.dataset]; + } + // 4. Check permissions of user is manager of associated team + let authorised = false; + if (_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { + let { + publisher: { team }, + } = accessRecord.datasets[0]; + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + team.toObject(), + userId + ); + } + // 5. Refuse access if not authorised + if (!authorised) { + return res + .status(401) + .json({ status: 'failure', message: 'Unauthorised' }); + } + // 6. Check publisher allows workflows + let workflowEnabled = false; + if (_.has(accessRecord.datasets[0].toObject(), 'publisher.workflowEnabled')) { + ({ + publisher: { workflowEnabled }, + } = accessRecord.datasets[0]); + if (!workflowEnabled) { + return res.status(400).json({ + success: false, + message: 'This custodian has not enabled workflows', + }); + } + } + // 7. Check no workflow already assigned + let { workflowId: currentWorkflowId = '' } = accessRecord; + if (!_.isEmpty(currentWorkflowId)) { + return res.status(400).json({ + success: false, + message: 'This application already has a workflow assigned', + }); + } + // 8. Check application is in-review + let { applicationStatus } = accessRecord; + if (applicationStatus !== 'inReview') { + return res.status(400).json({ + success: false, + message: + 'The application status must be set to in review to assign a workflow', + }); + } + // 9. Retrieve workflow using ID from database + const workflow = await WorkflowModel.findOne({ + _id: workflowId, + }).populate([ + { + path: 'steps.reviewers', + model: 'User', + select: '_id id firstname lastname email', + }, + ]); + if (!workflow) { + return res.status(404).json({ success: false }); + } + // 10. Set first workflow step active and ensure all others are false + let workflowObj = workflow.toObject(); + workflowObj.steps = workflowObj.steps.map((step) => { + return { ...step, active: false }; + }); + workflowObj.steps[0].active = true; + workflowObj.steps[0].startDateTime = new Date(); + // 11. Update application with attached workflow + accessRecord.workflowId = workflowId; + accessRecord.workflow = workflowObj; + // 12. Submit save + accessRecord.save(function (err) { + if (err) { + console.error(err); + return res.status(400).json({ + success: false, + message: err.message, + }); + } else { + // 13. Contact Camunda to start workflow process + let { name: publisher } = accessRecord.datasets[0].publisher; + let bpmContext = { + businessKey: id, + applicationStatus: 'inReview', + actioner: userId, + publisher, + stepName: workflowObj.steps[0].stepName, + deadlineDateTime: workflowController.calculateStepDeadlineReminderDate( + workflowObj.steps[0] + ), + reviewers: [...workflowObj.steps[0].reviewers], + }; + bpmController.postCreateProcess(bpmContext); + // 14. TODO Create notifications for workflow assigned (step 1 reviewers and other managers) + // 15. Return workflow payload + return res.status(200).json({ + success: true, + }); + } + }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred assigning the workflow', + }); + } }, //POST api/v1/data-access-request/:id @@ -569,20 +746,31 @@ module.exports = { } let dateSubmitted = new Date(); accessRecord.dateSubmitted = dateSubmitted; - await accessRecord.save(async(err) => { - if(err) { + await accessRecord.save(async (err) => { + if (err) { console.error(err); res.status(500).json({ status: 'error', message: err }); } else { // If save has succeeded - send notifications // Send notifications and emails to custodian team and main applicant - await module.exports.createNotifications('Submitted', {}, accessRecord, req.user); + await module.exports.createNotifications( + 'Submitted', + {}, + accessRecord, + req.user + ); // Start workflow process if publisher requires it if (accessRecord.datasets[0].publisher.workflowEnabled) { // Call Camunda controller to start workflow for submitted application let { name: publisher } = accessRecord.datasets[0].publisher; let { _id: userId } = req.user; - let bmpContext = { dateSubmitted, applicationStatus: 'submitted', publisher, businessKey:id, actioner: userId }; + let bmpContext = { + dateSubmitted, + applicationStatus: 'submitted', + publisher, + businessKey: id, + actioner: userId, + }; bpmController.postCreateProcess(bmpContext); } } @@ -604,9 +792,9 @@ module.exports = { let aboutApplication = JSON.parse(accessRecord.aboutApplication); let { projectName } = aboutApplication; let { projectId, _id } = accessRecord; - if(_.isEmpty(projectId)) { + if (_.isEmpty(projectId)) { projectId = _id; - } + } // Publisher details from single dataset let { datasetfields: { contactPoint, publisher }, @@ -816,11 +1004,13 @@ module.exports = { // 1. Deconstruct authors array from context to compare with existing Mongo authors const { newAuthors, currentAuthors } = context; // 2. Determine authors who have been removed - let addedAuthors = [...newAuthors] - .filter((author) => !currentAuthors.includes(author)); + let addedAuthors = [...newAuthors].filter( + (author) => !currentAuthors.includes(author) + ); // 3. Determine authors who have been added - let removedAuthors = [...currentAuthors] - .filter((author) => !newAuthors.includes(author)); + let removedAuthors = [...currentAuthors].filter( + (author) => !newAuthors.includes(author) + ); // 4. Create emails and notifications for added/removed contributors // Set required data for email generation options = { @@ -906,16 +1096,17 @@ module.exports = { // Check if the user is a custodian team member and assign permissions if so if (_.has(application.datasets[0].toObject(), 'publisher.team.members')) { ({ members } = application.datasets[0].publisher.team.toObject()); - if ( - members.some((el) => el.memberid.toString() === _id.toString()) - ) { + if (members.some((el) => el.memberid.toString() === _id.toString())) { userType = userTypes.CUSTODIAN; authorised = true; } } // If user is not authenticated as a custodian, check if they are an author or the main applicant if (_.isEmpty(userType)) { - if (application.authorIds.includes(userId) || application.userId === userId) { + if ( + application.authorIds.includes(userId) || + application.userId === userId + ) { userType = userTypes.APPLICANT; authorised = true; } @@ -928,81 +1119,90 @@ module.exports = { }, extractApplicantNames: (questionAnswers) => { - let fullnames = [], autoCompleteLookups = {"fullname": ['email']}; - // spread questionAnswers to new var - let qa = { ...questionAnswers }; - // get object keys of questionAnswers - let keys = Object.keys(qa); - // loop questionAnswer keys - for (const key of keys) { - // get value of key - let value = qa[key]; - // split the key up for unique purposes - let [qId] = key.split('_'); - // check if key in lookup - let lookup = autoCompleteLookups[`${qId}`]; - // if key exists and it has an object do relevant data setting - if (typeof lookup !== 'undefined' && typeof value === 'object') { - switch (qId) { - case 'fullname': - fullnames.push(value.name); - break; - } + let fullnames = [], + autoCompleteLookups = { fullname: ['email'] }; + // spread questionAnswers to new var + let qa = { ...questionAnswers }; + // get object keys of questionAnswers + let keys = Object.keys(qa); + // loop questionAnswer keys + for (const key of keys) { + // get value of key + let value = qa[key]; + // split the key up for unique purposes + let [qId] = key.split('_'); + // check if key in lookup + let lookup = autoCompleteLookups[`${qId}`]; + // if key exists and it has an object do relevant data setting + if (typeof lookup !== 'undefined' && typeof value === 'object') { + switch (qId) { + case 'fullname': + fullnames.push(value.name); + break; } } - return fullnames; + } + return fullnames; }, - + createApplicationDTO: (app) => { let projectName = ''; let applicants = ''; // Ensure backward compatibility with old single dataset DARs - if(_.isEmpty(app.datasets) || _.isUndefined(app.datasets)) { + if (_.isEmpty(app.datasets) || _.isUndefined(app.datasets)) { app.datasets = [app.dataset]; app.datasetIds = [app.datasetid]; } - let { datasetfields : { publisher }, name} = app.datasets[0]; + let { + datasetfields: { publisher }, + name, + } = app.datasets[0]; let { aboutApplication, questionAnswers } = app; if (aboutApplication) { let aboutObj = JSON.parse(aboutApplication); ({ projectName } = aboutObj); } - if(_.isEmpty(projectName)) { - projectName = `${publisher} - ${name}` + if (_.isEmpty(projectName)) { + projectName = `${publisher} - ${name}`; } if (questionAnswers) { let questionAnswersObj = JSON.parse(questionAnswers); - applicants = module.exports.extractApplicantNames(questionAnswersObj).join(', '); + applicants = module.exports + .extractApplicantNames(questionAnswersObj) + .join(', '); } - if(_.isEmpty(applicants)) { + if (_.isEmpty(applicants)) { let { firstname, lastname } = app.mainApplicant; applicants = `${firstname} ${lastname}`; } - return { ...app, projectName, applicants, publisher } + return { ...app, projectName, applicants, publisher }; }, calculateAvgDecisionTime: (applications) => { // Extract dateSubmitted dateFinalStatus let decidedApplications = applications.filter((app) => { - let { dateSubmitted = '', dateFinalStatus = '' } = app; - return (!_.isEmpty(dateSubmitted.toString()) && !_.isEmpty(dateFinalStatus.toString())); + let { dateSubmitted = '', dateFinalStatus = '' } = app; + return ( + !_.isEmpty(dateSubmitted.toString()) && + !_.isEmpty(dateFinalStatus.toString()) + ); }); // Find difference between dates in milliseconds - if(!_.isEmpty(decidedApplications)) { + if (!_.isEmpty(decidedApplications)) { let totalDecisionTime = decidedApplications.reduce((count, current) => { - let { dateSubmitted, dateFinalStatus } = current; - let start = moment(dateSubmitted); - let end = moment(dateFinalStatus); - let diff = end.diff(start, 'seconds'); - count += diff; - return count; + let { dateSubmitted, dateFinalStatus } = current; + let start = moment(dateSubmitted); + let end = moment(dateFinalStatus); + let diff = end.diff(start, 'seconds'); + count += diff; + return count; }, 0); // Divide by number of items - if(totalDecisionTime > 0) - return parseInt(totalDecisionTime/decidedApplications.length/86400); - } + if (totalDecisionTime > 0) + return parseInt(totalDecisionTime / decidedApplications.length / 86400); + } return 0; - } + }, }; diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index bc637f26..21b59565 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -9,7 +9,7 @@ const DataRequestSchema = new Schema({ datasetIds: [{ type: String}], projectId: String, workflowId: { type : Schema.Types.ObjectId, ref: 'Workflow' }, - workflow: { type: [ WorkflowSchema ] }, + workflow: { type: WorkflowSchema }, applicationStatus: { type: String, default: 'inProgress', diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 9403d4be..3a6015c0 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -39,7 +39,7 @@ router.put('/:id', passport.authenticate('jwt'), datarequestController.updateAcc // @route PUT api/v1/data-access-request/:id/assignworkflow // @desc Update access request workflow // @access Private -router.put('/:id', passport.authenticate('jwt'), datarequestController.updateAccessRequestWorkflow); +router.put('/:id/assignworkflow', passport.authenticate('jwt'), datarequestController.assignWorkflow); // @route POST api/v1/data-access-request/:id // @desc Submit request record diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 2aa6df47..f0fd910f 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -105,9 +105,14 @@ module.exports = { }).distinct('datasetid'); // 5. Find all applications where any datasetId exists let applications = await DataRequestModel.find({ - $or: [ - { dataSetId: { $in: datasetIds } }, - { datasetIds: { $elemMatch: { $in: datasetIds } } }, + $and: [ + { + $or: [ + { dataSetId: { $in: datasetIds } }, + { datasetIds: { $elemMatch: { $in: datasetIds } } }, + ], + }, + { applicationStatus: { $ne: 'inProgress' } }, ], }) .sort({ updatedAt: -1 }) @@ -167,8 +172,8 @@ module.exports = { // 2. Check the requesting user is a member of the team let { _id: userId } = req.user; let authorised = teamController.checkTeamPermissions( - teamController.roles.MANAGER, - workflow.publisher.team.toObject(), + teamController.roleTypes.MANAGER, + workflows[0].publisher.team.toObject(), userId ); // 3. If not return unauthorised diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index 4f55ff74..1a1a2b5a 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -1,12 +1,13 @@ import { TeamModel } from './team.model'; import _ from 'lodash'; -module.exports = { - roles: { - MANAGER: 'manager', - REVIEWER: 'reviewer', - }, +export const roleTypes = { + MANAGER: 'manager', + REVIEWER: 'reviewer', +} +module.exports = { + // GET api/v1/teams/:id getTeamById: async (req, res) => { try { @@ -80,11 +81,11 @@ module.exports = { * Check a users CRUD permissions for workflows * * @param {enum} role The role required for the action - * @param {object} team The publisher object containing its members + * @param {object} team The team object containing its members * @param {objectId} userId The userId to check the permissions for */ checkTeamPermissions: (role, team, userId) => { - // 1. Ensure the publisher has a team and associated members defined + // 1. Ensure the team has associated members defined if (_.has(team, 'members')) { // 2. Extract team members let { members } = team; diff --git a/src/resources/topic/topic.controller.js b/src/resources/topic/topic.controller.js index 71bf2888..41ec3997 100644 --- a/src/resources/topic/topic.controller.js +++ b/src/resources/topic/topic.controller.js @@ -94,8 +94,8 @@ module.exports = { findTopic: async (topicId, userId) => { try { const topic = await TopicModel.findOne({ - _id: new mongoose.Types.ObjectId(topicId), - recipients: { $elemMatch : { $eq: userId }} + _id: new mongoose.Types.ObjectId(topicId), + recipients: { $elemMatch : { $eq: userId }} }); if (!topic) return undefined diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 561e25d8..c5dcbfe0 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -2,6 +2,7 @@ import { PublisherModel } from '../publisher/publisher.model'; import { DataRequestModel } from '../datarequest/datarequest.model'; import { WorkflowModel } from './workflow.model'; import helper from '../utilities/helper.util'; +import moment from 'moment'; import _ from 'lodash'; import mongoose from 'mongoose'; @@ -41,7 +42,7 @@ module.exports = { // 2. Check the requesting user is a manager of the custodian team let { _id: userId } = req.user; let authorised = teamController.checkTeamPermissions( - teamController.roles.MANAGER, + teamController.roleTypes.MANAGER, workflow.publisher.team.toObject(), userId ); @@ -123,7 +124,7 @@ module.exports = { } // 3. Check the requesting user is a manager of the custodian team let authorised = teamController.checkTeamPermissions( - teamController.roles.MANAGER, + teamController.roleTypes.MANAGER, publisherObj.team.toObject(), userId ); @@ -189,7 +190,7 @@ module.exports = { } // 2. Check the requesting user is a manager of the custodian team let authorised = teamController.checkTeamPermissions( - teamController.roles.MANAGER, + teamController.roleTypes.MANAGER, workflow.publisher.team.toObject(), userId ); @@ -274,7 +275,7 @@ module.exports = { } // 2. Check the requesting user is a manager of the custodian team let authorised = teamController.checkTeamPermissions( - teamController.roles.MANAGER, + teamController.roleTypes.MANAGER, workflow.publisher.team.toObject(), userId ); @@ -318,5 +319,15 @@ module.exports = { message: 'An error occurred deleting the workflow', }); } + }, + + calculateStepDeadlineReminderDate: (step) => { + // Extract deadline in days from step definition + let { deadline, reminderOffset } = step; + // Add step duration to current date + let deadlineDate = moment().add(deadline, 'days'); + // Subtract SLA reminder offset + let reminderDate = moment(deadlineDate).subtract(reminderOffset, 'days'); + return reminderDate.format("YYYY-MM-DDTHH:mm:ss"); } }; diff --git a/src/resources/workflow/workflow.model.js b/src/resources/workflow/workflow.model.js index b7730fa1..a95532d3 100644 --- a/src/resources/workflow/workflow.model.js +++ b/src/resources/workflow/workflow.model.js @@ -16,7 +16,16 @@ const StepSchema = new Schema({ stepName: { type: String, required: true }, reviewers: { type: [{ type : Schema.Types.ObjectId, ref: 'User' }], validate:[minReviewers, 'There must be at least one reviewer per phase'] }, sections: { type: [String], validate:[minSections, 'There must be at least one section assigned to a phase'] }, - deadline: { type: Number, required: true } + deadline: { type: Number, required: true }, // Number of days from step starting that a deadline is reached + reminderOffset: { type: Number, required: true, default: 3 }, // Number of days before deadline that SLAs are triggered by Camunda + // Items below not required for step definition + active: { type: Boolean, default: false }, + startDateTime: { type: Date }, + endDateTime: { type: Date }, + recommendations: [{ + reviewer: { type : Schema.Types.ObjectId, ref: 'User' }, + approved: { type: Boolean } + }] }); export const WorkflowSchema = new Schema({ From d67622e76b830142da77b71c8f19da8bf2e92c97 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 5 Oct 2020 16:35:47 +0100 Subject: [PATCH 015/144] Completed assigning of workflow to a DAR --- .../bpmnworkflow/bpmnworkflow.controller.js | 16 +++++++++------- .../datarequest/datarequest.controller.js | 7 ++++--- src/resources/workflow/workflow.controller.js | 8 +++----- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/resources/bpmnworkflow/bpmnworkflow.controller.js b/src/resources/bpmnworkflow/bpmnworkflow.controller.js index 54bd3f42..91e9e98c 100644 --- a/src/resources/bpmnworkflow/bpmnworkflow.controller.js +++ b/src/resources/bpmnworkflow/bpmnworkflow.controller.js @@ -13,6 +13,7 @@ module.exports = { getProcess: async (businessKey) => { return await axios.get(`${bpmnBaseUrl}/engine-rest/task?processInstanceBusinessKey=${businessKey.toString()}`); }, + //Simple Workflow Endpoints postCreateProcess: async (bpmContext) => { // Create Axios requet to start Camunda process @@ -75,6 +76,7 @@ module.exports = { console.error(err); }); }, + //Complex Workflow Endpoints postStartPreReview: async (bpmContext) => { //Start pre-review process @@ -138,23 +140,23 @@ module.exports = { "dataRequestPublisher": publisher, "managerApproved": true } - await axios.post(`${bpmnBaseUrl}/manager/completed/${businessKey}`) + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/manager/completed/${businessKey}`) .catch((err) => { console.error(err); }) }, postStartStepReview: async (bpmContext) => { //Start Step-Review process - let { applicationStatus, userId, publisher, stepName, notifyReviewerSLA, reviewerList, businessKey } = bpmContext; + let { applicationStatus, actioner, publisher, stepName, reminderDateTime, reviewers, businessKey } = bpmContext; let data = { "dataRequestStatus": applicationStatus, - "dataRequestUserId": userId, + "dataRequestUserId": actioner, "dataRequestPublisher": publisher, "dataRequestStepName": stepName, - "notifyReviewerSLA": notifyReviewerSLA, - "reviewerList": reviewerList + "notifyReviewerSLA": reminderDateTime, + "reviewerList": reviewers } - await axios.post(`${bpmnBaseUrl}/complete/review/${businessKey}`, data) + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/complete/review/${businessKey}`, data) .catch((err) => { console.error(err); }); @@ -170,7 +172,7 @@ module.exports = { "phaseApproved": phaseApproved, "reviewerList": reviewerList } - await axios.post(`${bpmnBaseUrl}/reviewer/complete/${businessKey}`, data) + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/reviewer/complete/${businessKey}`, data) .catch((err) => { console.error(err); }); diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 4621ca37..3a4bab97 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -677,18 +677,19 @@ module.exports = { } else { // 13. Contact Camunda to start workflow process let { name: publisher } = accessRecord.datasets[0].publisher; + let reviewers = workflowObj.steps[0].reviewers.map((reviewer) => reviewer._id.toString()); let bpmContext = { businessKey: id, applicationStatus: 'inReview', actioner: userId, publisher, stepName: workflowObj.steps[0].stepName, - deadlineDateTime: workflowController.calculateStepDeadlineReminderDate( + reminderDateTime: workflowController.calculateStepDeadlineReminderDate( workflowObj.steps[0] ), - reviewers: [...workflowObj.steps[0].reviewers], + reviewers }; - bpmController.postCreateProcess(bpmContext); + bpmController.postStartStepReview(bpmContext); // 14. TODO Create notifications for workflow assigned (step 1 reviewers and other managers) // 15. Return workflow payload return res.status(200).json({ diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index c5dcbfe0..0ae2c59c 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -322,12 +322,10 @@ module.exports = { }, calculateStepDeadlineReminderDate: (step) => { - // Extract deadline in days from step definition + // Extract deadline and reminder offset in days from step definition let { deadline, reminderOffset } = step; - // Add step duration to current date - let deadlineDate = moment().add(deadline, 'days'); // Subtract SLA reminder offset - let reminderDate = moment(deadlineDate).subtract(reminderOffset, 'days'); - return reminderDate.format("YYYY-MM-DDTHH:mm:ss"); + let reminderPeriod = deadline - reminderOffset; + return `P${reminderPeriod}D`; } }; From 8dcecb0f23bb675c5d5534d670921f4a56fe1ae3 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 5 Oct 2020 20:04:49 +0100 Subject: [PATCH 016/144] Fixed LGTM warnings --- src/resources/bpmnworkflow/bpmnworkflow.controller.js | 2 +- src/resources/datarequest/datarequest.controller.js | 2 -- src/resources/workflow/workflow.controller.js | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/resources/bpmnworkflow/bpmnworkflow.controller.js b/src/resources/bpmnworkflow/bpmnworkflow.controller.js index 91e9e98c..23f5f5c4 100644 --- a/src/resources/bpmnworkflow/bpmnworkflow.controller.js +++ b/src/resources/bpmnworkflow/bpmnworkflow.controller.js @@ -140,7 +140,7 @@ module.exports = { "dataRequestPublisher": publisher, "managerApproved": true } - await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/manager/completed/${businessKey}`) + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/manager/completed/${businessKey}`, data) .catch((err) => { console.error(err); }) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 3a4bab97..e24d9f95 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -5,11 +5,9 @@ import { Data as ToolModel } from '../tool/data.model'; import { DataRequestSchemaModel } from './datarequest.schemas.model'; import helper from '../utilities/helper.util'; import _ from 'lodash'; -import mongoose from 'mongoose'; import { UserModel } from '../user/user.model'; import inputSanitizer from '../utilities/inputSanitizer'; import moment from 'moment'; -import { application } from 'express'; const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); const teamController = require('../team/team.controller'); diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 0ae2c59c..641c9dc6 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -2,7 +2,6 @@ import { PublisherModel } from '../publisher/publisher.model'; import { DataRequestModel } from '../datarequest/datarequest.model'; import { WorkflowModel } from './workflow.model'; import helper from '../utilities/helper.util'; -import moment from 'moment'; import _ from 'lodash'; import mongoose from 'mongoose'; From 36b0754b45abdf281707bda203cef7e560927d0a Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 6 Oct 2020 11:36:29 +0100 Subject: [PATCH 017/144] Completed complex workflow voting --- .../bpmnworkflow/bpmnworkflow.controller.js | 26 +-- .../datarequest/datarequest.controller.js | 175 +++++++++++++++++- .../datarequest/datarequest.route.js | 5 + src/resources/workflow/workflow.controller.js | 21 +++ src/resources/workflow/workflow.model.js | 3 +- 5 files changed, 201 insertions(+), 29 deletions(-) diff --git a/src/resources/bpmnworkflow/bpmnworkflow.controller.js b/src/resources/bpmnworkflow/bpmnworkflow.controller.js index 23f5f5c4..2db5f256 100644 --- a/src/resources/bpmnworkflow/bpmnworkflow.controller.js +++ b/src/resources/bpmnworkflow/bpmnworkflow.controller.js @@ -147,32 +147,16 @@ module.exports = { }, postStartStepReview: async (bpmContext) => { //Start Step-Review process - let { applicationStatus, actioner, publisher, stepName, reminderDateTime, reviewers, businessKey } = bpmContext; - let data = { - "dataRequestStatus": applicationStatus, - "dataRequestUserId": actioner, - "dataRequestPublisher": publisher, - "dataRequestStepName": stepName, - "notifyReviewerSLA": reminderDateTime, - "reviewerList": reviewers - } - await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/complete/review/${businessKey}`, data) + let { businessKey } = bpmContext; + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/complete/review/${businessKey}`, bpmContext) .catch((err) => { console.error(err); }); }, - postStartNextStep: async (bpmContext) => { + postCompleteReview: async (bpmContext) => { //Start Next-Step process - let { userId, publisher = "", stepName = "", notifyReviewerSLA = "", phaseApproved = false, reviewerList = [], businessKey } = bpmContext; - let data = { - "dataRequestUserId": userId, - "dataRequestPublisher": publisher, - "dataRequestStepName": stepName, - "notifyReviewerSLA": notifyReviewerSLA, - "phaseApproved": phaseApproved, - "reviewerList": reviewerList - } - await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/reviewer/complete/${businessKey}`, data) + let { businessKey } = bpmContext; + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/reviewer/complete/${businessKey}`, bpmContext) .catch((err) => { console.error(err); }); diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index e24d9f95..72138c2c 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -8,6 +8,7 @@ import _ from 'lodash'; import { UserModel } from '../user/user.model'; import inputSanitizer from '../utilities/inputSanitizer'; import moment from 'moment'; +import mongoose from 'mongoose'; const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); const teamController = require('../team/team.controller'); @@ -675,17 +676,17 @@ module.exports = { } else { // 13. Contact Camunda to start workflow process let { name: publisher } = accessRecord.datasets[0].publisher; - let reviewers = workflowObj.steps[0].reviewers.map((reviewer) => reviewer._id.toString()); + let reviewerList = workflowObj.steps[0].reviewers.map((reviewer) => reviewer._id.toString()); let bpmContext = { businessKey: id, - applicationStatus: 'inReview', - actioner: userId, - publisher, - stepName: workflowObj.steps[0].stepName, - reminderDateTime: workflowController.calculateStepDeadlineReminderDate( + dataRequestStatus: 'inReview', + dataRequestUserId: userId, + dataRequestPublisher, + dataRequestStepName: workflowObj.steps[0].stepName, + notifyReviewerSLA: workflowController.calculateStepDeadlineReminderDate( workflowObj.steps[0] ), - reviewers + reviewerList }; bpmController.postStartStepReview(bpmContext); // 14. TODO Create notifications for workflow assigned (step 1 reviewers and other managers) @@ -704,6 +705,166 @@ module.exports = { } }, + //PUT api/v1/data-access-request/:id/vote + updateAccessRequestReviewVote: async (req, res) => { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + let { _id: userId } = req.user; + let { approved, comments = '' } = req.body; + if (_.isUndefined(approved) || _.isEmpty(comments)) { + return res.status(400).json({ + success: false, + message: + 'You must supply the approved status with a reason', + }); + } + // 2. Retrieve DAR from database + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ + path: 'publisherObj', + populate: { + path: 'team' + }, + }); + if (!accessRecord) { + return res + .status(404) + .json({ status: 'error', message: 'Application not found.' }); + } + // 3. Check permissions of user is reviewer of associated team + let authorised = false; + if (_.has(accessRecord.toObject(), 'publisherObj.team')) { + let { + team + } = accessRecord.publisherObj; + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.REVIEWER, + team.toObject(), + userId + ); + } + // 4. Refuse access if not authorised + if (!authorised) { + return res + .status(401) + .json({ status: 'failure', message: 'Unauthorised' }); + } + // 5. Check application is in-review + let { applicationStatus } = accessRecord; + if (applicationStatus !== 'inReview') { + return res.status(400).json({ + success: false, + message: + 'The application status must be set to in review to cast a vote', + }); + } + // 6. Ensure a workflow has been attached to this application + let { workflow } = accessRecord; + if(!workflow) { + return res.status(400).json({ + success: false, + message: + 'There is no workflow attached to this application in order to cast a vote', + }); + } + // 7. Ensure the requesting user is expected to cast a vote + let { steps } = workflow; + let activeStepIndex = steps.findIndex((step) => { + return step.active === true; + }) + if(!steps[activeStepIndex].reviewers.includes(userId)) { + return res.status(400).json({ + success: false, + message: + 'You have not been assigned to vote on this review phase', + }); + } + //8. Ensure the requesting user has not already voted + let { recommendations = [] } = steps[activeStepIndex]; + if(recommendations) { + let found = recommendations.some((rec) => { + return rec.reviewer.equals(userId); + }); + if(found) { + return res.status(400).json({ + success: false, + message: + 'You have already voted on this review phase', + }); + } + } + // 9. Create new recommendation + let newRecommendation = { + approved, + comments, + reviewer: new mongoose.Types.ObjectId(userId) + } + // 10. Update access record with recommendation + accessRecord.workflow.steps[activeStepIndex].recommendations = [...accessRecord.workflow.steps[activeStepIndex].recommendations, newRecommendation]; + // 11. Check if access record now has the required number of reviewers and set completed state for phase + const requiredReviews = accessRecord.workflow.steps[activeStepIndex].reviewers.length; + const completedReviews = accessRecord.workflow.steps[activeStepIndex].recommendations.length; + const stepComplete = completedReviews === requiredReviews; + const finalStep = activeStepIndex === accessRecord.workflow.steps.length -1; + // 12. Workflow management - construct Camunda payloads + let bmpContext = { + businessKey: id, + //dataRequestUserId: userId.toString(), + dataRequestUserId: "5ede1713384b64b655b9dd13" + }; + if(stepComplete) { + accessRecord.workflow.steps[activeStepIndex].active = false; + accessRecord.workflow.steps[activeStepIndex].endDateTime = new Date(); + if(finalStep) { + // Move into final review phase (Camunda) set up payload + bmpContext = { + ...bmpContext, + finalPhaseApproved: true + }; + } else { + // Move to next step + accessRecord.workflow.steps[activeStepIndex+1].active = true; + accessRecord.workflow.steps[activeStepIndex+1].startDateTime = new Date(); + // Get details for next step + let { name: dataRequestPublisher } = accessRecord.publisherObj; + let nextStep = accessRecord.workflow.steps[activeStepIndex+1]; + let reviewerList = nextStep.reviewers.map((reviewer) => reviewer._id.toString()); + let { stepName: dataRequestStepName } = nextStep; + // Create payload for Camunda + bmpContext = { + ...bmpContext, + dataRequestPublisher, + dataRequestStepName, + notifyReviewerSLA: workflowController.calculateStepDeadlineReminderDate( + nextStep + ), + phaseApproved: true, + reviewerList + }; + } + } + // 13. Update MongoDb record for DAR + await accessRecord.save(async (err) => { + if (err) { + console.error(err); + res.status(500).json({ status: 'error', message: err }); + } else { + // Call Camunda controller to update workflow process + bpmController.postCompleteReview(bmpContext); + } + }); + // 14. Return aplication and successful response + return res + .status(200) + .json({ status: 'success', data: accessRecord._doc }); + } catch (err) { + console.log(err.message); + res.status(500).json({ status: 'error', message: err }); + } + }, + //POST api/v1/data-access-request/:id submitAccessRequestById: async (req, res) => { try { diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 3a6015c0..153f7492 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -41,6 +41,11 @@ router.put('/:id', passport.authenticate('jwt'), datarequestController.updateAcc // @access Private router.put('/:id/assignworkflow', passport.authenticate('jwt'), datarequestController.assignWorkflow); +// @route PUT api/v1/data-access-request/:id/vote +// @desc Update access request with user vote +// @access Private +router.put('/:id/vote', passport.authenticate('jwt'), datarequestController.updateAccessRequestReviewVote); + // @route POST api/v1/data-access-request/:id // @desc Submit request record // @access Private diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 641c9dc6..98ff072f 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -326,5 +326,26 @@ module.exports = { // Subtract SLA reminder offset let reminderPeriod = deadline - reminderOffset; return `P${reminderPeriod}D`; + }, + + workflowStepContainsManager: (reviewers, team) => { + let managerExists = false; + // 1. Extract team members + let { members } = team; + // 2. Iterate through each reviewer to check if they are a manager of the team + reviewers.forEach(reviewer => { + // 3. Find the current user + let userMember = members.find( + (member) => member.memberid.toString() === reviewer.toString() + ); + // 3. If the user was found check if they are a manager + if (userMember) { + let { roles } = userMember; + if (roles.includes(roleTypes.MANAGER)) { + managerExists = true; + } + } + }) + return managerExists; } }; diff --git a/src/resources/workflow/workflow.model.js b/src/resources/workflow/workflow.model.js index a95532d3..3f89d5cf 100644 --- a/src/resources/workflow/workflow.model.js +++ b/src/resources/workflow/workflow.model.js @@ -24,7 +24,8 @@ const StepSchema = new Schema({ endDateTime: { type: Date }, recommendations: [{ reviewer: { type : Schema.Types.ObjectId, ref: 'User' }, - approved: { type: Boolean } + approved: { type: Boolean }, + comments: { type: String } }] }); From fb357c62f579dcbd566af8a9f8658a1ef618d5c3 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 6 Oct 2020 12:03:37 +0100 Subject: [PATCH 018/144] Removed hard coded value --- src/resources/datarequest/datarequest.controller.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 72138c2c..9dc81a6d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -811,8 +811,7 @@ module.exports = { // 12. Workflow management - construct Camunda payloads let bmpContext = { businessKey: id, - //dataRequestUserId: userId.toString(), - dataRequestUserId: "5ede1713384b64b655b9dd13" + dataRequestUserId: userId.toString() }; if(stepComplete) { accessRecord.workflow.steps[activeStepIndex].active = false; From b7f23bd14f9ed778db1e708a522dd6e7c42cf0c7 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 6 Oct 2020 12:13:24 +0100 Subject: [PATCH 019/144] Fixed LGTM issues and defect --- src/resources/datarequest/datarequest.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 9dc81a6d..fcd15b08 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -675,12 +675,12 @@ module.exports = { }); } else { // 13. Contact Camunda to start workflow process - let { name: publisher } = accessRecord.datasets[0].publisher; + let { name: dataRequestPublisher } = accessRecord.datasets[0].publisher; let reviewerList = workflowObj.steps[0].reviewers.map((reviewer) => reviewer._id.toString()); let bpmContext = { businessKey: id, dataRequestStatus: 'inReview', - dataRequestUserId: userId, + dataRequestUserId: userId.toString(), dataRequestPublisher, dataRequestStepName: workflowObj.steps[0].stepName, notifyReviewerSLA: workflowController.calculateStepDeadlineReminderDate( From 351f01d353021085d889a29d3a088084dae3844f Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 7 Oct 2020 10:55:52 +0100 Subject: [PATCH 020/144] Completed manager override endpoint --- .../bpmnworkflow/bpmnworkflow.controller.js | 12 +- .../datarequest/datarequest.controller.js | 419 +++++++++++++----- .../datarequest/datarequest.model.js | 3 + .../datarequest/datarequest.route.js | 28 +- src/resources/workflow/workflow.controller.js | 42 ++ src/resources/workflow/workflow.model.js | 1 + 6 files changed, 374 insertions(+), 131 deletions(-) diff --git a/src/resources/bpmnworkflow/bpmnworkflow.controller.js b/src/resources/bpmnworkflow/bpmnworkflow.controller.js index 2db5f256..4430e7b6 100644 --- a/src/resources/bpmnworkflow/bpmnworkflow.controller.js +++ b/src/resources/bpmnworkflow/bpmnworkflow.controller.js @@ -105,7 +105,7 @@ module.exports = { }, postStartManagerReview: async (bpmContext) => { // Start manager-review process - let { applicationStatus, managerId, publisher, notifyManager } = bpmContext; + let { applicationStatus, managerId, publisher, notifyManager, taskId } = bpmContext; let data = { "variables": { "applicationStatus": { @@ -133,14 +133,8 @@ module.exports = { }, postManagerApproval: async (bpmContext) => { // Manager has approved sectoin - let { applicationStatus, managerId, publisher } = bpmContext; - let data = { - "dataRequestStatus": applicationStatus, - "dataRequestManagerId": managerId, - "dataRequestPublisher": publisher, - "managerApproved": true - } - await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/manager/completed/${businessKey}`, data) + let { businessKey } = bpmContext; + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/manager/completed/${businessKey}`, bpmContext) .catch((err) => { console.error(err); }) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index fcd15b08..b60cdd0f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -398,21 +398,29 @@ module.exports = { applicationStatusDesc = ''; // 3. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ - path: 'datasets dataset mainApplicant authors', - populate: { - path: 'publisher additionalInfo', + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'datasets dataset mainApplicant authors', populate: { - path: 'team', + path: 'publisher additionalInfo', populate: { - path: 'users', + path: 'team', populate: { - path: 'additionalInfo', + path: 'users', + populate: { + path: 'additionalInfo', + }, }, }, }, }, - }); + { + path: 'publisherObj', + populate: { + path: 'team', + }, + }, + ]); if (!accessRecord) { return res .status(404) @@ -447,8 +455,25 @@ module.exports = { let newAuthors = []; // 6. Extract new application status and desc to save updates - // If custodian, allow updated to application status and description if (userType === userTypes.CUSTODIAN) { + // Only a custodian manager can set the final status of an application + authorised = false; + if (_.has(accessRecord.publisherObj.toObject(), 'team')) { + let { + publisherObj: { team }, + } = accessRecord; + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + team.toObject(), + _id + ); + } + if (!authorised) { + return res + .status(401) + .json({ status: 'failure', message: 'Unauthorised' }); + } + // Extract params from body ({ applicationStatus, applicationStatusDesc } = req.body); const finalStatuses = [ 'submitted', @@ -457,10 +482,7 @@ module.exports = { 'approved with conditions', 'withdrawn', ]; - if ( - applicationStatus && - applicationStatus !== accessRecord.applicationStatus - ) { + if (applicationStatus) { accessRecord.applicationStatus = applicationStatus; if (finalStatuses.includes(applicationStatus)) { @@ -468,11 +490,29 @@ module.exports = { } isDirty = true; statusChange = true; + + // Update any attached workflow in Mongo to show workflow is finished + let { workflow = {} } = accessRecord; + if (!_.isEmpty(workflow)) { + accessRecord.workflow.steps = accessRecord.workflow.steps.map( + (step) => { + let updatedStep = { + ...step.toObject(), + active: false, + }; + if (step.active) { + updatedStep = { + ...updatedStep, + endDateTime: new Date(), + completed: true, + }; + } + return updatedStep; + } + ); + } } - if ( - applicationStatusDesc && - applicationStatusDesc !== accessRecord.applicationStatusDesc - ) { + if (applicationStatusDesc) { accessRecord.applicationStatusDesc = inputSanitizer.removeNonBreakingSpaces( applicationStatusDesc ); @@ -492,7 +532,6 @@ module.exports = { } } } - // 7. If a change has been made, notify custodian and main applicant if (isDirty) { await accessRecord.save(async (err) => { @@ -510,41 +549,30 @@ module.exports = { req.user ); } - // Send notifications to custodian team, main applicant and contributors regarding status change if (statusChange) { + // Send notifications to custodian team, main applicant and contributors regarding status change await module.exports.createNotifications( 'StatusChange', { applicationStatus, applicationStatusDesc }, accessRecord, req.user ); - } - // Update workflow process if publisher requires it - if (accessRecord.datasets[0].publisher.workflowEnabled) { - // Call Camunda controller to get current workflow process for application - let response = await bpmController.getProcess(id); - let { data = {} } = response; - if (!_.isEmpty(data)) { - let [obj] = data; - let { id: taskId } = obj; - - // Call Camunda to update workflow process for application - let { name: publisher } = accessRecord.datasets[0].publisher; - let bmpContext = { - taskId, - dateSubmitted: new Date(), - applicationStatus, - publisher, - actioner: _id, - archived: false, - }; - await bpmController.postUpdateProcess(bmpContext); - } + // Ensure Camunda ends workflow processes given that manager has made final decision + let { + name: dataRequestPublisher, + } = accessRecord.datasets[0].publisher; + let bpmContext = { + dataRequestStatus: applicationStatus, + dataRequestManagerId: _id.toString(), + dataRequestPublisher, + managerApproved: true, + businessKey: id, + }; + bpmController.postManagerApproval(bpmContext); } } }); } - // 8. Return application return res.status(200).json({ status: 'success', @@ -581,7 +609,7 @@ module.exports = { populate: { path: 'publisher additionalInfo', populate: { - path: 'team' + path: 'team', }, }, }); @@ -614,7 +642,9 @@ module.exports = { } // 6. Check publisher allows workflows let workflowEnabled = false; - if (_.has(accessRecord.datasets[0].toObject(), 'publisher.workflowEnabled')) { + if ( + _.has(accessRecord.datasets[0].toObject(), 'publisher.workflowEnabled') + ) { ({ publisher: { workflowEnabled }, } = accessRecord.datasets[0]); @@ -675,8 +705,12 @@ module.exports = { }); } else { // 13. Contact Camunda to start workflow process - let { name: dataRequestPublisher } = accessRecord.datasets[0].publisher; - let reviewerList = workflowObj.steps[0].reviewers.map((reviewer) => reviewer._id.toString()); + let { + name: dataRequestPublisher, + } = accessRecord.datasets[0].publisher; + let reviewerList = workflowObj.steps[0].reviewers.map((reviewer) => + reviewer._id.toString() + ); let bpmContext = { businessKey: id, dataRequestStatus: 'inReview', @@ -686,7 +720,7 @@ module.exports = { notifyReviewerSLA: workflowController.calculateStepDeadlineReminderDate( workflowObj.steps[0] ), - reviewerList + reviewerList, }; bpmController.postStartStepReview(bpmContext); // 14. TODO Create notifications for workflow assigned (step 1 reviewers and other managers) @@ -705,6 +739,89 @@ module.exports = { } }, + //PUT api/v1/data-access-request/:id/startreview + updateAccessRequestStartReview: async (req, res) => { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + let { _id: userId } = req.user; + // 2. Retrieve DAR from database + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ + path: 'publisherObj', + populate: { + path: 'team', + }, + }); + if (!accessRecord) { + return res + .status(404) + .json({ status: 'error', message: 'Application not found.' }); + } + // 3. Check permissions of user is reviewer of associated team + let authorised = false; + if (_.has(accessRecord.toObject(), 'publisherObj.team')) { + let { team } = accessRecord.publisherObj; + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + team.toObject(), + userId + ); + } + // 4. Refuse access if not authorised + if (!authorised) { + return res + .status(401) + .json({ status: 'failure', message: 'Unauthorised' }); + } + // 5. Check application is in submitted state + let { applicationStatus } = accessRecord; + if (applicationStatus !== 'submitted') { + return res.status(400).json({ + success: false, + message: + 'The application status must be set to submitted to start a review', + }); + } + // 6. Update application to 'in review' + accessRecord.applicationStatus = 'inReview'; + accessRecord.dateReviewStart = new Date(); + // 7. Save update to access record + await accessRecord.save(async (err) => { + if (err) { + console.error(err); + res.status(500).json({ status: 'error', message: err }); + } else { + // 8. Call Camunda controller to get pre-review process + let response = await bpmController.getProcess(id); + let { data = {} } = response; + if (!_.isEmpty(data)) { + let [obj] = data; + let { id: taskId } = obj; + let { + publisherObj: { name }, + } = accessRecord; + let bpmContext = { + taskId, + applicationStatus, + managerId: userId.toString(), + publisher: name, + notifyManager: 'P999D', + }; + // 9. Call Camunda controller to start manager review process + bpmController.postStartManagerReview(bpmContext); + } + } + }); + // 14. Return aplication and successful response + return res.status(200).json({ status: 'success' }); + } catch (err) { + console.log(err.message); + res.status(500).json({ status: 'error', message: err }); + } + }, + //PUT api/v1/data-access-request/:id/vote updateAccessRequestReviewVote: async (req, res) => { try { @@ -717,15 +834,14 @@ module.exports = { if (_.isUndefined(approved) || _.isEmpty(comments)) { return res.status(400).json({ success: false, - message: - 'You must supply the approved status with a reason', + message: 'You must supply the approved status with a reason', }); } // 2. Retrieve DAR from database let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ path: 'publisherObj', populate: { - path: 'team' + path: 'team', }, }); if (!accessRecord) { @@ -736,9 +852,7 @@ module.exports = { // 3. Check permissions of user is reviewer of associated team let authorised = false; if (_.has(accessRecord.toObject(), 'publisherObj.team')) { - let { - team - } = accessRecord.publisherObj; + let { team } = accessRecord.publisherObj; authorised = teamController.checkTeamPermissions( teamController.roleTypes.REVIEWER, team.toObject(), @@ -762,7 +876,7 @@ module.exports = { } // 6. Ensure a workflow has been attached to this application let { workflow } = accessRecord; - if(!workflow) { + if (!workflow) { return res.status(400).json({ success: false, message: @@ -772,26 +886,24 @@ module.exports = { // 7. Ensure the requesting user is expected to cast a vote let { steps } = workflow; let activeStepIndex = steps.findIndex((step) => { - return step.active === true; - }) - if(!steps[activeStepIndex].reviewers.includes(userId)) { + return step.active === true; + }); + if (!steps[activeStepIndex].reviewers.includes(userId)) { return res.status(400).json({ success: false, - message: - 'You have not been assigned to vote on this review phase', + message: 'You have not been assigned to vote on this review phase', }); } //8. Ensure the requesting user has not already voted let { recommendations = [] } = steps[activeStepIndex]; - if(recommendations) { + if (recommendations) { let found = recommendations.some((rec) => { return rec.reviewer.equals(userId); }); - if(found) { + if (found) { return res.status(400).json({ success: false, - message: - 'You have already voted on this review phase', + message: 'You have already voted on this review phase', }); } } @@ -799,62 +911,44 @@ module.exports = { let newRecommendation = { approved, comments, - reviewer: new mongoose.Types.ObjectId(userId) - } - // 10. Update access record with recommendation - accessRecord.workflow.steps[activeStepIndex].recommendations = [...accessRecord.workflow.steps[activeStepIndex].recommendations, newRecommendation]; - // 11. Check if access record now has the required number of reviewers and set completed state for phase - const requiredReviews = accessRecord.workflow.steps[activeStepIndex].reviewers.length; - const completedReviews = accessRecord.workflow.steps[activeStepIndex].recommendations.length; - const stepComplete = completedReviews === requiredReviews; - const finalStep = activeStepIndex === accessRecord.workflow.steps.length -1; - // 12. Workflow management - construct Camunda payloads - let bmpContext = { - businessKey: id, - dataRequestUserId: userId.toString() + reviewer: new mongoose.Types.ObjectId(userId), }; - if(stepComplete) { + // 10. Update access record with recommendation + accessRecord.workflow.steps[activeStepIndex].recommendations = [ + ...accessRecord.workflow.steps[activeStepIndex].recommendations, + newRecommendation, + ]; + // 11. Workflow management - construct Camunda payloads + let bpmContext = workflowController.buildNextStep( + userId, + accessRecord, + activeStepIndex, + false + ); + // 12. If step is now complete, update database record + if (bpmContext.stepComplete) { accessRecord.workflow.steps[activeStepIndex].active = false; + accessRecord.workflow.steps[activeStepIndex].completed = true; accessRecord.workflow.steps[activeStepIndex].endDateTime = new Date(); - if(finalStep) { - // Move into final review phase (Camunda) set up payload - bmpContext = { - ...bmpContext, - finalPhaseApproved: true - }; - } else { - // Move to next step - accessRecord.workflow.steps[activeStepIndex+1].active = true; - accessRecord.workflow.steps[activeStepIndex+1].startDateTime = new Date(); - // Get details for next step - let { name: dataRequestPublisher } = accessRecord.publisherObj; - let nextStep = accessRecord.workflow.steps[activeStepIndex+1]; - let reviewerList = nextStep.reviewers.map((reviewer) => reviewer._id.toString()); - let { stepName: dataRequestStepName } = nextStep; - // Create payload for Camunda - bmpContext = { - ...bmpContext, - dataRequestPublisher, - dataRequestStepName, - notifyReviewerSLA: workflowController.calculateStepDeadlineReminderDate( - nextStep - ), - phaseApproved: true, - reviewerList - }; - } } - // 13. Update MongoDb record for DAR + // 13. If it was not the final phase that was completed, move to next step in database + if (!bpmContext.finalPhaseApproved) { + accessRecord.workflow.steps[activeStepIndex + 1].active = true; + accessRecord.workflow.steps[ + activeStepIndex + 1 + ].startDateTime = new Date(); + } + // 14. Update MongoDb record for DAR await accessRecord.save(async (err) => { if (err) { console.error(err); res.status(500).json({ status: 'error', message: err }); } else { // Call Camunda controller to update workflow process - bpmController.postCompleteReview(bmpContext); + bpmController.postCompleteReview(bpmContext); } }); - // 14. Return aplication and successful response + // 15. Return aplication and successful response return res .status(200) .json({ status: 'success', data: accessRecord._doc }); @@ -864,6 +958,105 @@ module.exports = { } }, + //PUT api/v1/data-access-request/:id/stepoverride + updateAccessRequestStepOverride: async (req, res) => { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + let { _id: userId } = req.user; + // 2. Retrieve DAR from database + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ + path: 'publisherObj', + populate: { + path: 'team', + }, + }); + if (!accessRecord) { + return res + .status(404) + .json({ status: 'error', message: 'Application not found.' }); + } + // 3. Check permissions of user is reviewer of associated team + let authorised = false; + if (_.has(accessRecord.toObject(), 'publisherObj.team')) { + let { team } = accessRecord.publisherObj; + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + team.toObject(), + userId + ); + } + // 4. Refuse access if not authorised + if (!authorised) { + return res + .status(401) + .json({ status: 'failure', message: 'Unauthorised' }); + } + // 5. Check application is in review state + let { applicationStatus } = accessRecord; + if (applicationStatus !== 'inReview') { + return res.status(400).json({ + success: false, + message: 'The application status must be set to in review', + }); + } + // 6. Check a workflow is assigned with valid steps + let { workflow = {} } = accessRecord; + let { steps = [] } = workflow; + if (_.isEmpty(workflow) || _.isEmpty(steps)) { + return res.status(400).json({ + success: false, + message: 'A valid workflow has not been attached to this application', + }); + } + // 7. Get the attached active workflow step + let activeStepIndex = steps.findIndex((step) => { + return step.active === true; + }); + if (activeStepIndex === -1) { + return res.status(400).json({ + success: false, + message: 'There is no active step to override for this workflow', + }); + } + // 8. Update the step to be completed closing off end date/time + accessRecord.workflow.steps[activeStepIndex].active = false; + accessRecord.workflow.steps[activeStepIndex].completed = true; + accessRecord.workflow.steps[activeStepIndex].endDateTime = new Date(); + // 9. Set up Camunda payload + let bpmContext = workflowController.buildNextStep( + userId, + accessRecord, + activeStepIndex, + true + ); + // 10. If it was not the final phase that was completed, move to next step + if (!bpmContext.finalPhaseApproved) { + accessRecord.workflow.steps[activeStepIndex + 1].active = true; + accessRecord.workflow.steps[ + activeStepIndex + 1 + ].startDateTime = new Date(); + } + // 11. Save changes to the DAR + await accessRecord.save(async (err) => { + if (err) { + console.error(err); + res.status(500).json({ status: 'error', message: err }); + } else { + // 12. Call Camunda controller to start manager review process + bpmController.postCompleteReview(bpmContext); + } + }); + // 13. Return aplication and successful response + return res.status(200).json({ status: 'success' }); + } catch (err) { + console.log(err.message); + res.status(500).json({ status: 'error', message: err }); + } + }, + //POST api/v1/data-access-request/:id submitAccessRequestById: async (req, res) => { try { @@ -873,7 +1066,7 @@ module.exports = { } = req; // 2. Find the relevant data request application let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ - path: 'datasets dataset mainApplicant authors', + path: 'datasets dataset mainApplicant authors publisherObj', populate: { path: 'publisher additionalInfo', populate: { @@ -921,16 +1114,16 @@ module.exports = { // Start workflow process if publisher requires it if (accessRecord.datasets[0].publisher.workflowEnabled) { // Call Camunda controller to start workflow for submitted application - let { name: publisher } = accessRecord.datasets[0].publisher; - let { _id: userId } = req.user; - let bmpContext = { + let { + publisherObj: { name: publisher }, + } = accessRecord; + let bpmContext = { dateSubmitted, applicationStatus: 'submitted', publisher, businessKey: id, - actioner: userId, }; - bpmController.postCreateProcess(bmpContext); + bpmController.postStartPreReview(bpmContext); } } }); diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 21b59565..5a1b1c81 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -38,6 +38,9 @@ const DataRequestSchema = new Schema({ dateFinalStatus: { type: Date }, + dateReviewStart: { + type: Date + }, publisher: { type: String, default: "" diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 153f7492..fffc0195 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -8,47 +8,57 @@ const router = express.Router(); // @route GET api/v1/data-access-request // @desc GET Access requests for user -// @access Private +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer router.get('/', passport.authenticate('jwt'), datarequestController.getAccessRequestsByUser); // @route GET api/v1/data-access-request/:requestId // @desc GET a single data access request by Id -// @access Private +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer router.get('/:requestId', passport.authenticate('jwt'), datarequestController.getAccessRequestById); // @route GET api/v1/data-access-request/dataset/:datasetId // @desc GET Access request for user -// @access Private +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer router.get('/dataset/:dataSetId', passport.authenticate('jwt'), datarequestController.getAccessRequestByUserAndDataset); // @route GET api/v1/data-access-request/datasets/:datasetIds // @desc GET Access request with multiple datasets for user -// @access Private +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer router.get('/datasets/:datasetIds', passport.authenticate('jwt'), datarequestController.getAccessRequestByUserAndMultipleDatasets); // @route PATCH api/v1/data-access-request/:id // @desc Update application passing single object to update database entry with specified key -// @access Private +// @access Private - Applicant (Gateway User) router.patch('/:id', passport.authenticate('jwt'), datarequestController.updateAccessRequestDataElement); // @route PUT api/v1/data-access-request/:id // @desc Update request record by Id for status changes -// @access Private +// @access Private - Custodian Manager and Applicant (Gateway User) router.put('/:id', passport.authenticate('jwt'), datarequestController.updateAccessRequestById); // @route PUT api/v1/data-access-request/:id/assignworkflow // @desc Update access request workflow -// @access Private +// @access Private - Custodian Manager router.put('/:id/assignworkflow', passport.authenticate('jwt'), datarequestController.assignWorkflow); // @route PUT api/v1/data-access-request/:id/vote // @desc Update access request with user vote -// @access Private +// @access Private - Custodian Reviewer/Manager router.put('/:id/vote', passport.authenticate('jwt'), datarequestController.updateAccessRequestReviewVote); +// @route PUT api/v1/data-access-request/:id/startreview +// @desc Update access request with review started +// @access Private - Custodian Manager +router.put('/:id/startreview', passport.authenticate('jwt'), datarequestController.updateAccessRequestStartReview); + +// @route PUT api/v1/data-access-request/:id/stepoverride +// @desc Update access request with current step overriden (manager ends current phase regardless of votes cast) +// @access Private - Custodian Manager +router.put('/:id/stepoverride', passport.authenticate('jwt'), datarequestController.updateAccessRequestStepOverride); + // @route POST api/v1/data-access-request/:id // @desc Submit request record -// @access Private +// @access Private - Applicant (Gateway User) router.post('/:id', passport.authenticate('jwt'), datarequestController.submitAccessRequestById); module.exports = router; \ No newline at end of file diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 98ff072f..4bb3dd0b 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -347,5 +347,47 @@ module.exports = { } }) return managerExists; + }, + + buildNextStep: (userId, application, activeStepIndex, override) => { + // Check the current position of the application within its assigned workflow + const finalStep = activeStepIndex === application.workflow.steps.length -1; + const requiredReviews = application.workflow.steps[activeStepIndex].reviewers.length; + const completedReviews = application.workflow.steps[activeStepIndex].recommendations.length; + const stepComplete = completedReviews === requiredReviews; + // Establish base payload for Camunda + // (1) phaseApproved is passed as true when the manager is overriding the current step/phase + // this short circuits the review process in the workflow and closes any remaining user tasks + // i.e. reviewers within the active step + // (2) managerApproved is passed as true when the manager is approving the entire application + // from any point within the review process + // (3) finalPhaseApproved is passed as true when the final step is completed naturally through all + // reviewers casting their votes + let bpmContext = { + businessKey: application._id, + dataRequestUserId: userId.toString(), + managerApproved: override, + phaseApproved: (override && !finalStep) || stepComplete, + finalPhaseApproved: finalStep, + stepComplete + } + if(!finalStep) { + // Extract the information for the next step defintion + let { name: dataRequestPublisher } = application.publisherObj; + let nextStep = application.workflow.steps[activeStepIndex+1]; + let reviewerList = nextStep.reviewers.map((reviewer) => reviewer._id.toString()); + let { stepName: dataRequestStepName } = nextStep; + // Update Camunda payload with the next step information + bpmContext = { + ...bpmContext, + dataRequestPublisher, + dataRequestStepName, + notifyReviewerSLA: module.exports.calculateStepDeadlineReminderDate( + nextStep + ), + reviewerList + }; + } + return bpmContext; } }; diff --git a/src/resources/workflow/workflow.model.js b/src/resources/workflow/workflow.model.js index 3f89d5cf..044e84e6 100644 --- a/src/resources/workflow/workflow.model.js +++ b/src/resources/workflow/workflow.model.js @@ -20,6 +20,7 @@ const StepSchema = new Schema({ reminderOffset: { type: Number, required: true, default: 3 }, // Number of days before deadline that SLAs are triggered by Camunda // Items below not required for step definition active: { type: Boolean, default: false }, + completed: { type: Boolean, default: false }, startDateTime: { type: Date }, endDateTime: { type: Date }, recommendations: [{ From 1fe261a5f3c76d4a3fb97d86ea668a8f1585422e Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 7 Oct 2020 11:39:53 +0100 Subject: [PATCH 021/144] Fixed comment on workflow variables --- src/resources/workflow/workflow.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 4bb3dd0b..8acf75b9 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -358,7 +358,7 @@ module.exports = { // Establish base payload for Camunda // (1) phaseApproved is passed as true when the manager is overriding the current step/phase // this short circuits the review process in the workflow and closes any remaining user tasks - // i.e. reviewers within the active step + // i.e. reviewers within the active step OR when the last reviewer in the step submits a vote // (2) managerApproved is passed as true when the manager is approving the entire application // from any point within the review process // (3) finalPhaseApproved is passed as true when the final step is completed naturally through all From bc28803673792f99f107cf63fe2a60fde8d5ea44 Mon Sep 17 00:00:00 2001 From: Alex Power Date: Wed, 7 Oct 2020 17:55:29 +0100 Subject: [PATCH 022/144] WIP --- .../datarequest/datarequest.controller.js | 72 ++++++++++++++++++- .../publisher/publisher.controller.js | 13 +++- src/resources/team/team.controller.js | 4 ++ 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b60cdd0f..c61eb715 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1498,8 +1498,74 @@ module.exports = { }, createApplicationDTO: (app) => { - let projectName = ''; - let applicants = ''; + let projectName = "", + applicants = "", + workflowName = "", + workflowCompleted = false, + reviewStatus = "", + deadlinePassed = false, + activeStepName = "", + remainingActioners = [], + decisionDuration = "", + managerUsers = []; + + // Check if the application has a workflow assigned + let { workflow = {}, applicationStatus } = app; + if(_.has(app, 'publisherObj.team.members')) { + let { publisherObj: { team: { members, users }}} = app; + let managers = members.filter(mem => { + return mem.roles.includes('manager'); + }); + managerUsers = users.filter( + (user) => + managers.some( + (manager) => manager.memberid.toString() === user._id.toString() + ) + ); + if(applicationStatus === 'submitted') { + remainingActioners = managerUsers; + } + }; + if(!_.isEmpty(workflow)) { + ({ workflowName } = workflow); + let { steps } = workflow; + workflowCompleted = steps.every((step) => { return step.completed }); + let activeStep = steps.find((step) => { + return step.active; + }); + // Calculate active step status + if(activeStep) { + ({ stepName: activeStepName } = activeStep); + //Get deadline status + let { deadline, startDateTime } = activeStep; + let deadlineDate = moment(startDateTime).add(deadline, "days"); + let diff = parseInt(deadlineDate.diff(new Date(), "days")); + if (diff > 0) { + reviewStatus = `Deadline in ${diff} days`; + } else if (diff < 0) { + reviewStatus = `Deadline was ${Math.abs(diff)} days ago`; + deadlinePassed = true; + } else { + reviewStatus = `Deadline is today`; + } + //Remaining Actioners + let { reviewers = [], recommendations = [] } = activeStep; + remainingActioners = reviewers.filter( + (reviewer) => + !recommendations.some( + (rec) => rec.reviewer.toString() === reviewer.toString() + ) + ); + } else if(_.isUndefined(activeStep) && applicationStatus === 'inReview') { + reviewStatus = 'Final decision required'; + remainingActioners = managerUsers; + } + // Get decision duration if completed + let { dateFinalStatus, dateSubmitted } = app; + if(dateFinalStatus) { + decisionDuration = parseInt(moment(dateFinalStatus).diff(dateSubmitted, 'days')); + } + } // Ensure backward compatibility with old single dataset DARs if (_.isEmpty(app.datasets) || _.isUndefined(app.datasets)) { @@ -1529,7 +1595,7 @@ module.exports = { let { firstname, lastname } = app.mainApplicant; applicants = `${firstname} ${lastname}`; } - return { ...app, projectName, applicants, publisher }; + return { ...app, projectName, applicants, publisher, workflowName, workflowCompleted, reviewStatus, activeStepName, remainingActioners, decisionDuration, deadlinePassed }; }, calculateAvgDecisionTime: (applications) => { diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index b2634e73..43973ce6 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -129,7 +129,18 @@ module.exports = { ], }) .sort({ updatedAt: -1 }) - .populate("datasets dataset mainApplicant"); + .populate([{ + path: 'datasets dataset mainApplicant' + }, { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users', + select: 'firstname lastname' + } + } + }]); if (!isManager) { applications = applications.filter((app) => { diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index 1a1a2b5a..b3a41e70 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -103,4 +103,8 @@ module.exports = { } return false; }, + + findTeamManagers: () => { + + } }; From faf13b74a70ebce089e7975e255b1acc86f11951 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 7 Oct 2020 22:43:57 +0100 Subject: [PATCH 023/144] Modified application endpoint to return review mode and sections for user --- .../datarequest/datarequest.controller.js | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b60cdd0f..0c6663eb 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -124,7 +124,12 @@ module.exports = { ) { readOnly = false; } - // 7. Return application form + // 7. Set the review mode if user is a custodian reviewing the current step + let { inReviewMode, reviewSections } = module.exports.getReviewStatus( + accessRecord, + req.user._id + ); + // 8. Return application form return res.status(200).json({ status: 'success', data: { @@ -138,6 +143,8 @@ module.exports = { projectId: accessRecord.projectId || helper.generateFriendlyId(accessRecord._id), + inReviewMode, + reviewSections, }, }); } catch (err) { @@ -234,6 +241,8 @@ module.exports = { dataset, projectId: data.projectId || helper.generateFriendlyId(data._id), userType: 'applicant', + inReviewMode: false, + reviewSections: [], }, }); } catch (err) { @@ -331,6 +340,8 @@ module.exports = { datasets, projectId: data.projectId || helper.generateFriendlyId(data._id), userType: 'applicant', + inReviewMode: false, + reviewSections: [], }, }); } catch (err) { @@ -1557,4 +1568,33 @@ module.exports = { } return 0; }, + + getReviewStatus: (application, userId) => { + let inReviewMode = false, + reviewSections = [], + isActiveStepReviewer = false; + // Get current application status + let { applicationStatus } = application; + // Check if the current user is a reviewer on the current step of an attached workflow + let { workflow = {} } = application; + if (!_.isEmpty(workflow)) { + let { steps } = workflow; + let activeStep = steps.find((step) => { + return step.active === true; + }); + if (activeStep) { + isActiveStepReviewer = activeStep.reviewers.some( + (reviewer) => reviewer.toString() === userId.toString() + ); + reviewSections = [...activeStep.sections]; + } + } + // Return active review mode if conditions apply + if (applicationStatus === 'inReview' && isActiveStepReviewer) { + inReviewMode = true; + reviewSections; + } + + return { inReviewMode, reviewSections }; + }, }; From 5a0defcdbf11e32e212de3676723a98f83220003 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 8 Oct 2020 14:40:24 +0100 Subject: [PATCH 024/144] Started additions to response to get voting status --- .../datarequest/datarequest.controller.js | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 0c6663eb..695a480d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -129,7 +129,12 @@ module.exports = { accessRecord, req.user._id ); - // 8. Return application form + // 8. Get the workflow/voting status + let workflow = module.exports.getWorkflowStatus( + accessRecord + ); + + // 9. Return application form return res.status(200).json({ status: 'success', data: { @@ -145,6 +150,7 @@ module.exports = { helper.generateFriendlyId(accessRecord._id), inReviewMode, reviewSections, + workflow }, }); } catch (err) { @@ -1597,4 +1603,18 @@ module.exports = { return { inReviewMode, reviewSections }; }, + + getWorkflowStatus: (application) => { + let { workflow = {} } = application; + if(!_.isEmpty(workflow)) { + let { workflowName, steps } = workflow; + workflow = { + workflowName, + steps, + completed + }; + } + + return workflow; + } }; From bc24a73c98e8c22275b57fa54b57abb94d5194e6 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 8 Oct 2020 16:13:03 +0100 Subject: [PATCH 025/144] Finished modifications to return voting data --- .../datarequest/datarequest.controller.js | 172 +++++++++++------- 1 file changed, 108 insertions(+), 64 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 31fa0428..b78b923f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -130,9 +130,7 @@ module.exports = { req.user._id ); // 8. Get the workflow/voting status - let workflow = module.exports.getWorkflowStatus( - accessRecord - ); + let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); // 9. Return application form return res.status(200).json({ @@ -150,7 +148,7 @@ module.exports = { helper.generateFriendlyId(accessRecord._id), inReviewMode, reviewSections, - workflow + workflow, }, }); } catch (err) { @@ -1515,72 +1513,56 @@ module.exports = { }, createApplicationDTO: (app) => { - let projectName = "", - applicants = "", - workflowName = "", + let projectName = '', + applicants = '', + workflowName = '', workflowCompleted = false, - reviewStatus = "", - deadlinePassed = false, - activeStepName = "", + reviewStatus = '', remainingActioners = [], - decisionDuration = "", - managerUsers = []; + decisionDuration = '', + managerUsers = [], + activeStepStatus = {}; // Check if the application has a workflow assigned let { workflow = {}, applicationStatus } = app; - if(_.has(app, 'publisherObj.team.members')) { - let { publisherObj: { team: { members, users }}} = app; - let managers = members.filter(mem => { + if (_.has(app, 'publisherObj.team.members')) { + let { + publisherObj: { + team: { members, users }, + }, + } = app; + let managers = members.filter((mem) => { return mem.roles.includes('manager'); }); - managerUsers = users.filter( - (user) => - managers.some( - (manager) => manager.memberid.toString() === user._id.toString() - ) + managerUsers = users.filter((user) => + managers.some( + (manager) => manager.memberid.toString() === user._id.toString() + ) ); - if(applicationStatus === 'submitted') { + if (applicationStatus === 'submitted') { remainingActioners = managerUsers; } - }; - if(!_.isEmpty(workflow)) { - ({ workflowName } = workflow); - let { steps } = workflow; - workflowCompleted = steps.every((step) => { return step.completed }); - let activeStep = steps.find((step) => { - return step.active; - }); - // Calculate active step status - if(activeStep) { - ({ stepName: activeStepName } = activeStep); - //Get deadline status - let { deadline, startDateTime } = activeStep; - let deadlineDate = moment(startDateTime).add(deadline, "days"); - let diff = parseInt(deadlineDate.diff(new Date(), "days")); - if (diff > 0) { - reviewStatus = `Deadline in ${diff} days`; - } else if (diff < 0) { - reviewStatus = `Deadline was ${Math.abs(diff)} days ago`; - deadlinePassed = true; - } else { - reviewStatus = `Deadline is today`; + if (!_.isEmpty(workflow)) { + ({ workflowName } = workflow); + workflowCompleted = module.exports.getWorkflowCompleted(workflow); + let activeStep = module.exports.getActiveWorkflowStep(workflow); + // Calculate active step status + if (activeStep) { + activeStepStatus = module.exports.getActiveStepStatus(activeStep, users); + } else if ( + _.isUndefined(activeStep) && + applicationStatus === 'inReview' + ) { + activeStepStatus.reviewStatus = 'Final decision required'; + remainingActioners = [...managerUsers]; + } + // Get decision duration if completed + let { dateFinalStatus, dateSubmitted } = app; + if (dateFinalStatus) { + decisionDuration = parseInt( + moment(dateFinalStatus).diff(dateSubmitted, 'days') + ); } - //Remaining Actioners - let { reviewers = [], recommendations = [] } = activeStep; - remainingActioners = reviewers.filter( - (reviewer) => - !recommendations.some( - (rec) => rec.reviewer.toString() === reviewer.toString() - ) - ); - } else if(_.isUndefined(activeStep) && applicationStatus === 'inReview') { - reviewStatus = 'Final decision required'; - remainingActioners = managerUsers; - } - // Get decision duration if completed - let { dateFinalStatus, dateSubmitted } = app; - if(dateFinalStatus) { - decisionDuration = parseInt(moment(dateFinalStatus).diff(dateSubmitted, 'days')); } } @@ -1612,7 +1594,17 @@ module.exports = { let { firstname, lastname } = app.mainApplicant; applicants = `${firstname} ${lastname}`; } - return { ...app, projectName, applicants, publisher, workflowName, workflowCompleted, reviewStatus, activeStepName, remainingActioners, decisionDuration, deadlinePassed }; + return { + ...app, + projectName, + applicants, + publisher, + workflowName, + workflowCompleted, + decisionDuration, + remainingActioners, + ...activeStepStatus, + }; }, calculateAvgDecisionTime: (applications) => { @@ -1671,16 +1663,68 @@ module.exports = { }, getWorkflowStatus: (application) => { + let workflowStatus = {}; let { workflow = {} } = application; - if(!_.isEmpty(workflow)) { + if (!_.isEmpty(workflow)) { let { workflowName, steps } = workflow; - workflow = { + // Find the active step in steps + let activeStep = module.exports.getActiveWorkflowStep(workflow); + let activeStepIndex = steps.findIndex((step) => { + return step.active === true; + }); + if(activeStep) { + let { reviewStatus } = module.exports.getActiveStepStatus(activeStep); + //Update active step with review status + steps[activeStepIndex] = { + ...steps[activeStepIndex], + reviewStatus + } + } + workflowStatus = { workflowName, steps, - completed + isCompleted: module.exports.getWorkflowCompleted(workflow), }; } + return workflowStatus; + }, + + getWorkflowCompleted: (workflow) => { + let { steps } = workflow; + return steps.every((step) => step.completed); + }, - return workflow; + getActiveStepStatus: (activeStep, users = []) => { + let reviewStatus = '', deadlinePassed = false, remainingActioners = []; + let { stepName, deadline, startDateTime, reviewers = [], recommendations = [] } = activeStep; + let deadlineDate = moment(startDateTime).add(deadline, 'days'); + let diff = parseInt(deadlineDate.diff(new Date(), 'days')); + if (diff > 0) { + reviewStatus = `Deadline in ${diff} days`; + } else if (diff < 0) { + reviewStatus = `Deadline was ${Math.abs(diff)} days ago`; + deadlinePassed = true; + } else { + reviewStatus = `Deadline is today`; + } + remainingActioners = reviewers.filter( + (reviewer) => + !recommendations.some( + (rec) => rec.reviewer.toString() === reviewer.toString() + ) + ); + remainingActioners = users.filter((user) => + remainingActioners.some( + (actioner) => actioner.toString() === user._id.toString() + ) + ); + return { stepName, remainingActioners, deadlinePassed, reviewStatus }; + }, + + getActiveWorkflowStep: (workflow) => { + let { steps } = workflow; + return steps.find((step) => { + return step.active; + }); } }; From a7e533ec9997648617764e0552db4602561a01d6 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 9 Oct 2020 11:45:12 +0100 Subject: [PATCH 026/144] Added action by required output to application --- .../datarequest/datarequest.controller.js | 54 ++++++++++++------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b78b923f..704cfe31 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1517,11 +1517,12 @@ module.exports = { applicants = '', workflowName = '', workflowCompleted = false, - reviewStatus = '', remainingActioners = [], decisionDuration = '', managerUsers = [], - activeStepStatus = {}; + stepName = '', + deadlinePassed = '', + reviewStatus = '' // Check if the application has a workflow assigned let { workflow = {}, applicationStatus } = app; @@ -1538,9 +1539,11 @@ module.exports = { managers.some( (manager) => manager.memberid.toString() === user._id.toString() ) - ); + ).map((user) => { + return `${user.firstname} ${user.lastname}`; + }); if (applicationStatus === 'submitted') { - remainingActioners = managerUsers; + remainingActioners = managerUsers.join(', '); } if (!_.isEmpty(workflow)) { ({ workflowName } = workflow); @@ -1548,13 +1551,16 @@ module.exports = { let activeStep = module.exports.getActiveWorkflowStep(workflow); // Calculate active step status if (activeStep) { - activeStepStatus = module.exports.getActiveStepStatus(activeStep, users); + ({stepName = '', remainingActioners = [], deadlinePassed = '', reviewStatus = ''} = module.exports.getActiveStepStatus( + activeStep, + users + )); } else if ( _.isUndefined(activeStep) && applicationStatus === 'inReview' ) { - activeStepStatus.reviewStatus = 'Final decision required'; - remainingActioners = [...managerUsers]; + reviewStatus = 'Final decision required'; + remainingActioners = managerUsers.join(', '); } // Get decision duration if completed let { dateFinalStatus, dateSubmitted } = app; @@ -1603,7 +1609,9 @@ module.exports = { workflowCompleted, decisionDuration, remainingActioners, - ...activeStepStatus, + stepName, + deadlinePassed, + reviewStatus }; }, @@ -1672,13 +1680,13 @@ module.exports = { let activeStepIndex = steps.findIndex((step) => { return step.active === true; }); - if(activeStep) { + if (activeStep) { let { reviewStatus } = module.exports.getActiveStepStatus(activeStep); //Update active step with review status steps[activeStepIndex] = { ...steps[activeStepIndex], - reviewStatus - } + reviewStatus, + }; } workflowStatus = { workflowName, @@ -1695,8 +1703,16 @@ module.exports = { }, getActiveStepStatus: (activeStep, users = []) => { - let reviewStatus = '', deadlinePassed = false, remainingActioners = []; - let { stepName, deadline, startDateTime, reviewers = [], recommendations = [] } = activeStep; + let reviewStatus = '', + deadlinePassed = false, + remainingActioners = []; + let { + stepName, + deadline, + startDateTime, + reviewers = [], + recommendations = [], + } = activeStep; let deadlineDate = moment(startDateTime).add(deadline, 'days'); let diff = parseInt(deadlineDate.diff(new Date(), 'days')); if (diff > 0) { @@ -1715,10 +1731,12 @@ module.exports = { ); remainingActioners = users.filter((user) => remainingActioners.some( - (actioner) => actioner.toString() === user._id.toString() - ) - ); - return { stepName, remainingActioners, deadlinePassed, reviewStatus }; + (actioner) => actioner.toString() === user._id.toString() + ) + ).map((user) => { + return `${user.firstname} ${user.lastname}`; + }); + return { stepName, remainingActioners: remainingActioners.join(', '), deadlinePassed, reviewStatus }; }, getActiveWorkflowStep: (workflow) => { @@ -1726,5 +1744,5 @@ module.exports = { return steps.find((step) => { return step.active; }); - } + }, }; From 34d10bf18bbb0306a3ab7d4f4e5baddc5107c8b1 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Fri, 9 Oct 2020 12:00:21 +0100 Subject: [PATCH 027/144] IG-691 - Code to create a PID for datasets and link them to other dataset versions with in our DB --- package.json | 1 + src/resources/dataset/dataset.route.js | 33 +++++++++++ src/resources/dataset/dataset.service.js | 72 +++++++++++++++++++++++- src/resources/tool/data.model.js | 3 + 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9fe4d1ed..db22f53a 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "snyk": "^1.334.0", "swagger-ui-express": "^4.1.4", "test": "^0.6.0", + "uuid": "^8.3.1", "yamljs": "^0.3.0" }, "devDependencies": { diff --git a/src/resources/dataset/dataset.route.js b/src/resources/dataset/dataset.route.js index c56a9500..01db488f 100644 --- a/src/resources/dataset/dataset.route.js +++ b/src/resources/dataset/dataset.route.js @@ -22,6 +22,39 @@ router.post('/', async (req, res) => { return res.json({ success: true, message: "Caching started" }); }); +// @router GET /api/v1/datasets/pidList +// @desc Returns List of PIDs with linked datasetIDs +// @access Public +router.get( + '/pidList/', + async (req, res) => { + var q = Data.find( + { "type" : "dataset", "pid" : { "$exists" : true } }, + { "pid" : 1, "datasetid" : 1 } + ).sort({ "pid" : 1 }); + + q.exec((err, data) => { + var listOfPIDs = [] + + data.forEach((item) => { + if (listOfPIDs.find(x => x.pid === item.pid)) { + var index = listOfPIDs.findIndex(x => x.pid === item.pid) + listOfPIDs[index].datasetIds.push(item.datasetid) + } + else { + listOfPIDs.push({"pid":item.pid, "datasetIds":[item.datasetid]}) + } + + }) + + return res.json({ success: true, data: listOfPIDs }); + }) + } +); + + + + router.get('/:datasetID', async (req, res) => { var q = Data.aggregate([ { $match: { $and: [{ datasetid: req.params.datasetID }] } } diff --git a/src/resources/dataset/dataset.service.js b/src/resources/dataset/dataset.service.js index 247dc0b1..4b6fa0f8 100644 --- a/src/resources/dataset/dataset.service.js +++ b/src/resources/dataset/dataset.service.js @@ -2,6 +2,7 @@ import { Data } from '../tool/data.model' import { MetricsData } from '../stats/metrics.model' import axios from 'axios'; import emailGenerator from '../utilities/emailGenerator.util'; +import { v4 as uuidv4 } from 'uuid'; export async function loadDataset(datasetID) { var metadataCatalogueLink = process.env.metadataURL || 'https://metadata-catalogue.org/hdruk'; @@ -160,6 +161,7 @@ export async function loadDatasets(override) { const metadataQualityList = await axios.get('https://raw.githubusercontent.com/HDRUK/datasets/master/reports/metadata_quality.json', { timeout:5000 }).catch(err => { console.log('Unable to get metadata quality value '+err.message) }); const phenotypesList = await axios.get('https://raw.githubusercontent.com/spiros/hdr-caliber-phenome-portal/master/_data/dataset2phenotypes.json', { timeout:5000 }).catch(err => { console.log('Unable to get phenotypes '+err.message) }); + const dataUtilityList = await axios.get('https://raw.githubusercontent.com/HDRUK/datasets/master/reports/data_utility.json', { timeout:5000 }).catch(err => { console.log('Unable to get data utility '+err.message) }); var datasetsMDCIDs = [] var counter = 0; @@ -172,6 +174,7 @@ export async function loadDatasets(override) { datasetsMDCIDs.push({ datasetid: datasetMDC.id }); const metadataQuality = metadataQualityList.data.find(x => x.id === datasetMDC.id); + const dataUtility = dataUtilityList.data.find(x => x.id === datasetMDC.id); const phenotypes = phenotypesList.data[datasetMDC.id] || []; const metadataSchemaCall = axios.get(metadataCatalogueLink + '/api/profiles/uk.ac.hdrukgateway/HdrUkProfilePluginService/schema.org/'+ datasetMDC.id, { timeout:5000 }).catch(err => { console.log('Unable to get metadata schema '+err.message) }); @@ -223,12 +226,44 @@ export async function loadDatasets(override) { if (datasetHDR) { //Edit + if (!datasetHDR.pid) { + var uuid = uuidv4(); + var listOfVersions =[]; + datasetHDR.pid = uuid; + datasetHDR.datasetVersion = "0.0.1"; + + if (versionLinks && versionLinks.data && versionLinks.data.items && versionLinks.data.items.length > 0) { + versionLinks.data.items.forEach((item) => { + if (!listOfVersions.find(x => x.id === item.source.id)) { + listOfVersions.push({"id":item.source.id, "version":item.source.documentationVersion}); + } + if (!listOfVersions.find(x => x.id === item.target.id)) { + listOfVersions.push({"id":item.target.id, "version":item.target.documentationVersion}); + } + }) + + listOfVersions.forEach(async (item) => { + if (item.id !== datasetMDC.id) { + await Data.findOneAndUpdate({ datasetid: item.id }, + { pid: uuid, datasetVersion: item.version } + ) + } + else { + datasetHDR.pid = uuid; + datasetHDR.datasetVersion = item.version; + } + }) + } + } + var keywordArray = splitString(datasetMDC.keywords) var physicalSampleAvailabilityArray = splitString(datasetMDC.physicalSampleAvailability) var geographicCoverageArray = splitString(datasetMDC.geographicCoverage) await Data.findOneAndUpdate({ datasetid: datasetMDC.id }, { + pid: datasetHDR.pid, + datasetVersion: datasetHDR.datasetVersion, name: datasetMDC.title, description: datasetMDC.description, activeflag: 'active', @@ -254,6 +289,7 @@ export async function loadDatasets(override) { periodicity: datasetMDC.periodicity, metadataquality: metadataQuality ? metadataQuality : {}, + datautility: dataUtility ? dataUtility : {}, metadataschema: metadataSchema && metadataSchema.data ? metadataSchema.data : {}, technicaldetails: technicaldetails, versionLinks: versionLinks && versionLinks.data && versionLinks.data.items ? versionLinks.data.items : [], @@ -264,6 +300,37 @@ export async function loadDatasets(override) { } else { //Add + var uuid = uuidv4(); + var listOfVersions =[]; + var pid = uuid; + var datasetVersion = "0.0.1"; + + if (versionLinks && versionLinks.data && versionLinks.data.items && versionLinks.data.items.length > 0) { + versionLinks.data.items.forEach((item) => { + if (!listOfVersions.find(x => x.id === item.source.id)) { + listOfVersions.push({"id":item.source.id, "version":item.source.documentationVersion}); + } + if (!listOfVersions.find(x => x.id === item.target.id)) { + listOfVersions.push({"id":item.target.id, "version":item.target.documentationVersion}); + } + }) + + listOfVersions.forEach(async (item) => { + if (item.id !== datasetMDC.id) { + var existingDataset = await Data.findOne({ datasetid: item.id }); + if (existingDataset && existingDataset.pid) pid = existingDataset.pid; + else { + await Data.findOneAndUpdate({ datasetid: item.id }, + { pid: uuid, datasetVersion: item.version } + ) + } + } + else { + datasetVersion = item.version; + } + }) + } + var uniqueID=''; while (uniqueID === '') { uniqueID = parseInt(Math.random().toString().replace('0.', '')); @@ -271,12 +338,14 @@ export async function loadDatasets(override) { uniqueID = ''; } } - + var keywordArray = splitString(datasetMDC.keywords) var physicalSampleAvailabilityArray = splitString(datasetMDC.physicalSampleAvailability) var geographicCoverageArray = splitString(datasetMDC.geographicCoverage) var data = new Data(); + data.pid = pid; + data.datasetVersion = datasetVersion; data.id = uniqueID; data.datasetid = datasetMDC.id; data.type = 'dataset'; @@ -303,6 +372,7 @@ export async function loadDatasets(override) { data.datasetfields.periodicity = datasetMDC.periodicity; data.datasetfields.metadataquality = metadataQuality ? metadataQuality : {}; + data.datasetfields.datautility = dataUtility ? dataUtility : {}; data.datasetfields.metadataschema = metadataSchema && metadataSchema.data ? metadataSchema.data : {}; data.datasetfields.technicaldetails = technicaldetails; data.datasetfields.versionLinks = versionLinks && versionLinks.data && versionLinks.data.items ? versionLinks.data.items : []; diff --git a/src/resources/tool/data.model.js b/src/resources/tool/data.model.js index e4ab264a..48fb0ade 100644 --- a/src/resources/tool/data.model.js +++ b/src/resources/tool/data.model.js @@ -59,6 +59,8 @@ const DataSchema = new Schema( //dataset related fields datasetid: String, + pid: String, + datasetVersion: String, datasetfields: { publisher: String, geographicCoverage: [String], @@ -77,6 +79,7 @@ const DataSchema = new Schema( periodicity: String, populationSize: String, metadataquality : {}, + datautility : {}, metadataschema : {}, technicaldetails : [], versionLinks: [], From b93442d08a2f48cd06481b0d401bfa2ab7960059 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 9 Oct 2020 12:51:15 +0100 Subject: [PATCH 028/144] Added additional required statuses --- .../datarequest/datarequest.controller.js | 62 ++++++++++++++++--- .../publisher/publisher.controller.js | 2 +- src/resources/utilities/helper.util.js | 11 +++- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 704cfe31..bdde64e6 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1512,17 +1512,22 @@ module.exports = { return fullnames; }, - createApplicationDTO: (app) => { + createApplicationDTO: (app, userId = '') => { let projectName = '', applicants = '', workflowName = '', workflowCompleted = false, remainingActioners = [], decisionDuration = '', + decisionMade = false, + decisionStatus = '', + decisionComments = '', managerUsers = [], stepName = '', deadlinePassed = '', - reviewStatus = '' + reviewStatus = '', + isReviewer = false, + reviewPanels = [] // Check if the application has a workflow assigned let { workflow = {}, applicationStatus } = app; @@ -1551,9 +1556,10 @@ module.exports = { let activeStep = module.exports.getActiveWorkflowStep(workflow); // Calculate active step status if (activeStep) { - ({stepName = '', remainingActioners = [], deadlinePassed = '', reviewStatus = ''} = module.exports.getActiveStepStatus( + ({stepName = '', remainingActioners = [], deadlinePassed = '', reviewStatus = '', decisionMade = false, decisionStatus = '', decisionComments = '', isReviewer = false, reviewPanels = [] } = module.exports.getActiveStepStatus( activeStep, - users + users, + userId )); } else if ( _.isUndefined(activeStep) && @@ -1608,10 +1614,15 @@ module.exports = { workflowName, workflowCompleted, decisionDuration, + decisionMade, + decisionStatus, + decisionComments, remainingActioners, stepName, deadlinePassed, - reviewStatus + reviewStatus, + isReviewer, + reviewPanels }; }, @@ -1688,6 +1699,11 @@ module.exports = { reviewStatus, }; } + //Update steps with user friendly review sections + steps = steps.map((step) => { + step.reviewPanels = step.sections.map(section => helper.darPanelMapper[section]).join(', '); + }); + workflowStatus = { workflowName, steps, @@ -1702,16 +1718,20 @@ module.exports = { return steps.every((step) => step.completed); }, - getActiveStepStatus: (activeStep, users = []) => { + getActiveStepStatus: (activeStep, users = [], userId = '') => { let reviewStatus = '', deadlinePassed = false, - remainingActioners = []; + remainingActioners = [], + decisionMade = false, + decisionStatus = '', + decisionComments = ''; let { stepName, deadline, startDateTime, reviewers = [], recommendations = [], + sections = [] } = activeStep; let deadlineDate = moment(startDateTime).add(deadline, 'days'); let diff = parseInt(deadlineDate.diff(new Date(), 'days')); @@ -1736,7 +1756,33 @@ module.exports = { ).map((user) => { return `${user.firstname} ${user.lastname}`; }); - return { stepName, remainingActioners: remainingActioners.join(', '), deadlinePassed, reviewStatus }; + + let isReviewer = reviewers.some( + (reviewer) => reviewer.toString() === userId.toString() + ); + let hasRecommended = recommendations.some( + (rec) => rec.reviewer.toString() === userId.toString() + ); + + decisionMade = isReviewer && hasRecommended; + + if(decisionMade) { + decisionStatus = 'Decision made for this phase'; + } + else { + decisionStatus = 'Decision required'; + } + + if(hasRecommended) { + let recommendation = recommendations.find( + (rec) => rec.reviewer.toString() === userId.toString() + ); + ({ decisionComments = '' } = recommendation); + } + + let reviewPanels = sections.map(section => helper.darPanelMapper[section]).join(', '); + + return { stepName, remainingActioners: remainingActioners.join(', '), deadlinePassed, isReviewer, reviewStatus, decisionMade, decisionStatus, decisionComments, reviewPanels }; }, getActiveWorkflowStep: (workflow) => { diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 43973ce6..282ed10d 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -170,7 +170,7 @@ module.exports = { // 6. Append projectName and applicants let modifiedApplications = [...applications] .map((app) => { - return datarequestController.createApplicationDTO(app.toObject()); + return datarequestController.createApplicationDTO(app.toObject(), _id.toString()); }) .sort((a, b) => b.updatedAt - a.updatedAt); diff --git a/src/resources/utilities/helper.util.js b/src/resources/utilities/helper.util.js index 23c54050..9d4758cb 100644 --- a/src/resources/utilities/helper.util.js +++ b/src/resources/utilities/helper.util.js @@ -30,9 +30,18 @@ const _generatedNumericId = () => { return parseInt(Math.random().toString().replace('0.', '')); } +const _darPanelMapper = { + safesettings: 'Safe settings', + safeproject: 'Safe project', + safepeople: 'Safe people', + safedata: 'Safe data', + safeoutputs: 'Safe outputs' +} + export default { censorEmail: _censorEmail, arraysEqual: _arraysEqual, generateFriendlyId: _generateFriendlyId, - generatedNumericId: _generatedNumericId + generatedNumericId: _generatedNumericId, + darPanelMapper: _darPanelMapper }; From c2ba474ea23f8a191cbec281674f6b6d81ddb570 Mon Sep 17 00:00:00 2001 From: Ciara Date: Fri, 9 Oct 2020 13:27:18 +0100 Subject: [PATCH 029/144] IG-403 data utility added to data model --- src/resources/tool/data.model.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resources/tool/data.model.js b/src/resources/tool/data.model.js index e4ab264a..c3c943b8 100644 --- a/src/resources/tool/data.model.js +++ b/src/resources/tool/data.model.js @@ -57,7 +57,7 @@ const DataSchema = new Schema( organisation: String, showOrganisation: {type: Boolean, default: false }, - //dataset related fields + //dataset related fields datasetid: String, datasetfields: { publisher: String, @@ -77,6 +77,7 @@ const DataSchema = new Schema( periodicity: String, populationSize: String, metadataquality : {}, + datautility : {}, metadataschema : {}, technicaldetails : [], versionLinks: [], From b5ef630c0be181b87efa136e1650b821d742ce37 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 9 Oct 2020 16:55:48 +0100 Subject: [PATCH 030/144] Finished endpoint formatting output for workflows --- .../publisher/publisher.controller.js | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 282ed10d..e5ac828b 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -4,6 +4,7 @@ import { Data } from '../tool/data.model'; import _ from 'lodash'; import { DataRequestModel } from '../datarequest/datarequest.model'; import { WorkflowModel } from '../workflow/workflow.model'; +import helper from '../utilities/helper.util'; const datarequestController = require('../datarequest/datarequest.controller'); const teamController = require('../team/team.controller'); @@ -106,10 +107,10 @@ module.exports = { _id ); - let applicationStatus = ["inProgress"]; + let applicationStatus = ['inProgress']; //If the current user is not a manager then push 'Submitted' into the applicationStatus array - if(!isManager) { - applicationStatus.push("submitted"); + if (!isManager) { + applicationStatus.push('submitted'); } // 4. Find all datasets owned by the publisher (no linkage between DAR and publisher in historic data) let datasetIds = await Data.find({ @@ -129,18 +130,21 @@ module.exports = { ], }) .sort({ updatedAt: -1 }) - .populate([{ - path: 'datasets dataset mainApplicant' - }, { - path: 'publisherObj', - populate: { - path: 'team', + .populate([ + { + path: 'datasets dataset mainApplicant', + }, + { + path: 'publisherObj', populate: { - path: 'users', - select: 'firstname lastname' - } - } - }]); + path: 'team', + populate: { + path: 'users', + select: 'firstname lastname', + }, + }, + }, + ]); if (!isManager) { applications = applications.filter((app) => { @@ -158,8 +162,10 @@ module.exports = { return step.active === true; }); - let elapsedSteps = [...steps].slice(0, activeStepIndex+1); - let found = elapsedSteps.some((step) => step.reviewers.some((reviewer) => reviewer.equals(_id))); + let elapsedSteps = [...steps].slice(0, activeStepIndex + 1); + let found = elapsedSteps.some((step) => + step.reviewers.some((reviewer) => reviewer.equals(_id)) + ); if (found) { return app; @@ -170,7 +176,10 @@ module.exports = { // 6. Append projectName and applicants let modifiedApplications = [...applications] .map((app) => { - return datarequestController.createApplicationDTO(app.toObject(), _id.toString()); + return datarequestController.createApplicationDTO( + app.toObject(), + _id.toString() + ); }) .sort((a, b) => b.updatedAt - a.updatedAt); @@ -241,6 +250,16 @@ module.exports = { steps, applications = [], } = workflow.toObject(); + + let formattedSteps = [...steps].reduce((arr, item) => { + let step = { + ...item, + sections: [...item.sections].map(section => helper.darPanelMapper[section]) + } + arr.push(step); + return arr; + }, []); + applications = applications.map((app) => { const { aboutApplication, _id } = app; const aboutApplicationObj = JSON.parse(aboutApplication) || {}; @@ -255,7 +274,7 @@ module.exports = { id, workflowName, version, - steps, + steps: formattedSteps, applications, appCount: applications.length, canDelete, From 1ca663d72cdcd6c8671b2d39538c8af61bb3bcff Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Fri, 9 Oct 2020 17:48:24 +0100 Subject: [PATCH 031/144] Adding rate limiting to API --- package.json | 1 + src/resources/dataset/dataset.route.js | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/package.json b/package.json index db22f53a..ac414ec1 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "dotenv": "^8.2.0", "esm": "^3.2.25", "express": "^4.17.1", + "express-rate-limit": "^5.1.3", "express-session": "^1.17.1", "googleapis": "^55.0.0", "jose": "^2.0.2", diff --git a/src/resources/dataset/dataset.route.js b/src/resources/dataset/dataset.route.js index 01db488f..eacbd9e6 100644 --- a/src/resources/dataset/dataset.route.js +++ b/src/resources/dataset/dataset.route.js @@ -3,7 +3,13 @@ import { Data } from '../tool/data.model' import { loadDataset, loadDatasets } from './dataset.service'; import { getToolsAdmin } from '../tool/data.repository'; const router = express.Router(); +const rateLimit = require("express-rate-limit"); +const datasetLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour window + max: 10, // start blocking after 10 requests + message: "Too many calls have been made to this api from this IP, please try again after an hour" +}); router.post('/', async (req, res) => { //Check to see if header is in json format @@ -27,6 +33,7 @@ router.post('/', async (req, res) => { // @access Public router.get( '/pidList/', + datasetLimiter, async (req, res) => { var q = Data.find( { "type" : "dataset", "pid" : { "$exists" : true } }, From 78e52ae562cb2d89f45d898925d8eba2284e04c0 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 12 Oct 2020 11:41:58 +0100 Subject: [PATCH 032/144] Made additional output modifications for DAR dashboard output --- src/resources/datarequest/datarequest.controller.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index bdde64e6..662c8d3f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1723,8 +1723,9 @@ module.exports = { deadlinePassed = false, remainingActioners = [], decisionMade = false, - decisionStatus = '', - decisionComments = ''; + decisionComments = '', + decisionApproved = false, + decisionStatus = ''; let { stepName, deadline, @@ -1769,20 +1770,22 @@ module.exports = { if(decisionMade) { decisionStatus = 'Decision made for this phase'; } - else { + else if(isReviewer) { decisionStatus = 'Decision required'; + } else { + decisionStatus = ''; } if(hasRecommended) { let recommendation = recommendations.find( (rec) => rec.reviewer.toString() === userId.toString() ); - ({ decisionComments = '' } = recommendation); + ({ comments: decisionComments = '', approved: decisionApproved = false } = recommendation); } let reviewPanels = sections.map(section => helper.darPanelMapper[section]).join(', '); - return { stepName, remainingActioners: remainingActioners.join(', '), deadlinePassed, isReviewer, reviewStatus, decisionMade, decisionStatus, decisionComments, reviewPanels }; + return { stepName, remainingActioners: remainingActioners.join(', '), deadlinePassed, isReviewer, reviewStatus, decisionMade, decisionApproved, decisionStatus, decisionComments, reviewPanels }; }, getActiveWorkflowStep: (workflow) => { From 5108fac7f6841243ebc6be68761d73b484df558f Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 12 Oct 2020 11:43:50 +0100 Subject: [PATCH 033/144] Allowing a rejection message to be sent on rejection of a resource --- src/resources/tool/data.repository.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/resources/tool/data.repository.js b/src/resources/tool/data.repository.js index 02ece013..87fbb71e 100644 --- a/src/resources/tool/data.repository.js +++ b/src/resources/tool/data.repository.js @@ -256,7 +256,7 @@ const editTool = async (req, res) => { const setStatus = async (req, res) => { return new Promise(async (resolve, reject) => { try { - const { activeflag } = req.body; + const { activeflag, rejectionReason } = req.body; const id = req.params.id; let tool = await Data.findOneAndUpdate({ id: id }, { $set: { activeflag: activeflag } }); @@ -266,17 +266,17 @@ const editTool = async (req, res) => { if (tool.authors) { tool.authors.forEach(async (authorId) => { - await createMessage(authorId, id, tool.name, tool.type, activeflag); + await createMessage(authorId, id, tool.name, tool.type, activeflag, rejectionReason); }); } - await createMessage(0, id, tool.name, tool.type, activeflag); + await createMessage(0, id, tool.name, tool.type, activeflag, rejectionReason); if (!tool.discourseTopicId && tool.activeflag === 'active') { await createDiscourseTopic(tool); } // Send email notification of status update to admins and authors who have opted in - await sendEmailNotifications(tool, activeflag); + await sendEmailNotifications(tool, activeflag, rejectionReason); resolve(id); @@ -287,16 +287,17 @@ const editTool = async (req, res) => { }); }; - async function createMessage(authorId, toolId, toolName, toolType, activeflag) { + async function createMessage(authorId, toolId, toolName, toolType, activeflag, rejectionReason) { let message = new MessagesModel(); const toolLink = process.env.homeURL + '/' + toolType + '/' + toolId; if (activeflag === 'active') { message.messageType = 'approved'; message.messageDescription = `Your ${toolType} ${toolName} has been approved and is now live ${toolLink}` - } else if (activeflag === 'archive') { + } else if (activeflag === 'archive' || activeflag === 'rejected') { message.messageType = 'rejected'; message.messageDescription = `Your ${toolType} ${toolName} has been rejected ${toolLink}` + message.messageDescription = (rejectionReason) ? message.messageDescription.concat(` Rejection reason: ${rejectionReason}`) : message.messageDescription } message.messageID = parseInt(Math.random().toString().replace('0.', '')); message.messageTo = authorId; @@ -306,7 +307,7 @@ const editTool = async (req, res) => { await message.save(); } - async function sendEmailNotifications(tool, activeflag) { + async function sendEmailNotifications(tool, activeflag, rejectionReason) { let subject; let html; // 1. Generate tool URL for linking user from email @@ -316,9 +317,9 @@ const editTool = async (req, res) => { if (activeflag === 'active') { subject = `Your ${tool.type} ${tool.name} has been approved and is now live` html = `Your ${tool.type} ${tool.name} has been approved and is now live

${toolLink}` - } else if (activeflag === 'archive') { + } else if (activeflag === 'archive' || activeflag === 'rejected') { subject = `Your ${tool.type} ${tool.name} has been rejected` - html = `Your ${tool.type} ${tool.name} has been rejected

${toolLink}` + html = `Your ${tool.type} ${tool.name} has been rejected

Rejection reason: ${rejectionReason}

${toolLink}` } // 3. Find all authors of the tool who have opted in to email updates From c9ec50fac089e90fa3d6e546b85014601132e688 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 12 Oct 2020 12:10:25 +0100 Subject: [PATCH 034/144] Added date to individual recommendations for voting --- src/resources/datarequest/datarequest.controller.js | 1 + src/resources/workflow/workflow.model.js | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 662c8d3f..ad81479d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -927,6 +927,7 @@ module.exports = { approved, comments, reviewer: new mongoose.Types.ObjectId(userId), + createdDate: new Date() }; // 10. Update access record with recommendation accessRecord.workflow.steps[activeStepIndex].recommendations = [ diff --git a/src/resources/workflow/workflow.model.js b/src/resources/workflow/workflow.model.js index 044e84e6..87f1bfd9 100644 --- a/src/resources/workflow/workflow.model.js +++ b/src/resources/workflow/workflow.model.js @@ -26,7 +26,8 @@ const StepSchema = new Schema({ recommendations: [{ reviewer: { type : Schema.Types.ObjectId, ref: 'User' }, approved: { type: Boolean }, - comments: { type: String } + comments: { type: String }, + createdDate: { type: Date } }] }); From 772aea92ae7aa04a82dbfa47c5f998d1649ada2b Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 12 Oct 2020 15:01:06 +0100 Subject: [PATCH 035/144] Added more outputs for DAR dashboard --- .../datarequest/datarequest.controller.js | 101 ++++++++++++------ 1 file changed, 67 insertions(+), 34 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index ad81479d..5efacc15 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -927,7 +927,7 @@ module.exports = { approved, comments, reviewer: new mongoose.Types.ObjectId(userId), - createdDate: new Date() + createdDate: new Date(), }; // 10. Update access record with recommendation accessRecord.workflow.steps[activeStepIndex].recommendations = [ @@ -1524,11 +1524,11 @@ module.exports = { decisionStatus = '', decisionComments = '', managerUsers = [], - stepName = '', - deadlinePassed = '', + stepName = '', + deadlinePassed = '', reviewStatus = '', isReviewer = false, - reviewPanels = [] + reviewPanels = []; // Check if the application has a workflow assigned let { workflow = {}, applicationStatus } = app; @@ -1541,13 +1541,15 @@ module.exports = { let managers = members.filter((mem) => { return mem.roles.includes('manager'); }); - managerUsers = users.filter((user) => - managers.some( - (manager) => manager.memberid.toString() === user._id.toString() + managerUsers = users + .filter((user) => + managers.some( + (manager) => manager.memberid.toString() === user._id.toString() + ) ) - ).map((user) => { - return `${user.firstname} ${user.lastname}`; - }); + .map((user) => { + return `${user.firstname} ${user.lastname}`; + }); if (applicationStatus === 'submitted') { remainingActioners = managerUsers.join(', '); } @@ -1557,11 +1559,19 @@ module.exports = { let activeStep = module.exports.getActiveWorkflowStep(workflow); // Calculate active step status if (activeStep) { - ({stepName = '', remainingActioners = [], deadlinePassed = '', reviewStatus = '', decisionMade = false, decisionStatus = '', decisionComments = '', isReviewer = false, reviewPanels = [] } = module.exports.getActiveStepStatus( - activeStep, - users, - userId - )); + ({ + stepName = '', + remainingActioners = [], + deadlinePassed = '', + reviewStatus = '', + decisionMade = false, + decisionStatus = '', + decisionComments = '', + decisionApproved, + decisionDate, + isReviewer = false, + reviewPanels = [] + } = module.exports.getActiveStepStatus(activeStep, users, userId)); } else if ( _.isUndefined(activeStep) && applicationStatus === 'inReview' @@ -1618,12 +1628,14 @@ module.exports = { decisionMade, decisionStatus, decisionComments, + decisionDate, + decisionApproved, remainingActioners, - stepName, - deadlinePassed, + stepName, + deadlinePassed, reviewStatus, isReviewer, - reviewPanels + reviewPanels, }; }, @@ -1702,7 +1714,9 @@ module.exports = { } //Update steps with user friendly review sections steps = steps.map((step) => { - step.reviewPanels = step.sections.map(section => helper.darPanelMapper[section]).join(', '); + step.reviewPanels = step.sections + .map((section) => helper.darPanelMapper[section]) + .join(', '); }); workflowStatus = { @@ -1723,7 +1737,7 @@ module.exports = { let reviewStatus = '', deadlinePassed = false, remainingActioners = [], - decisionMade = false, + decisionMade = false, decisionComments = '', decisionApproved = false, decisionStatus = ''; @@ -1733,7 +1747,7 @@ module.exports = { startDateTime, reviewers = [], recommendations = [], - sections = [] + sections = [], } = activeStep; let deadlineDate = moment(startDateTime).add(deadline, 'days'); let diff = parseInt(deadlineDate.diff(new Date(), 'days')); @@ -1751,13 +1765,15 @@ module.exports = { (rec) => rec.reviewer.toString() === reviewer.toString() ) ); - remainingActioners = users.filter((user) => - remainingActioners.some( - (actioner) => actioner.toString() === user._id.toString() + remainingActioners = users + .filter((user) => + remainingActioners.some( + (actioner) => actioner.toString() === user._id.toString() + ) ) - ).map((user) => { - return `${user.firstname} ${user.lastname}`; - }); + .map((user) => { + return `${user.firstname} ${user.lastname}`; + }); let isReviewer = reviewers.some( (reviewer) => reviewer.toString() === userId.toString() @@ -1768,25 +1784,42 @@ module.exports = { decisionMade = isReviewer && hasRecommended; - if(decisionMade) { + if (decisionMade) { decisionStatus = 'Decision made for this phase'; - } - else if(isReviewer) { + } else if (isReviewer) { decisionStatus = 'Decision required'; } else { decisionStatus = ''; } - if(hasRecommended) { + if (hasRecommended) { let recommendation = recommendations.find( (rec) => rec.reviewer.toString() === userId.toString() ); - ({ comments: decisionComments = '', approved: decisionApproved = false } = recommendation); + ({ + comments: decisionComments, + approved: decisionApproved, + createdDate: decisionDate + } = recommendation); } - let reviewPanels = sections.map(section => helper.darPanelMapper[section]).join(', '); + let reviewPanels = sections + .map((section) => helper.darPanelMapper[section]) + .join(', '); - return { stepName, remainingActioners: remainingActioners.join(', '), deadlinePassed, isReviewer, reviewStatus, decisionMade, decisionApproved, decisionStatus, decisionComments, reviewPanels }; + return { + stepName, + remainingActioners: remainingActioners.join(', '), + deadlinePassed, + isReviewer, + reviewStatus, + decisionMade, + decisionApproved, + decisionDate, + decisionStatus, + decisionComments, + reviewPanels, + }; }, getActiveWorkflowStep: (workflow) => { From fd98792d7e66defd5aebb84343df9fd495330795 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 12 Oct 2020 15:07:06 +0100 Subject: [PATCH 036/144] Added more outputs for DAR dashboard --- src/resources/datarequest/datarequest.controller.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 5efacc15..ae045294 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1523,6 +1523,8 @@ module.exports = { decisionMade = false, decisionStatus = '', decisionComments = '', + decisionDate = '', + decisionApproved = false, managerUsers = [], stepName = '', deadlinePassed = '', From c735cdba22bfc5afe64e87cd9db39ed17440c30e Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 12 Oct 2020 15:11:04 +0100 Subject: [PATCH 037/144] Added more outputs for DAR dashboard --- src/resources/datarequest/datarequest.controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index ae045294..0fc5eef5 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1742,6 +1742,7 @@ module.exports = { decisionMade = false, decisionComments = '', decisionApproved = false, + decisionDate = '', decisionStatus = ''; let { stepName, From dc7d5a418a43d71679d4e2708955e1489eedeefe Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 12 Oct 2020 15:33:02 +0100 Subject: [PATCH 038/144] Fixed LGTM warning --- src/resources/datarequest/datarequest.controller.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 0fc5eef5..63c5414a 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1690,7 +1690,6 @@ module.exports = { // Return active review mode if conditions apply if (applicationStatus === 'inReview' && isActiveStepReviewer) { inReviewMode = true; - reviewSections; } return { inReviewMode, reviewSections }; From 97bce75aa838171770df9019b3c4bb129134fcd7 Mon Sep 17 00:00:00 2001 From: Richard Date: Mon, 12 Oct 2020 16:09:20 +0100 Subject: [PATCH 039/144] Allowing a rejection message to be sent on rejection of a resource --- src/resources/message/message.model.js | 1 + src/resources/tool/data.repository.js | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/resources/message/message.model.js b/src/resources/message/message.model.js index bb7a3876..d21e6b17 100644 --- a/src/resources/message/message.model.js +++ b/src/resources/message/message.model.js @@ -14,6 +14,7 @@ const MessageSchema = new Schema({ enum: ['message', 'add', 'approved', + 'archive', 'author', 'rejected', 'added collection', diff --git a/src/resources/tool/data.repository.js b/src/resources/tool/data.repository.js index 87fbb71e..c472a6bf 100644 --- a/src/resources/tool/data.repository.js +++ b/src/resources/tool/data.repository.js @@ -294,7 +294,10 @@ const editTool = async (req, res) => { if (activeflag === 'active') { message.messageType = 'approved'; message.messageDescription = `Your ${toolType} ${toolName} has been approved and is now live ${toolLink}` - } else if (activeflag === 'archive' || activeflag === 'rejected') { + } else if (activeflag === 'archive') { + message.messageType = 'archive'; + message.messageDescription = `Your ${toolType} ${toolName} has been archived ${toolLink}` + } else if (activeflag === 'rejected') { message.messageType = 'rejected'; message.messageDescription = `Your ${toolType} ${toolName} has been rejected ${toolLink}` message.messageDescription = (rejectionReason) ? message.messageDescription.concat(` Rejection reason: ${rejectionReason}`) : message.messageDescription @@ -317,7 +320,10 @@ const editTool = async (req, res) => { if (activeflag === 'active') { subject = `Your ${tool.type} ${tool.name} has been approved and is now live` html = `Your ${tool.type} ${tool.name} has been approved and is now live

${toolLink}` - } else if (activeflag === 'archive' || activeflag === 'rejected') { + } else if (activeflag === 'archive') { + subject = `Your ${tool.type} ${tool.name} has been archived` + html = `Your ${tool.type} ${tool.name} has been archived

${toolLink}` + } else if (activeflag === 'rejected') { subject = `Your ${tool.type} ${tool.name} has been rejected` html = `Your ${tool.type} ${tool.name} has been rejected

Rejection reason: ${rejectionReason}

${toolLink}` } From bf86f6537e5ffc5a72a288a672d2992ffe4cc225 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 13 Oct 2020 13:45:41 +0100 Subject: [PATCH 040/144] Fixed mapping issue for returning steps against single DAR --- .../datarequest/datarequest.controller.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 63c5414a..7682577f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1714,15 +1714,18 @@ module.exports = { }; } //Update steps with user friendly review sections - steps = steps.map((step) => { - step.reviewPanels = step.sections - .map((section) => helper.darPanelMapper[section]) - .join(', '); - }); + let formattedSteps = [...steps].reduce((arr, item) => { + let step = { + ...item, + sections: [...item.sections].map(section => helper.darPanelMapper[section]) + } + arr.push(step); + return arr; + }, []); workflowStatus = { workflowName, - steps, + steps: formattedSteps, isCompleted: module.exports.getWorkflowCompleted(workflow), }; } From 108a53a9d8f0ddd13dab78fad244fb0162ad06c8 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 14 Oct 2020 10:07:16 +0100 Subject: [PATCH 041/144] Added additional output for loading DAR workflow status --- .../datarequest/datarequest.controller.js | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 7682577f..5c09a902 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -86,12 +86,14 @@ module.exports = { // 2. Find the matching record and include attached datasets records with publisher details let accessRecord = await DataRequestModel.findOne({ _id: requestId, - }) - .populate({ path: 'mainApplicant', select: 'firstname lastname -id' }) - .populate({ + }).populate([ + { path: 'mainApplicant', select: 'firstname lastname -id' }, + { path: 'datasets dataset authors', populate: { path: 'publisher', populate: { path: 'team' } }, - }); + }, + { path: 'workflow.steps.reviewers', select: 'firstname lastname' } + ]); // 3. If no matching application found, return 404 if (!accessRecord) { return res @@ -1572,7 +1574,7 @@ module.exports = { decisionApproved, decisionDate, isReviewer = false, - reviewPanels = [] + reviewPanels = [], } = module.exports.getActiveStepStatus(activeStep, users, userId)); } else if ( _.isUndefined(activeStep) && @@ -1706,19 +1708,25 @@ module.exports = { return step.active === true; }); if (activeStep) { - let { reviewStatus } = module.exports.getActiveStepStatus(activeStep); + let { + reviewStatus, + deadlinePassed, + } = module.exports.getActiveStepStatus(activeStep); //Update active step with review status steps[activeStepIndex] = { ...steps[activeStepIndex], reviewStatus, + deadlinePassed, }; } //Update steps with user friendly review sections let formattedSteps = [...steps].reduce((arr, item) => { let step = { ...item, - sections: [...item.sections].map(section => helper.darPanelMapper[section]) - } + sections: [...item.sections].map( + (section) => helper.darPanelMapper[section] + ), + }; arr.push(step); return arr; }, []); @@ -1804,7 +1812,7 @@ module.exports = { ({ comments: decisionComments, approved: decisionApproved, - createdDate: decisionDate + createdDate: decisionDate, } = recommendation); } From d0b44a645913de5ee4dc045712e1ed7e6205159f Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 14 Oct 2020 11:28:48 +0100 Subject: [PATCH 042/144] Removed subscribe link from DAR emails and negated opt-in status --- .../datarequest/datarequest.controller.js | 42 +++++-------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 5c09a902..27e31c67 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1243,13 +1243,8 @@ module.exports = { emailRecipients = [ accessRecord.mainApplicant, ...custodianUsers, - ...accessRecord.authors, - ].filter(function (user) { - let { - additionalInfo: { emailNotifications }, - } = user; - return emailNotifications === true; - }); + ...accessRecord.authors + ]; let { dateSubmitted } = accessRecord; if (!dateSubmitted) ({ updatedAt: dateSubmitted } = accessRecord); // Create object to pass through email data @@ -1272,7 +1267,7 @@ module.exports = { hdrukEmail, `Data Access Request for ${datasetTitles} was ${context.applicationStatus} by ${publisher}`, html, - true + false ); break; case 'Submitted': @@ -1340,13 +1335,8 @@ module.exports = { // Send email to main applicant and contributors if they have opted in to email notifications emailRecipients = [ accessRecord.mainApplicant, - ...accessRecord.authors, - ].filter(function (user) { - let { - additionalInfo: { emailNotifications }, - } = user; - return emailNotifications === true; - }); + ...accessRecord.authors + ]; } // Establish email context object options = { ...options, userType: emailRecipientType }; @@ -1365,7 +1355,7 @@ module.exports = { hdrukEmail, `Data Access Request has been submitted to ${publisher} for ${datasetTitles}`, html, - true + false ); } } @@ -1400,12 +1390,6 @@ module.exports = { let addedUsers = await UserModel.find({ id: { $in: addedAuthors }, }).populate('additionalInfo'); - emailRecipients = addedUsers.filter(function (user) { - let { - additionalInfo: { emailNotifications }, - } = user; - return emailNotifications === true; - }); await notificationBuilder.triggerNotificationMessage( addedUsers.map((user) => user.id), @@ -1414,11 +1398,11 @@ module.exports = { accessRecord._id ); await emailGenerator.sendEmail( - emailRecipients, + addedUsers, hdrukEmail, `You have been added as a contributor for a Data Access Request to ${publisher} by ${firstname} ${lastname}`, html, - true + false ); } // Notifications for removed contributors @@ -1429,12 +1413,6 @@ module.exports = { let removedUsers = await UserModel.find({ id: { $in: removedAuthors }, }).populate('additionalInfo'); - emailRecipients = removedUsers.filter(function (user) { - let { - additionalInfo: { emailNotifications }, - } = user; - return emailNotifications === true; - }); await notificationBuilder.triggerNotificationMessage( removedUsers.map((user) => user.id), @@ -1443,11 +1421,11 @@ module.exports = { accessRecord._id ); await emailGenerator.sendEmail( - emailRecipients, + removedUsers, hdrukEmail, `You have been removed as a contributor from a Data Access Request to ${publisher} by ${firstname} ${lastname}`, html, - true + false ); } break; From ad781879998e0c53f9d68abb7613a62df3ad3f56 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 14 Oct 2020 13:35:47 +0100 Subject: [PATCH 043/144] Removed missed opt status check on submission email send --- src/resources/datarequest/datarequest.controller.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 27e31c67..4034f66d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1325,12 +1325,7 @@ module.exports = { for (let emailRecipientType of emailRecipientTypes) { // Send emails to custodian team members who have opted in to email notifications if (emailRecipientType === 'dataCustodian') { - emailRecipients = [...custodianUsers].filter(function (user) { - let { - additionalInfo: { emailNotifications }, - } = user; - return emailNotifications === true; - }); + emailRecipients = [...custodianUsers]; } else { // Send email to main applicant and contributors if they have opted in to email notifications emailRecipients = [ From 0ad786f99cf2f87333745830e8291cba94833a54 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Wed, 14 Oct 2020 16:43:14 +0100 Subject: [PATCH 044/144] Fixes that went out in v1.5.1 --- .../auth/sso/sso.discourse.router.js | 52 +++++++++++-------- src/resources/dataset/dataset.service.js | 8 +-- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/resources/auth/sso/sso.discourse.router.js b/src/resources/auth/sso/sso.discourse.router.js index c82ffc3b..9ddf14c4 100644 --- a/src/resources/auth/sso/sso.discourse.router.js +++ b/src/resources/auth/sso/sso.discourse.router.js @@ -8,28 +8,38 @@ const router = express.Router(); // @router GET /api/v1/auth/sso/discourse // @desc Single Sign On for Discourse forum // @access Private -router.get( - '/', - passport.authenticate('jwt'), - async (req, res) => { - let redirectUrl = null; +router.get("/", function(req, res, next) { + passport.authenticate("jwt", function(err, user, info) { + if (err || !user) { + return res.status(200); + } else { + let redirectUrl = null; - if (req.query.sso && req.query.sig) { - try { - redirectUrl = discourseLogin(req.query.sso, req.query.sig, req.user); - } catch (err) { - console.error(err); - return res.status(500).send('Error authenticating the user.'); - } - } + if (req.query.sso && req.query.sig) { + try { + redirectUrl = discourseLogin(req.query.sso, req.query.sig, req.user); + } catch (err) { + console.error(err); + return res.status(500).send("Error authenticating the user."); + } + } - return res - .status(200) - .cookie('jwt', signToken({_id: req.user._id, id: req.user.id, timeStamp: Date.now()}), { - httpOnly: true, - }) - .json({redirectUrl: redirectUrl}); - } -); + return res + .status(200) + .cookie( + "jwt", + signToken({ + _id: req.user._id, + id: req.user.id, + timeStamp: Date.now() + }), + { + httpOnly: true + } + ) + .json({ redirectUrl: redirectUrl }); + } + })(req, res, next); +}); module.exports = router; diff --git a/src/resources/dataset/dataset.service.js b/src/resources/dataset/dataset.service.js index 4b6fa0f8..85317a3b 100644 --- a/src/resources/dataset/dataset.service.js +++ b/src/resources/dataset/dataset.service.js @@ -68,7 +68,7 @@ export async function loadDataset(datasetID) { var geographicCoverageArray = splitString(dataset.data.geographicCoverage) const metadataQuality = metadataQualityList.data.find(x => x.id === datasetID); - const phenotypes = phenotypesList.data[datasetMDC.id] || []; + const phenotypes = phenotypesList.data[datasetID] || []; var data = new Data(); data.id = uniqueID; @@ -94,7 +94,7 @@ export async function loadDataset(datasetID) { data.datasetfields.statisticalPopulation = dataset.data.statisticalPopulation; data.datasetfields.ageBand = dataset.data.ageBand; data.datasetfields.contactPoint = dataset.data.contactPoint; - data.datasetfields.periodicity = datasetMDC.periodicity; + data.datasetfields.periodicity = dataset.data.periodicity; data.datasetfields.metadataquality = metadataQuality ? metadataQuality : {}; data.datasetfields.metadataschema = metadataSchema && metadataSchema.data ? metadataSchema.data : {}; @@ -178,7 +178,7 @@ export async function loadDatasets(override) { const phenotypes = phenotypesList.data[datasetMDC.id] || []; const metadataSchemaCall = axios.get(metadataCatalogueLink + '/api/profiles/uk.ac.hdrukgateway/HdrUkProfilePluginService/schema.org/'+ datasetMDC.id, { timeout:5000 }).catch(err => { console.log('Unable to get metadata schema '+err.message) }); - const dataClassCall = axios.get(metadataCatalogueLink + '/api/dataModels/'+datasetMDC.id+'/dataClasses?max=100', { timeout:5000 }).catch(err => { console.log('Unable to get dataclass '+err.message) }); + const dataClassCall = axios.get(metadataCatalogueLink + '/api/dataModels/'+datasetMDC.id+'/dataClasses?max=300', { timeout:5000 }).catch(err => { console.log('Unable to get dataclass '+err.message) }); const versionLinksCall = axios.get(metadataCatalogueLink + '/api/catalogueItems/'+datasetMDC.id+'/semanticLinks', { timeout:5000 }).catch(err => { console.log('Unable to get version links '+err.message) }); const [metadataSchema, dataClass, versionLinks] = await axios.all([metadataSchemaCall, dataClassCall, versionLinksCall]); @@ -188,7 +188,7 @@ export async function loadDatasets(override) { (p, dataclassMDC) => p.then( () => (new Promise(resolve => { setTimeout(async function () { - const dataClassElementCall = axios.get(metadataCatalogueLink + '/api/dataModels/'+datasetMDC.id+'/dataClasses/'+dataclassMDC.id+'/dataElements?max=100', { timeout:5000 }).catch(err => { console.log('Unable to get dataclass element '+err.message) }); + const dataClassElementCall = axios.get(metadataCatalogueLink + '/api/dataModels/'+datasetMDC.id+'/dataClasses/'+dataclassMDC.id+'/dataElements?max=300', { timeout:5000 }).catch(err => { console.log('Unable to get dataclass element '+err.message) }); const [dataClassElement] = await axios.all([dataClassElementCall]); var dataClassElementArray = [] From 384fc13937baecc08066ee3fdfa7e4a158bde948 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Wed, 14 Oct 2020 17:15:07 +0100 Subject: [PATCH 045/144] Fix for discourse SSO sign in --- src/resources/auth/sso/sso.discourse.router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/auth/sso/sso.discourse.router.js b/src/resources/auth/sso/sso.discourse.router.js index 9ddf14c4..309d71d5 100644 --- a/src/resources/auth/sso/sso.discourse.router.js +++ b/src/resources/auth/sso/sso.discourse.router.js @@ -11,7 +11,7 @@ const router = express.Router(); router.get("/", function(req, res, next) { passport.authenticate("jwt", function(err, user, info) { if (err || !user) { - return res.status(200); + return res.status(200).json({ redirectUrl: null }); } else { let redirectUrl = null; From 0c5630e54d79f8aebad3ec938d592fde94d182e3 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 14 Oct 2020 17:17:25 +0100 Subject: [PATCH 046/144] Submission emails for DAR now only go to managers or dataset contact point if no team established --- src/resources/datarequest/datarequest.controller.js | 13 ++++++++----- src/resources/team/team.controller.js | 9 +++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 4034f66d..100fa1dd 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1182,6 +1182,7 @@ module.exports = { let { firstname, lastname } = user; // Instantiate default params let custodianUsers = [], + custodianManagers = [], emailRecipients = [], options = {}, html = '', @@ -1286,14 +1287,17 @@ module.exports = { _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { // Retrieve all custodian user Ids to generate notifications - custodianUsers = [...accessRecord.datasets[0].publisher.team.users]; - let custodianUserIds = custodianUsers.map((user) => user.id); + custodianManagers = teamController.getTeamManagers(accessRecord.datasets[0].publisher.team); + let custodianUserIds = custodianManagers.map((user) => user.id); await notificationBuilder.triggerNotificationMessage( custodianUserIds, `A Data Access Request has been submitted to ${publisher} for ${datasetTitles} by ${appFirstName} ${appLastName}`, 'data access request', accessRecord._id ); + } else { + const dataCustodianEmail = process.env.DATA_CUSTODIAN_EMAIL || contactPoint; + custodianManagers = [{ email: dataCustodianEmail }]; } // Applicant notification await notificationBuilder.triggerNotificationMessage( @@ -1316,7 +1320,6 @@ module.exports = { options = { userType: '', userEmail: appEmail, - custodianEmail: contactPoint, publisher, datasetTitles, userName: `${appFirstName} ${appLastName}`, @@ -1325,7 +1328,7 @@ module.exports = { for (let emailRecipientType of emailRecipientTypes) { // Send emails to custodian team members who have opted in to email notifications if (emailRecipientType === 'dataCustodian') { - emailRecipients = [...custodianUsers]; + emailRecipients = [...custodianManagers]; } else { // Send email to main applicant and contributors if they have opted in to email notifications emailRecipients = [ @@ -1683,7 +1686,7 @@ module.exports = { if (activeStep) { let { reviewStatus, - deadlinePassed, + deadlinePassed } = module.exports.getActiveStepStatus(activeStep); //Update active step with review status steps[activeStepIndex] = { diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index b3a41e70..4766edbc 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -104,7 +104,12 @@ module.exports = { return false; }, - findTeamManagers: () => { - + getTeamManagers: (team) => { + // Destructure members array and populated users array (populate 'users' must be included in the original Mongo query) + let { members = [], users = [] } = team; + // Get all userIds for managers within team + let managerIds = members.filter(mem => mem.roles.includes('manager')).map(mem => mem.memberid.toString()); + // return all user records for managers + return users.filter(user => managerIds.includes(user._id.toString())); } }; From cd5d91b1a34c9c0bc078f4c4492a51dc6f93741d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 15 Oct 2020 13:52:05 +0100 Subject: [PATCH 047/144] Added additional parameter to determine if the user can override current phase --- .../datarequest/datarequest.controller.js | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 100fa1dd..bb9de028 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -133,8 +133,18 @@ module.exports = { ); // 8. Get the workflow/voting status let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); - - // 9. Return application form + // 9. Check if the current user can override the current step + let isManager = false; + if(_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { + isManager = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + accessRecord.datasets[0].publisher.team.toObject(), + req.user._id + ); + // Set the workflow override capability if there is an active step and user is a manager + workflow.canOverrideStep = !workflow.isCompleted && isManager; + } + // 10. Return application form return res.status(200).json({ status: 'success', data: { @@ -150,7 +160,7 @@ module.exports = { helper.generateFriendlyId(accessRecord._id), inReviewMode, reviewSections, - workflow, + workflow }, }); } catch (err) { @@ -1067,7 +1077,7 @@ module.exports = { bpmController.postCompleteReview(bpmContext); } }); - // 13. Return aplication and successful response + // 12. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { console.log(err.message); @@ -1710,7 +1720,7 @@ module.exports = { workflowStatus = { workflowName, steps: formattedSteps, - isCompleted: module.exports.getWorkflowCompleted(workflow), + isCompleted: module.exports.getWorkflowCompleted(workflow) }; } return workflowStatus; From 5db321251402f99f8903b9dd061d821295d51219 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 15 Oct 2020 15:39:59 +0100 Subject: [PATCH 048/144] Building Team Help FAQ --- src/config/server.js | 2 ++ src/resources/help/help.model.js | 12 ++++++++++++ src/resources/help/help.router.js | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 src/resources/help/help.model.js create mode 100644 src/resources/help/help.router.js diff --git a/src/config/server.js b/src/config/server.js index a0c461be..6a16969f 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -200,6 +200,8 @@ app.use('/api/v1/collections', require('../resources/collections/collections.rou app.use('/api/v1/analyticsdashboard', require('../resources/googleanalytics/googleanalytics.router')); +app.use('/api/v1/help', require('../resources/help/help.router')); + initialiseAuthentication(app); // launch our backend into a port diff --git a/src/resources/help/help.model.js b/src/resources/help/help.model.js new file mode 100644 index 00000000..122522f6 --- /dev/null +++ b/src/resources/help/help.model.js @@ -0,0 +1,12 @@ +import { model, Schema } from 'mongoose'; + +const HelpSchema = new Schema( + { + question: String, + answer: String, + category: String, + activeFlag: Boolean + } +); + +export const Help = model('help_faq', HelpSchema) \ No newline at end of file diff --git a/src/resources/help/help.router.js b/src/resources/help/help.router.js new file mode 100644 index 00000000..12bc5113 --- /dev/null +++ b/src/resources/help/help.router.js @@ -0,0 +1,19 @@ +import express from "express"; +import { Help } from "./help.model"; + +const router = express.Router(); + +// @router GET /api/help/:category +// @desc Get Help FAQ for a category +// @access Public +router.get("/:category", async (req, res) => { + let query = Help.aggregate([ + { $match: { $and: [{ active: true }, { category: req.params.category }] } } + ]); + query.exec((err, data) => { + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true, data: data }); + }); +}); + +module.exports = router; \ No newline at end of file From e845f33cac1e004b272d655cddfee39698c10edc Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 15 Oct 2020 16:33:02 +0100 Subject: [PATCH 049/144] Building team help FAQ --- src/resources/help/help.router.js | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/resources/help/help.router.js b/src/resources/help/help.router.js index 12bc5113..378229df 100644 --- a/src/resources/help/help.router.js +++ b/src/resources/help/help.router.js @@ -7,13 +7,25 @@ const router = express.Router(); // @desc Get Help FAQ for a category // @access Public router.get("/:category", async (req, res) => { - let query = Help.aggregate([ - { $match: { $and: [{ active: true }, { category: req.params.category }] } } - ]); - query.exec((err, data) => { - if (err) return res.json({ success: false, error: err }); - return res.json({ success: true, data: data }); - }); + try { + // 1. Destructure category parameter with safe default + let { category = '' } = req.params; + // 2. Check if parameter is empty (if required throw error response) + if(_.isEmpty(category)) { + return res.status(400).json({ success: false, message: 'Category is required' }); + } + // 3. Find matching help items in MongoDb + let help = await Help.find({ $and: [{ active: true }, { category }] }); + // 4. Return help data in response + return res.status(200).json({ success: true, help }); + } + catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for help data', + }); + } }); module.exports = router; \ No newline at end of file From 9644c47f2975e25ee0dcc307a54ee1478fc11259 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Thu, 15 Oct 2020 17:35:23 +0100 Subject: [PATCH 050/144] Change to allow BC platforms to have multiple redirect uris --- src/config/configuration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index e53970b3..3cf264c4 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -34,7 +34,7 @@ export const clients = [ //grant_types: ['authorization_code', 'implicit'], response_types: ['code'], //response_types: ['code'], - redirect_uris: [process.env.BCPRedirectURI || ''], + redirect_uris: process.env.BCPRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256' } ]; From cc32856da4dd6abc672919caa33e78067e3bcf54 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 16 Oct 2020 11:18:36 +0100 Subject: [PATCH 051/144] Fixed defect detecting active step reviewer --- src/resources/datarequest/datarequest.controller.js | 5 ++--- src/resources/publisher/publisher.controller.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index bb9de028..a66c6b44 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -134,9 +134,8 @@ module.exports = { // 8. Get the workflow/voting status let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); // 9. Check if the current user can override the current step - let isManager = false; if(_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { - isManager = teamController.checkTeamPermissions( + let isManager = teamController.checkTeamPermissions( teamController.roleTypes.MANAGER, accessRecord.datasets[0].publisher.team.toObject(), req.user._id @@ -1670,7 +1669,7 @@ module.exports = { }); if (activeStep) { isActiveStepReviewer = activeStep.reviewers.some( - (reviewer) => reviewer.toString() === userId.toString() + (reviewer) => reviewer._id.toString() === userId.toString() ); reviewSections = [...activeStep.sections]; } diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index e5ac828b..931907e2 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -291,4 +291,4 @@ module.exports = { }); } }, -}; +}; \ No newline at end of file From bd762dfdd7684239531ef87da6915b5d29c71725 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 16 Oct 2020 15:00:42 +0100 Subject: [PATCH 052/144] Added endpoint to search any entity by tag --- src/resources/project/project.route.js | 253 +++++---- src/resources/tool/tool.route.js | 696 +++++++++++++------------ 2 files changed, 518 insertions(+), 431 deletions(-) diff --git a/src/resources/project/project.route.js b/src/resources/project/project.route.js index b5c4b444..9162c9d2 100644 --- a/src/resources/project/project.route.js +++ b/src/resources/project/project.route.js @@ -1,144 +1,183 @@ -import express from 'express' -import { Data } from '../tool/data.model' -import { ROLES } from '../user/user.roles' -import passport from "passport"; -import { utils } from "../auth"; -import {addTool, editTool, deleteTool, setStatus, getTools, getToolsAdmin} from '../tool/data.repository'; +import express from 'express'; +import { Data } from '../tool/data.model'; +import { ROLES } from '../user/user.roles'; +import passport from 'passport'; +import { utils } from '../auth'; +import { + addTool, + editTool, + deleteTool, + setStatus, + getTools, + getToolsAdmin, +} from '../tool/data.repository'; const router = express.Router(); // @router POST /api/v1/ // @desc Add project user // @access Private -router.post('/', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await addTool(req) - .then(response => { - return res.json({ success: true, response}); - }) - .catch(err => { - return res.json({ success: false, err}); - }) - } +router.post( + '/', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await addTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router GET /api/v1/ // @desc Returns List of Project Objects Authenticated // @access Private router.get( - '/getList', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - req.params.type = 'project'; - let role = req.user.role; + '/getList', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + req.params.type = 'project'; + let role = req.user.role; - if (role === ROLES.Admin) { - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } else if (role === ROLES.Creator) { - await getTools(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } - } + if (role === ROLES.Admin) { + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } else if (role === ROLES.Creator) { + await getTools(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } + } ); // @router GET /api/v1/ // @desc Returns List of Project Objects No auth // This unauthenticated route was created specifically for API-docs // @access Public -router.get( - '/', - async (req, res) => { - req.params.type = 'project'; - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } -); +router.get('/', async (req, res) => { + req.params.type = 'project'; + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); +}); // @router GET /api/v1/ // @desc Returns a Project object // @access Public router.get('/:projectID', async (req, res) => { - var q = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.projectID) }, {type: 'project'}] } }, - { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } } - ]); - q.exec((err, data) => { - if (data.length > 0) { - var p = Data.aggregate([ - { $match: { $and: [{ "relatedObjects": { $elemMatch: { "objectId": req.params.projectID } } }] } }, - ]); + var q = Data.aggregate([ + { + $match: { + $and: [{ id: parseInt(req.params.projectID) }, { type: 'project' }], + }, + }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + ]); + q.exec((err, data) => { + if (data.length > 0) { + var p = Data.aggregate([ + { + $match: { + $and: [ + { + relatedObjects: { + $elemMatch: { objectId: req.params.projectID }, + }, + }, + ], + }, + }, + ]); - p.exec( async (err, relatedData) => { - relatedData.forEach((dat) => { - dat.relatedObjects.forEach((x) => { - if (x.objectId === req.params.projectID && dat.id !== req.params.projectID) { - if (typeof data[0].relatedObjects === "undefined") data[0].relatedObjects=[]; - data[0].relatedObjects.push({ objectId: dat.id, reason: x.reason, objectType: dat.type, user: x.user, updated: x.updated }) - } - }) - }); + p.exec(async (err, relatedData) => { + relatedData.forEach((dat) => { + dat.relatedObjects.forEach((x) => { + if ( + x.objectId === req.params.projectID && + dat.id !== req.params.projectID + ) { + if (typeof data[0].relatedObjects === 'undefined') + data[0].relatedObjects = []; + data[0].relatedObjects.push({ + objectId: dat.id, + reason: x.reason, + objectType: dat.type, + user: x.user, + updated: x.updated, + }); + } + }); + }); - if (err) return res.json({ success: false, error: err }); - return res.json({ success: true, data: data }); - }); - } - else{ - return res.status(404).send(`Project not found for Id: ${req.params.projectID}`); - } - }); + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true, data: data }); + }); + } else { + return res + .status(404) + .send(`Project not found for Id: ${req.params.projectID}`); + } + }); }); // @router PATCH /api/v1/status // @desc Set project status // @access Private -router.patch('/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - await setStatus(req) - .then(response => { - return res.json({success: true, response}); - }) - .catch(err => { - return res.json({success: false, err}); - }); - } +router.patch( + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + await setStatus(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router PUT /api/v1/ -// @desc Edit project +// @desc Edit project // @access Private -router.put('/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await editTool(req) - .then(response => { - return res.json({ success: true, response}); - }) - .catch(err => { - return res.json({ success: false, err}); - }) - } +router.put( + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await editTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/resources/tool/tool.route.js b/src/resources/tool/tool.route.js index 2d88909d..b5e5029d 100644 --- a/src/resources/tool/tool.route.js +++ b/src/resources/tool/tool.route.js @@ -7,15 +7,16 @@ import { utils } from '../auth'; import { UserModel } from '../user/user.model'; import { MessagesModel } from '../message/message.model'; import { - addTool, - editTool, - deleteTool, - setStatus, - getTools, - getToolsAdmin, + addTool, + editTool, + deleteTool, + setStatus, + getTools, + getToolsAdmin, } from '../tool/data.repository'; import emailGenerator from '../utilities/emailGenerator.util'; import inputSanitizer from '../utilities/inputSanitizer'; +import _ from 'lodash'; const hdrukEmail = `enquiry@healthdatagateway.org`; const router = express.Router(); @@ -23,18 +24,18 @@ const router = express.Router(); // @desc Add tools user // @access Private router.post( - '/', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await addTool(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } + '/', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await addTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router PUT /api/v1/{id} @@ -42,85 +43,82 @@ router.post( // @access Private // router.put('/{id}', router.put( - '/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await editTool(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, error: err.message }); - }); - } + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await editTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, error: err.message }); + }); + } ); // @router GET /api/v1/get/admin // @desc Returns List of Tool objects // @access Private router.get( - '/getList', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - req.params.type = 'tool'; - let role = req.user.role; + '/getList', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + req.params.type = 'tool'; + let role = req.user.role; - if (role === ROLES.Admin) { - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } else if (role === ROLES.Creator) { - await getTools(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } - } + if (role === ROLES.Admin) { + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } else if (role === ROLES.Creator) { + await getTools(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } + } ); // @router GET /api/v1/ // @desc Returns List of Tool Objects No auth // This unauthenticated route was created specifically for API-docs // @access Public -router.get( - '/', - async (req, res) => { - req.params.type = 'tool'; - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } -); +router.get('/', async (req, res) => { + req.params.type = 'tool'; + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); +}); // @router PATCH /api/v1/status // @desc Set tool status // @access Private router.patch( - '/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - await setStatus(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, error: err.message }); - }); - } + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + await setStatus(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, error: err.message }); + }); + } ); /** @@ -129,93 +127,96 @@ router.patch( * Return the details on the tool based on the tool ID. */ router.get('/:id', async (req, res) => { - var query = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.id) }, {type: 'tool'}]} }, - { - $lookup: { - from: 'tools', - localField: 'authors', - foreignField: 'id', - as: 'persons', - }, - }, - { - $lookup: { - from: 'tools', - localField: 'uploader', - foreignField: 'id', - as: 'uploaderIs', - }, - }, - ]); - query.exec((err, data) => { - if (data.length > 0) { - var p = Data.aggregate([ - { - $match: { - $and: [ - { relatedObjects: { $elemMatch: { objectId: req.params.id } } }, - ], - }, - }, - ]); - p.exec((err, relatedData) => { - relatedData.forEach((dat) => { - dat.relatedObjects.forEach((x) => { - if (x.objectId === req.params.id && dat.id !== req.params.id) { - let relatedObject = { - objectId: dat.id, - reason: x.reason, - objectType: dat.type, - user: x.user, - updated: x.updated - }; - data[0].relatedObjects = [relatedObject, ...data[0].relatedObjects || []]; - } - }); - }); + var query = Data.aggregate([ + { $match: { $and: [{ id: parseInt(req.params.id) }, { type: 'tool' }] } }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + { + $lookup: { + from: 'tools', + localField: 'uploader', + foreignField: 'id', + as: 'uploaderIs', + }, + }, + ]); + query.exec((err, data) => { + if (data.length > 0) { + var p = Data.aggregate([ + { + $match: { + $and: [ + { relatedObjects: { $elemMatch: { objectId: req.params.id } } }, + ], + }, + }, + ]); + p.exec((err, relatedData) => { + relatedData.forEach((dat) => { + dat.relatedObjects.forEach((x) => { + if (x.objectId === req.params.id && dat.id !== req.params.id) { + let relatedObject = { + objectId: dat.id, + reason: x.reason, + objectType: dat.type, + user: x.user, + updated: x.updated, + }; + data[0].relatedObjects = [ + relatedObject, + ...(data[0].relatedObjects || []), + ]; + } + }); + }); - var r = Reviews.aggregate([ - { - $match: { - $and: [ - { toolID: parseInt(req.params.id) }, - { activeflag: 'active' }, - ], - }, - }, - { $sort: { date: -1 } }, - { - $lookup: { - from: 'tools', - localField: 'reviewerID', - foreignField: 'id', - as: 'person', - }, - }, - { - $lookup: { - from: 'tools', - localField: 'replierID', - foreignField: 'id', - as: 'owner', - }, - }, - ]); - r.exec(async (err, reviewData) => { - if (err) return res.json({ success: false, error: err }); + var r = Reviews.aggregate([ + { + $match: { + $and: [ + { toolID: parseInt(req.params.id) }, + { activeflag: 'active' }, + ], + }, + }, + { $sort: { date: -1 } }, + { + $lookup: { + from: 'tools', + localField: 'reviewerID', + foreignField: 'id', + as: 'person', + }, + }, + { + $lookup: { + from: 'tools', + localField: 'replierID', + foreignField: 'id', + as: 'owner', + }, + }, + ]); + r.exec(async (err, reviewData) => { + if (err) return res.json({ success: false, error: err }); - return res.json({ - success: true, - data: data, - reviewData: reviewData - }); - }); - }); - } else { - return res.status(404).send(`Tool not found for Id: ${req.params.id}`); - } - }); + return res.json({ + success: true, + data: data, + reviewData: reviewData, + }); + }); + }); + } else { + return res.status(404).send(`Tool not found for Id: ${req.params.id}`); + } + }); }); /** @@ -224,27 +225,27 @@ router.get('/:id', async (req, res) => { * Return the details on the tool based on the tool ID for edit. */ router.get('/edit/:id', async (req, res) => { - var query = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.id) }] } }, - { - $lookup: { - from: 'tools', - localField: 'authors', - foreignField: 'id', - as: 'persons', - }, - }, - ]); - query.exec((err, data) => { - if (data.length > 0) { - return res.json({ success: true, data: data }); - } else { - return res.json({ - success: false, - error: `Tool not found for tool id ${req.params.id}`, - }); - } - }); + var query = Data.aggregate([ + { $match: { $and: [{ id: parseInt(req.params.id) }] } }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + ]); + query.exec((err, data) => { + if (data.length > 0) { + return res.json({ success: true, data: data }); + } else { + return res.json({ + success: false, + error: `Tool not found for tool id ${req.params.id}`, + }); + } + }); }); /** @@ -255,30 +256,30 @@ router.get('/edit/:id', async (req, res) => { * We will also check the review (Free word entry) for exclusion data (node module?) */ router.post( - '/review/add', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - let reviews = new Reviews(); - const { toolID, reviewerID, rating, projectName, review } = req.body; + '/review/add', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + let reviews = new Reviews(); + const { toolID, reviewerID, rating, projectName, review } = req.body; - reviews.reviewID = parseInt(Math.random().toString().replace('0.', '')); - reviews.toolID = toolID; - reviews.reviewerID = reviewerID; - reviews.rating = rating; - reviews.projectName = inputSanitizer.removeNonBreakingSpaces(projectName); - reviews.review = inputSanitizer.removeNonBreakingSpaces(review); - reviews.activeflag = 'review'; - reviews.date = Date.now(); + reviews.reviewID = parseInt(Math.random().toString().replace('0.', '')); + reviews.toolID = toolID; + reviews.reviewerID = reviewerID; + reviews.rating = rating; + reviews.projectName = inputSanitizer.removeNonBreakingSpaces(projectName); + reviews.review = inputSanitizer.removeNonBreakingSpaces(review); + reviews.activeflag = 'review'; + reviews.date = Date.now(); - reviews.save(async (err) => { - if (err) { - return res.json({ success: false, error: err }); - } else { - return res.json({ success: true, id: reviews.reviewID }); - } - }); - } + reviews.save(async (err) => { + if (err) { + return res.json({ success: false, error: err }); + } else { + return res.json({ success: true, id: reviews.reviewID }); + } + }); + } ); /** @@ -289,56 +290,56 @@ router.post( * We will also check the review (Free word entry) for exclusion data (node module?) */ router.post( - '/reply', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - const { reviewID, replierID, reply } = req.body; - Reviews.findOneAndUpdate( - { reviewID: reviewID }, - { - replierID: replierID, - reply: inputSanitizer.removeNonBreakingSpaces(reply), - replydate: Date.now(), - }, - (err) => { - if (err) return res.json({ success: false, error: err }); - return res.json({ success: true }); - } - ); - } + '/reply', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + const { reviewID, replierID, reply } = req.body; + Reviews.findOneAndUpdate( + { reviewID: reviewID }, + { + replierID: replierID, + reply: inputSanitizer.removeNonBreakingSpaces(reply), + replydate: Date.now(), + }, + (err) => { + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true }); + } + ); + } ); - + /** * {post} /tool/review/approve Approve review * * Authenticate user to see if user can approve. */ router.put( - '/review/approve', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - const { id, activeflag } = req.body; - Reviews.findOneAndUpdate( - { reviewID: id }, - { - activeflag: activeflag, - }, - (err) => { - if (err) return res.json({ success: false, error: err }); + '/review/approve', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + const { id, activeflag } = req.body; + Reviews.findOneAndUpdate( + { reviewID: id }, + { + activeflag: activeflag, + }, + (err) => { + if (err) return res.json({ success: false, error: err }); - return res.json({ success: true }); - } - ).then(async (res) => { - const review = await Reviews.findOne({ reviewID: id }); + return res.json({ success: true }); + } + ).then(async (res) => { + const review = await Reviews.findOne({ reviewID: id }); - await storeNotificationMessages(review); + await storeNotificationMessages(review); - // Send email notififcation of approval to authors and admins who have opted in - await sendEmailNotifications(review); - }); - } + // Send email notififcation of approval to authors and admins who have opted in + await sendEmailNotifications(review); + }); + } ); /** @@ -347,16 +348,16 @@ router.put( * Authenticate user to see if user can reject. */ router.delete( - '/review/reject', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - const { id } = req.body; - Reviews.findOneAndDelete({ reviewID: id }, (err) => { - if (err) return res.send(err); - return res.json({ success: true }); - }); - } + '/review/reject', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + const { id } = req.body; + Reviews.findOneAndDelete({ reviewID: id }, (err) => { + if (err) return res.send(err); + return res.json({ success: true }); + }); + } ); /** @@ -365,16 +366,16 @@ router.delete( * When they delete, authenticate the user and remove the review data from the DB. */ router.delete( - '/review/delete', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - const { id } = req.body; - Data.findOneAndDelete({ id: id }, (err) => { - if (err) return res.send(err); - return res.json({ success: true }); - }); - } + '/review/delete', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + const { id } = req.body; + Data.findOneAndDelete({ id: id }, (err) => { + if (err) return res.send(err); + return res.json({ success: true }); + }); + } ); //Validation required if Delete is to be implemented @@ -392,70 +393,117 @@ router.delete( // } // ); +// @router GET /api/v1/project/tag/name +// @desc Get tools by tag search +// @access Public +router.get('/:type/tag/:name', async (req, res) => { + try { + // 1. Destructure tag name parameter passed + let { type, name } = req.params; + // 2. Check if parameters are empty + if (_.isEmpty(name) || _.isEmpty(type)) { + return res + .status(400) + .json({ success: false, message: 'Entity type and tag are required' }); + } + // 3. Find matching projects in MongoDb selecting name and id + let entities = await Data.find({ + $and: [ + { type }, + { $or: [{ 'tags.topics': name }, { 'tags.features': name }] }, + ], + }).select('id name'); + // 4. Return projects + return res.status(200).json({ success: true, entities }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for tools by tag', + }); + } +}); + module.exports = router; async function storeNotificationMessages(review) { - const tool = await Data.findOne({ id: review.toolID }); - //Get reviewer name - const reviewer = await UserModel.findOne({ id: review.reviewerID }); - const toolLink = - process.env.homeURL + '/tool/' + review.toolID + '/' + tool.name; - //admins - let message = new MessagesModel(); - message.messageID = parseInt(Math.random().toString().replace('0.', '')); - message.messageTo = 0; - message.messageObjectID = review.toolID; - message.messageType = 'review'; - message.messageSent = Date.now(); - message.isRead = false; - message.messageDescription = `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name} ${toolLink}`; + const tool = await Data.findOne({ id: review.toolID }); + //Get reviewer name + const reviewer = await UserModel.findOne({ id: review.reviewerID }); + const toolLink = + process.env.homeURL + '/tool/' + review.toolID + '/' + tool.name; + //admins + let message = new MessagesModel(); + message.messageID = parseInt(Math.random().toString().replace('0.', '')); + message.messageTo = 0; + message.messageObjectID = review.toolID; + message.messageType = 'review'; + message.messageSent = Date.now(); + message.isRead = false; + message.messageDescription = `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name} ${toolLink}`; - await message.save(async (err) => { - if (err) { - return new Error({ success: false, error: err }); - } - }); - //authors - const authors = tool.authors; - authors.forEach(async (author) => { - message.messageTo = author; - await message.save(async (err) => { - if (err) { - return new Error({ success: false, error: err }); - } - }); - }); - return { success: true, id: message.messageID }; + await message.save(async (err) => { + if (err) { + return new Error({ success: false, error: err }); + } + }); + //authors + const authors = tool.authors; + authors.forEach(async (author) => { + message.messageTo = author; + await message.save(async (err) => { + if (err) { + return new Error({ success: false, error: err }); + } + }); + }); + return { success: true, id: message.messageID }; } async function sendEmailNotifications(review) { - // 1. Retrieve tool for authors and reviewer user plus generate URL for linking tool - const tool = await Data.findOne({ id: review.toolID }); - const reviewer = await UserModel.findOne({ id: review.reviewerID }); - const toolLink = process.env.homeURL + '/tool/' + tool.id + '/' + tool.name; + // 1. Retrieve tool for authors and reviewer user plus generate URL for linking tool + const tool = await Data.findOne({ id: review.toolID }); + const reviewer = await UserModel.findOne({ id: review.reviewerID }); + const toolLink = process.env.homeURL + '/tool/' + tool.id + '/' + tool.name; - // 2. Query Db for all admins or authors of the tool who have opted in to email updates - var q = UserModel.aggregate([ - // Find all users who are admins or authors of this tool - { $match: { $or: [{ role: 'Admin' }, { id: { $in: tool.authors } }] } }, - // Perform lookup to check opt in/out flag in tools schema - { $lookup: { from: 'tools', localField: 'id', foreignField: 'id', as: 'tool' } }, - // Filter out any user who has opted out of email notifications - { $match: { 'tool.emailNotifications': true } }, - // Reduce response payload size to required fields - { $project: { _id: 1, firstname: 1, lastname: 1, email: 1, role: 1, 'tool.emailNotifications': 1 } } - ]); + // 2. Query Db for all admins or authors of the tool who have opted in to email updates + var q = UserModel.aggregate([ + // Find all users who are admins or authors of this tool + { $match: { $or: [{ role: 'Admin' }, { id: { $in: tool.authors } }] } }, + // Perform lookup to check opt in/out flag in tools schema + { + $lookup: { + from: 'tools', + localField: 'id', + foreignField: 'id', + as: 'tool', + }, + }, + // Filter out any user who has opted out of email notifications + { $match: { 'tool.emailNotifications': true } }, + // Reduce response payload size to required fields + { + $project: { + _id: 1, + firstname: 1, + lastname: 1, + email: 1, + role: 1, + 'tool.emailNotifications': 1, + }, + }, + ]); - // 3. Use the returned array of email recipients to generate and send emails with SendGrid - q.exec((err, emailRecipients) => { - if (err) { - return new Error({ success: false, error: err }); - } - emailGenerator.sendEmail( - emailRecipients, - `${hdrukEmail}`, - `Someone reviewed your tool`, - `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name}

${toolLink}` - ); - }); + // 3. Use the returned array of email recipients to generate and send emails with SendGrid + q.exec((err, emailRecipients) => { + if (err) { + return new Error({ success: false, error: err }); + } + emailGenerator.sendEmail( + emailRecipients, + `${hdrukEmail}`, + `Someone reviewed your tool`, + `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name}

${toolLink}` + ); + }); } From b07cd8a8b9b39d93a198be1b3930361bc9215bbc Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Fri, 16 Oct 2020 15:25:55 +0100 Subject: [PATCH 053/144] Update to logout code --- src/config/configuration.js | 45 +++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/config/configuration.js b/src/config/configuration.js index 3cf264c4..b9d05eb1 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -63,6 +63,11 @@ export const features = { introspection: { enabled: true }, revocation: { enabled: true }, encryption: { enabled: true }, + rpInitiatedLogout: { + enabled: true, + logoutSource, + postLogoutSuccessSource + } }; export const jwks = require('./jwks.json'); @@ -74,3 +79,43 @@ export const ttl = { DeviceCode: 10 * 60, RefreshToken: 1 * 24 * 60 * 60, }; + +async function logoutSource(ctx, form) { + // @param ctx - koa request context + // @param form - form source (id="op.logoutForm") to be embedded in the page and submitted by + // the End-User + ctx.body = ` + + Logout Request + + + +
+

Do you want to sign-out from ${ctx.host}?

+ ${form} + + +
+ + `; + } + + async function postLogoutSuccessSource(ctx) { + // @param ctx - koa request context + const { + clientId, clientName, clientUri, initiateLoginUri, logoUri, policyUri, tosUri, + } = ctx.oidc.client || {}; // client is defined if the user chose to stay logged in with the OP + const display = clientName || clientId; + ctx.body = ` + + Sign-out Success + + + +
+

Sign-out Success

+

Your sign-out ${display ? `with ${display}` : ''} was successful.

+
+ + `; + } \ No newline at end of file From cf1790f171e92c52652edf090801d8b703488b78 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 16 Oct 2020 16:21:55 +0100 Subject: [PATCH 054/144] Added hasRecommended flag --- .../datarequest/datarequest.controller.js | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 5c09a902..0af4eb96 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -127,7 +127,7 @@ module.exports = { readOnly = false; } // 7. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections } = module.exports.getReviewStatus( + let { inReviewMode, reviewSections, hasRecommended } = module.exports.getReviewStatus( accessRecord, req.user._id ); @@ -150,6 +150,7 @@ module.exports = { helper.generateFriendlyId(accessRecord._id), inReviewMode, reviewSections, + hasRecommended, workflow, }, }); @@ -1672,7 +1673,8 @@ module.exports = { getReviewStatus: (application, userId) => { let inReviewMode = false, reviewSections = [], - isActiveStepReviewer = false; + isActiveStepReviewer = false, + hasRecommended = false; // Get current application status let { applicationStatus } = application; // Check if the current user is a reviewer on the current step of an attached workflow @@ -1687,6 +1689,13 @@ module.exports = { (reviewer) => reviewer.toString() === userId.toString() ); reviewSections = [...activeStep.sections]; + + let { recommendations = [] } = activeStep; + if(!_.isEmpty(recommendations)) { + hasRecommended = recommendations.some( + (rec) => rec.reviewer.toString() === userId.toString() + ); + } } } // Return active review mode if conditions apply @@ -1694,7 +1703,7 @@ module.exports = { inReviewMode = true; } - return { inReviewMode, reviewSections }; + return { inReviewMode, reviewSections, hasRecommended }; }, getWorkflowStatus: (application) => { @@ -1710,13 +1719,13 @@ module.exports = { if (activeStep) { let { reviewStatus, - deadlinePassed, + deadlinePassed } = module.exports.getActiveStepStatus(activeStep); //Update active step with review status steps[activeStepIndex] = { ...steps[activeStepIndex], reviewStatus, - deadlinePassed, + deadlinePassed }; } //Update steps with user friendly review sections @@ -1831,7 +1840,7 @@ module.exports = { decisionDate, decisionStatus, decisionComments, - reviewPanels, + reviewPanels }; }, From dc444295a7a8dfe37c5733b027105a9ea1a2f82c Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Mon, 19 Oct 2020 09:47:57 +0100 Subject: [PATCH 055/144] Update to log out and destory the odic session when calling /api/v1/openid/endsession endpoint --- src/config/server.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/config/server.js b/src/config/server.js index a0c461be..c7c0d458 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -77,6 +77,20 @@ function setNoCache(req, res, next) { next(); } +app.get('/api/v1/openid/endsession', setNoCache, (req, res, next) => { + passport.authenticate('jwt', async function (err, user, info) { + if (err || !user) { + return res.status(200).redirect(process.env.homeURL+'/search?search='); + } + oidc.Session.destory; + req.logout(); + res.clearCookie('jwt'); + + return res.status(200).redirect(process.env.homeURL+'/search?search='); + })(req, res, next); +}) + + app.get('/api/v1/openid/interaction/:uid', setNoCache, (req, res, next) => { passport.authenticate('jwt', async function (err, user, info) { From 65e8e23781be2a8ae1da7cf4fc5dacb12a574832 Mon Sep 17 00:00:00 2001 From: Alex Power Date: Mon, 19 Oct 2020 13:24:11 +0100 Subject: [PATCH 056/144] Added token generation for camunda --- src/resources/auth/strategies/jwt.js | 4 + src/resources/auth/utils.js | 13 ++- .../bpmnworkflow/bpmnworkflow.controller.js | 21 ++-- src/resources/user/user.roles.js | 3 +- src/resources/user/user.route.js | 98 +++++++++++-------- 5 files changed, 90 insertions(+), 49 deletions(-) diff --git a/src/resources/auth/strategies/jwt.js b/src/resources/auth/strategies/jwt.js index 7c660faf..e7f66b42 100644 --- a/src/resources/auth/strategies/jwt.js +++ b/src/resources/auth/strategies/jwt.js @@ -14,9 +14,13 @@ const strategy = () => { } const verifyCallback = async (req, jwtPayload, cb) => { + if(typeof jwtPayload.data === 'string') { + jwtPayload.data = JSON.parse(jwtPayload.data); + } const [err, user] = await to(getUserById(jwtPayload.data._id)) if (err) { + console.log("error"); return cb(err) } req.user = user diff --git a/src/resources/auth/utils.js b/src/resources/auth/utils.js index f447278c..d3b60a9f 100644 --- a/src/resources/auth/utils.js +++ b/src/resources/auth/utils.js @@ -28,6 +28,17 @@ const signToken = (user) => { ) } +const camundaToken = () => { + return jwt.sign( + { username: "HDRAdmin", groupIds: ["camunda-admin"], tenantIds: []}, + process.env.JWTSecret, + { //Here change it so only id + algorithm: 'HS256', + expiresIn: 604800 + } + ) +} + const hashPassword = async password => { if (!password) { throw new Error('Password was not provided') @@ -74,4 +85,4 @@ const getRedirectUrl = role => { } } -export { setup, signToken, hashPassword, verifyPassword, checkIsInRole, getRedirectUrl, whatIsRole } \ No newline at end of file +export { setup, signToken, camundaToken, hashPassword, verifyPassword, checkIsInRole, getRedirectUrl, whatIsRole } \ No newline at end of file diff --git a/src/resources/bpmnworkflow/bpmnworkflow.controller.js b/src/resources/bpmnworkflow/bpmnworkflow.controller.js index 4430e7b6..20726352 100644 --- a/src/resources/bpmnworkflow/bpmnworkflow.controller.js +++ b/src/resources/bpmnworkflow/bpmnworkflow.controller.js @@ -1,17 +1,22 @@ import axios from 'axios'; import axiosRetry from 'axios-retry'; import _ from 'lodash'; +import { utils } from "../auth"; axiosRetry(axios, { retries: 3, retryDelay: () => { return 3000; }}); const bpmnBaseUrl = process.env.BPMNBASEURL; +//Generate Bearer token for camunda endpoints +const config = { + headers: { Authorization: `Bearer ${utils.camundaToken()}` }, +}; module.exports = { //Generic Get Task Process Endpoints getProcess: async (businessKey) => { - return await axios.get(`${bpmnBaseUrl}/engine-rest/task?processInstanceBusinessKey=${businessKey.toString()}`); + return await axios.get(`${bpmnBaseUrl}/engine-rest/task?processInstanceBusinessKey=${businessKey.toString()}`, config); }, //Simple Workflow Endpoints @@ -39,7 +44,7 @@ module.exports = { }, "businessKey": businessKey.toString() } - await axios.post(`${bpmnBaseUrl}/engine-rest/process-definition/key/GatewayWorkflowSimple/start`, data) + await axios.post(`${bpmnBaseUrl}/engine-rest/process-definition/key/GatewayWorkflowSimple/start`, data, config) .catch((err) => { console.error(err); }); @@ -71,7 +76,7 @@ module.exports = { } } } - await axios.post(`${bpmnBaseUrl}/engine-rest/task/${taskId}/complete`, data) + await axios.post(`${bpmnBaseUrl}/engine-rest/task/${taskId}/complete`, data, config) .catch((err) => { console.error(err); }); @@ -98,7 +103,7 @@ module.exports = { }, "businessKey": businessKey.toString() } - await axios.post(`${bpmnBaseUrl}/engine-rest/process-definition/key/GatewayReviewWorkflowComplex/start`, data) + await axios.post(`${bpmnBaseUrl}/engine-rest/process-definition/key/GatewayReviewWorkflowComplex/start`, data, config) .catch((err) => { console.error(err); }); @@ -126,7 +131,7 @@ module.exports = { } } } - await axios.post(`${bpmnBaseUrl}/engine-rest/task/${taskId}/complete`, data) + await axios.post(`${bpmnBaseUrl}/engine-rest/task/${taskId}/complete`, data, config) .catch((err) => { console.error(err); }); @@ -134,7 +139,7 @@ module.exports = { postManagerApproval: async (bpmContext) => { // Manager has approved sectoin let { businessKey } = bpmContext; - await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/manager/completed/${businessKey}`, bpmContext) + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/manager/completed/${businessKey}`, bpmContext. config) .catch((err) => { console.error(err); }) @@ -142,7 +147,7 @@ module.exports = { postStartStepReview: async (bpmContext) => { //Start Step-Review process let { businessKey } = bpmContext; - await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/complete/review/${businessKey}`, bpmContext) + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/complete/review/${businessKey}`, bpmContext, config) .catch((err) => { console.error(err); }); @@ -150,7 +155,7 @@ module.exports = { postCompleteReview: async (bpmContext) => { //Start Next-Step process let { businessKey } = bpmContext; - await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/reviewer/complete/${businessKey}`, bpmContext) + await axios.post(`${bpmnBaseUrl}/api/gateway/workflow/v1/reviewer/complete/${businessKey}`, bpmContext, config) .catch((err) => { console.error(err); }); diff --git a/src/resources/user/user.roles.js b/src/resources/user/user.roles.js index c79e8bb6..3727108c 100644 --- a/src/resources/user/user.roles.js +++ b/src/resources/user/user.roles.js @@ -1,7 +1,8 @@ const ROLES = { Admin: 'Admin', DataCustodian: 'DataCustodian', - Creator: 'Creator' + Creator: 'Creator', + System: 'System' } export { ROLES } \ No newline at end of file diff --git a/src/resources/user/user.route.js b/src/resources/user/user.route.js index bb9a1a71..17cece13 100644 --- a/src/resources/user/user.route.js +++ b/src/resources/user/user.route.js @@ -12,48 +12,68 @@ const router = express.Router(); // @desc find user by id // @access Private router.get( - '/:userID', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - //req.params.id is how you get the id from the url - var q = UserModel.find({ id: req.params.userID }); - - q.exec((err, userdata) => { - if (err) return res.json({ success: false, error: err }); - return res.json({ success: true, userdata: userdata }); - }); - }); + '/:userID', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + //req.params.id is how you get the id from the url + var q = UserModel.find({ id: req.params.userID }); + + q.exec((err, userdata) => { + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true, userdata: userdata }); + }); + } +); // @router GET /api/v1/users // @desc get all // @access Private -router.get('/', async (req, res) => { - - var q = Data.aggregate([ - // Find all tools with type of person - { $match: { type: 'person' } }, - // Perform lookup to users - { $lookup: { from: 'users', localField: 'id', foreignField: 'id', as: 'user' } }, - // select fields to use - { $project: { id: 1, firstname: 1, lastname: 1, orcid: 1, bio: 1, email: '$user.email' } }, - ]); - - q.exec((err, data) => { - if (err) { - return new Error({ success: false, error: err }); - } - - const users = []; - data.map((dat) => { - let { id, firstname, lastname, orcid = '', bio = '', email = '' } = dat; - if (email.length !== 0) email = helper.censorEmail(email[0]); - users.push({ id, orcid, name: `${firstname} ${lastname}`, bio, email }); - }); - - return res.json({ success: true, data: users }); - - }); -}); +router.get( + '/', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + var q = Data.aggregate([ + // Find all tools with type of person + { $match: { type: 'person' } }, + // Perform lookup to users + { + $lookup: { + from: 'users', + localField: 'id', + foreignField: 'id', + as: 'user', + }, + }, + // select fields to use + { + $project: { + id: 1, + firstname: 1, + lastname: 1, + orcid: 1, + bio: 1, + email: '$user.email', + }, + }, + ]); + + q.exec((err, data) => { + if (err) { + return new Error({ success: false, error: err }); + } + + const users = []; + data.map((dat) => { + let { id, firstname, lastname, orcid = '', bio = '', email = '' } = dat; + if (email.length !== 0) email = helper.censorEmail(email[0]); + users.push({ id, orcid, name: `${firstname} ${lastname}`, bio, email }); + }); + + return res.json({ success: true, data: users }); + }); + } +); module.exports = router \ No newline at end of file From eeababd1a195adea9a0cae3ecb2a58612c2a751d Mon Sep 17 00:00:00 2001 From: Alex Power Date: Mon, 19 Oct 2020 14:19:32 +0100 Subject: [PATCH 057/144] Updated camunda token generator --- src/resources/auth/utils.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/resources/auth/utils.js b/src/resources/auth/utils.js index d3b60a9f..1013b8ad 100644 --- a/src/resources/auth/utils.js +++ b/src/resources/auth/utils.js @@ -30,7 +30,10 @@ const signToken = (user) => { const camundaToken = () => { return jwt.sign( - { username: "HDRAdmin", groupIds: ["camunda-admin"], tenantIds: []}, + // This structure must not change or the authenication between camunda and the gateway will fail + // username: An admin user the exists within the camunda-admin group + // groupIds: The admin group that has been configured on the camunda portal. + { username: process.env.BPMN_ADMIN_USER, groupIds: ["camunda-admin"], tenantIds: []}, process.env.JWTSecret, { //Here change it so only id algorithm: 'HS256', From 3058d5ce5cb7f2ce1fb084d2dd0bad8d596263cf Mon Sep 17 00:00:00 2001 From: Alex Power Date: Mon, 19 Oct 2020 14:20:48 +0100 Subject: [PATCH 058/144] Removed log --- src/resources/auth/strategies/jwt.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/resources/auth/strategies/jwt.js b/src/resources/auth/strategies/jwt.js index e7f66b42..e91adef5 100644 --- a/src/resources/auth/strategies/jwt.js +++ b/src/resources/auth/strategies/jwt.js @@ -20,7 +20,6 @@ const strategy = () => { const [err, user] = await to(getUserById(jwtPayload.data._id)) if (err) { - console.log("error"); return cb(err) } req.user = user From 92e4da5df504908c8fb88fd07b07bdd2ee3da1dc Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 19 Oct 2020 15:07:53 +0100 Subject: [PATCH 059/144] Added security to entity extraction by tag endpoint --- src/resources/tool/tool.route.js | 60 ++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/resources/tool/tool.route.js b/src/resources/tool/tool.route.js index b5e5029d..95c5de9c 100644 --- a/src/resources/tool/tool.route.js +++ b/src/resources/tool/tool.route.js @@ -395,34 +395,42 @@ router.delete( // @router GET /api/v1/project/tag/name // @desc Get tools by tag search -// @access Public -router.get('/:type/tag/:name', async (req, res) => { - try { - // 1. Destructure tag name parameter passed - let { type, name } = req.params; - // 2. Check if parameters are empty - if (_.isEmpty(name) || _.isEmpty(type)) { - return res - .status(400) - .json({ success: false, message: 'Entity type and tag are required' }); +// @access Private +router.get( + '/:type/tag/:name', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + try { + // 1. Destructure tag name parameter passed + let { type, name } = req.params; + // 2. Check if parameters are empty + if (_.isEmpty(name) || _.isEmpty(type)) { + return res + .status(400) + .json({ + success: false, + message: 'Entity type and tag are required', + }); + } + // 3. Find matching projects in MongoDb selecting name and id + let entities = await Data.find({ + $and: [ + { type }, + { $or: [{ 'tags.topics': name }, { 'tags.features': name }] }, + ], + }).select('id name'); + // 4. Return projects + return res.status(200).json({ success: true, entities }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for tools by tag', + }); } - // 3. Find matching projects in MongoDb selecting name and id - let entities = await Data.find({ - $and: [ - { type }, - { $or: [{ 'tags.topics': name }, { 'tags.features': name }] }, - ], - }).select('id name'); - // 4. Return projects - return res.status(200).json({ success: true, entities }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred searching for tools by tag', - }); } -}); +); module.exports = router; From f5db5ef0388f7681e6811b9492e63359b06ae1ed Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 19 Oct 2020 15:12:32 +0100 Subject: [PATCH 060/144] Added firstname and lastname output to DAR console reviewers --- src/resources/publisher/publisher.controller.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 931907e2..7a0c2bf2 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -144,6 +144,10 @@ module.exports = { }, }, }, + { + path: 'workflow.steps.reviewers', + select: 'firstname lastname' + } ]); if (!isManager) { From 384bed817bff83548802b38a11a2328a50d5ade5 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 19 Oct 2020 16:14:24 +0100 Subject: [PATCH 061/144] Added check to canOverrideStep on DAR --- src/resources/datarequest/datarequest.controller.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 7c0dba69..02f9c757 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -141,7 +141,9 @@ module.exports = { req.user._id ); // Set the workflow override capability if there is an active step and user is a manager - workflow.canOverrideStep = !workflow.isCompleted && isManager; + if(!_.isEmpty(workflow)) { + workflow.canOverrideStep = !workflow.isCompleted && isManager; + } } // 10. Return application form return res.status(200).json({ From 935f83a8cfff90606199a537b1ab9d55474b6d79 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 19 Oct 2020 16:46:45 +0100 Subject: [PATCH 062/144] Added display sections output --- src/resources/publisher/publisher.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 7a0c2bf2..24c9597f 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -258,7 +258,7 @@ module.exports = { let formattedSteps = [...steps].reduce((arr, item) => { let step = { ...item, - sections: [...item.sections].map(section => helper.darPanelMapper[section]) + displaySections: [...item.sections].map(section => helper.darPanelMapper[section]) } arr.push(step); return arr; From b25f98e3d832eea2f1b084eae02e7163168ef28e Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Tue, 20 Oct 2020 10:29:01 +0100 Subject: [PATCH 063/144] Update publisher reviewe check --- src/resources/publisher/publisher.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 24c9597f..65b8361a 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -168,7 +168,7 @@ module.exports = { let elapsedSteps = [...steps].slice(0, activeStepIndex + 1); let found = elapsedSteps.some((step) => - step.reviewers.some((reviewer) => reviewer.equals(_id)) + step.reviewers.some((reviewer) => reviewer_.id.equals(_id)) ); if (found) { From cb9a7320544052e283a20e4e180bda2103d11e49 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 10:47:59 +0100 Subject: [PATCH 064/144] Continuing notification build out --- .../datarequest/datarequest.controller.js | 272 +++++------------- src/resources/team/team.controller.js | 10 +- src/resources/workflow/workflow.controller.js | 203 +++++++++++++ 3 files changed, 277 insertions(+), 208 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 02f9c757..6139a737 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -127,12 +127,12 @@ module.exports = { readOnly = false; } // 7. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections, hasRecommended } = module.exports.getReviewStatus( + let { inReviewMode, reviewSections, hasRecommended } = workflowController.getReviewStatus( accessRecord, req.user._id ); // 8. Get the workflow/voting status - let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); + let workflow = workflowController.getWorkflowStatus(accessRecord.toObject()); // 9. Check if the current user can override the current step if(_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { let isManager = teamController.checkTeamPermissions( @@ -449,6 +449,10 @@ module.exports = { path: 'team', }, }, + { + path: 'workflow.steps.reviewers', + select: 'id email' + } ]); if (!accessRecord) { return res @@ -1075,11 +1079,13 @@ module.exports = { console.error(err); res.status(500).json({ status: 'error', message: err }); } else { - // 12. Call Camunda controller to start manager review process + // 12. Create notifications to reviewers of the step that has been completed + module.exports.createNotifications('StepOverride', {}, accessRecord, req.user); + // 13. Call Camunda controller to start manager review process bpmController.postCompleteReview(bpmContext); } }); - // 12. Return aplication and successful response + // 14. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { console.log(err.message); @@ -1173,7 +1179,7 @@ module.exports = { // Project details from about application if 5 Safes let aboutApplication = JSON.parse(accessRecord.aboutApplication); let { projectName } = aboutApplication; - let { projectId, _id } = accessRecord; + let { projectId, _id, workflow = {} } = accessRecord; if (_.isEmpty(projectId)) { projectId = _id; } @@ -1193,9 +1199,9 @@ module.exports = { // Requesting user let { firstname, lastname } = user; // Instantiate default params - let custodianUsers = [], - custodianManagers = [], + let custodianManagers = [], emailRecipients = [], + stepReviewers = [], options = {}, html = '', authors = []; @@ -1214,20 +1220,22 @@ module.exports = { return { firstname, lastname, email, id }; }); } - + switch (type) { // DAR application status has been updated case 'StatusChange': // 1. Create notifications - // Custodian team notifications + // Custodian manager and current step reviewer notifications if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { - // Retrieve all custodian user Ids to generate notifications - custodianUsers = [...accessRecord.datasets[0].publisher.team.users]; - let custodianUserIds = custodianUsers.map((user) => user.id); + // Retrieve all custodian manager user Ids and active step reviewers + custodianManagers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, teamController.roleTypes.MANAGER); + stepReviewers = workflowController.getActiveStepReviewers(workflow); + // Create custodian notification + let statusChangeUserIds = [...custodianManagers, ...stepReviewers].map((user) => user.id); await notificationBuilder.triggerNotificationMessage( - custodianUserIds, + statusChangeUserIds, `${appFirstName} ${appLastName}'s Data Access Request for ${datasetTitles} was ${context.applicationStatus} by ${firstname} ${lastname}`, 'data access request', accessRecord._id @@ -1255,7 +1263,8 @@ module.exports = { // Aggregate objects for custodian and applicant emailRecipients = [ accessRecord.mainApplicant, - ...custodianUsers, + ...custodianManagers, + ...stepReviewers, ...accessRecord.authors ]; let { dateSubmitted } = accessRecord; @@ -1299,7 +1308,7 @@ module.exports = { _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { // Retrieve all custodian user Ids to generate notifications - custodianManagers = teamController.getTeamManagers(accessRecord.datasets[0].publisher.team); + custodianManagers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, roleTypes.MANAGER); let custodianUserIds = custodianManagers.map((user) => user.id); await notificationBuilder.triggerNotificationMessage( custodianUserIds, @@ -1395,7 +1404,7 @@ module.exports = { // Notifications for added contributors if (!_.isEmpty(addedAuthors)) { options.change = 'added'; - html = await emailGenerator.generateContributorEmail(options); + html = emailGenerator.generateContributorEmail(options); // Find related user objects and filter out users who have not opted in to email communications let addedUsers = await UserModel.find({ id: { $in: addedAuthors }, @@ -1439,7 +1448,45 @@ module.exports = { ); } break; - } + case 'StepOverride': + if ( + _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') + ) { + // Retrieve all user Ids for active step reviewers + stepReviewers = workflowController.getActiveStepReviewers(workflow); + // 1. Create reviewer notifications + let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); + await notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + 'data access request', + accessRecord._id + ); + + // 2. Create reviewer emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + stepName, + reviewSections, + reviewers + }; + html = await emailGenerator.generateStepOverrideEmail(options); + await emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + html, + false + ); + } + break; + } }, getUserPermissionsForApplication: (application, userId, _id) => { @@ -1547,10 +1594,10 @@ module.exports = { } if (!_.isEmpty(workflow)) { ({ workflowName } = workflow); - workflowCompleted = module.exports.getWorkflowCompleted(workflow); - let activeStep = module.exports.getActiveWorkflowStep(workflow); + workflowCompleted = workflowController.getWorkflowCompleted(workflow); + let activeStep = workflowController.getActiveWorkflowStep(workflow); // Calculate active step status - if (activeStep) { + if (!_.isEmpty(activeStep)) { ({ stepName = '', remainingActioners = [], @@ -1563,7 +1610,7 @@ module.exports = { decisionDate, isReviewer = false, reviewPanels = [], - } = module.exports.getActiveStepStatus(activeStep, users, userId)); + } = workflowController.getActiveStepStatus(activeStep, users, userId)); } else if ( _.isUndefined(activeStep) && applicationStatus === 'inReview' @@ -1655,186 +1702,5 @@ module.exports = { return parseInt(totalDecisionTime / decidedApplications.length / 86400); } return 0; - }, - - getReviewStatus: (application, userId) => { - let inReviewMode = false, - reviewSections = [], - isActiveStepReviewer = false, - hasRecommended = false; - // Get current application status - let { applicationStatus } = application; - // Check if the current user is a reviewer on the current step of an attached workflow - let { workflow = {} } = application; - if (!_.isEmpty(workflow)) { - let { steps } = workflow; - let activeStep = steps.find((step) => { - return step.active === true; - }); - if (activeStep) { - isActiveStepReviewer = activeStep.reviewers.some( - (reviewer) => reviewer._id.toString() === userId.toString() - ); - reviewSections = [...activeStep.sections]; - - let { recommendations = [] } = activeStep; - if(!_.isEmpty(recommendations)) { - hasRecommended = recommendations.some( - (rec) => rec.reviewer.toString() === userId.toString() - ); - } - } - } - // Return active review mode if conditions apply - if (applicationStatus === 'inReview' && isActiveStepReviewer) { - inReviewMode = true; - } - - return { inReviewMode, reviewSections, hasRecommended }; - }, - - getWorkflowStatus: (application) => { - let workflowStatus = {}; - let { workflow = {} } = application; - if (!_.isEmpty(workflow)) { - let { workflowName, steps } = workflow; - // Find the active step in steps - let activeStep = module.exports.getActiveWorkflowStep(workflow); - let activeStepIndex = steps.findIndex((step) => { - return step.active === true; - }); - if (activeStep) { - let { - reviewStatus, - deadlinePassed - } = module.exports.getActiveStepStatus(activeStep); - //Update active step with review status - steps[activeStepIndex] = { - ...steps[activeStepIndex], - reviewStatus, - deadlinePassed - }; - } - //Update steps with user friendly review sections - let formattedSteps = [...steps].reduce((arr, item) => { - let step = { - ...item, - sections: [...item.sections].map( - (section) => helper.darPanelMapper[section] - ), - }; - arr.push(step); - return arr; - }, []); - - workflowStatus = { - workflowName, - steps: formattedSteps, - isCompleted: module.exports.getWorkflowCompleted(workflow) - }; - } - return workflowStatus; - }, - - getWorkflowCompleted: (workflow) => { - let { steps } = workflow; - return steps.every((step) => step.completed); - }, - - getActiveStepStatus: (activeStep, users = [], userId = '') => { - let reviewStatus = '', - deadlinePassed = false, - remainingActioners = [], - decisionMade = false, - decisionComments = '', - decisionApproved = false, - decisionDate = '', - decisionStatus = ''; - let { - stepName, - deadline, - startDateTime, - reviewers = [], - recommendations = [], - sections = [], - } = activeStep; - let deadlineDate = moment(startDateTime).add(deadline, 'days'); - let diff = parseInt(deadlineDate.diff(new Date(), 'days')); - if (diff > 0) { - reviewStatus = `Deadline in ${diff} days`; - } else if (diff < 0) { - reviewStatus = `Deadline was ${Math.abs(diff)} days ago`; - deadlinePassed = true; - } else { - reviewStatus = `Deadline is today`; - } - remainingActioners = reviewers.filter( - (reviewer) => - !recommendations.some( - (rec) => rec.reviewer.toString() === reviewer.toString() - ) - ); - remainingActioners = users - .filter((user) => - remainingActioners.some( - (actioner) => actioner.toString() === user._id.toString() - ) - ) - .map((user) => { - return `${user.firstname} ${user.lastname}`; - }); - - let isReviewer = reviewers.some( - (reviewer) => reviewer.toString() === userId.toString() - ); - let hasRecommended = recommendations.some( - (rec) => rec.reviewer.toString() === userId.toString() - ); - - decisionMade = isReviewer && hasRecommended; - - if (decisionMade) { - decisionStatus = 'Decision made for this phase'; - } else if (isReviewer) { - decisionStatus = 'Decision required'; - } else { - decisionStatus = ''; - } - - if (hasRecommended) { - let recommendation = recommendations.find( - (rec) => rec.reviewer.toString() === userId.toString() - ); - ({ - comments: decisionComments, - approved: decisionApproved, - createdDate: decisionDate, - } = recommendation); - } - - let reviewPanels = sections - .map((section) => helper.darPanelMapper[section]) - .join(', '); - - return { - stepName, - remainingActioners: remainingActioners.join(', '), - deadlinePassed, - isReviewer, - reviewStatus, - decisionMade, - decisionApproved, - decisionDate, - decisionStatus, - decisionComments, - reviewPanels - }; - }, - - getActiveWorkflowStep: (workflow) => { - let { steps } = workflow; - return steps.find((step) => { - return step.active; - }); - }, + } }; diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index 4766edbc..9513f8e7 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -104,12 +104,12 @@ module.exports = { return false; }, - getTeamManagers: (team) => { + getTeamMembersByRole: (team, role) => { // Destructure members array and populated users array (populate 'users' must be included in the original Mongo query) let { members = [], users = [] } = team; - // Get all userIds for managers within team - let managerIds = members.filter(mem => mem.roles.includes('manager')).map(mem => mem.memberid.toString()); - // return all user records for managers - return users.filter(user => managerIds.includes(user._id.toString())); + // Get all userIds for role within team + let userIds = members.filter(mem => mem.roles.includes(role)).map(mem => mem.memberid.toString()); + // return all user records for role + return users.filter(user => userIds.includes(user._id.toString())); } }; diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 8acf75b9..f6d7d3d7 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -389,5 +389,208 @@ module.exports = { }; } return bpmContext; + }, + + getWorkflowCompleted: (workflow = {}) => { + let workflowCompleted = false; + if (!_.isEmpty(workflow)) { + let { steps } = workflow; + workflowCompleted = steps.every((step) => step.completed); + } + return workflowCompleted; + }, + + getActiveWorkflowStep: (workflow = {}) => { + let activeStep = {}; + if (!_.isEmpty(workflow)) { + let { steps } = workflow; + activeStep = steps.find((step) => { + return step.active; + }); + } + return activeStep; + }, + + getActiveStepReviewers: (workflow = {}) => { + let stepReviewers = []; + // Attempt to get step reviewers if workflow passed + if (!_.isEmpty(workflow)) { + // Get active step + let activeStep = module.exports.getActiveWorkflowStep(workflow); + // If active step, return the reviewers + if(activeStep) { + ({ reviewers: stepReviewers }) = activeStep; + } + } + return stepReviewers; + }, + + getActiveStepStatus: (activeStep, users = [], userId = '') => { + let reviewStatus = '', + deadlinePassed = false, + remainingActioners = [], + decisionMade = false, + decisionComments = '', + decisionApproved = false, + decisionDate = '', + decisionStatus = ''; + let { + stepName, + deadline, + startDateTime, + reviewers = [], + recommendations = [], + sections = [], + } = activeStep; + let deadlineDate = moment(startDateTime).add(deadline, 'days'); + let diff = parseInt(deadlineDate.diff(new Date(), 'days')); + if (diff > 0) { + reviewStatus = `Deadline in ${diff} days`; + } else if (diff < 0) { + reviewStatus = `Deadline was ${Math.abs(diff)} days ago`; + deadlinePassed = true; + } else { + reviewStatus = `Deadline is today`; + } + remainingActioners = reviewers.filter( + (reviewer) => + !recommendations.some( + (rec) => rec.reviewer.toString() === reviewer.toString() + ) + ); + remainingActioners = users + .filter((user) => + remainingActioners.some( + (actioner) => actioner.toString() === user._id.toString() + ) + ) + .map((user) => { + return `${user.firstname} ${user.lastname}`; + }); + + let isReviewer = reviewers.some( + (reviewer) => reviewer.toString() === userId.toString() + ); + let hasRecommended = recommendations.some( + (rec) => rec.reviewer.toString() === userId.toString() + ); + + decisionMade = isReviewer && hasRecommended; + + if (decisionMade) { + decisionStatus = 'Decision made for this phase'; + } else if (isReviewer) { + decisionStatus = 'Decision required'; + } else { + decisionStatus = ''; + } + + if (hasRecommended) { + let recommendation = recommendations.find( + (rec) => rec.reviewer.toString() === userId.toString() + ); + ({ + comments: decisionComments, + approved: decisionApproved, + createdDate: decisionDate, + } = recommendation); + } + + let reviewPanels = sections + .map((section) => helper.darPanelMapper[section]) + .join(', '); + + return { + stepName, + remainingActioners: remainingActioners.join(', '), + deadlinePassed, + isReviewer, + reviewStatus, + decisionMade, + decisionApproved, + decisionDate, + decisionStatus, + decisionComments, + reviewPanels + }; + }, + + getWorkflowStatus: (application) => { + let workflowStatus = {}; + let { workflow = {} } = application; + if (!_.isEmpty(workflow)) { + let { workflowName, steps } = workflow; + // Find the active step in steps + let activeStep = module.exports.getActiveWorkflowStep(workflow); + let activeStepIndex = steps.findIndex((step) => { + return step.active === true; + }); + if (!_.isEmpty(activeStep)) { + let { + reviewStatus, + deadlinePassed + } = module.exports.getActiveStepStatus(activeStep); + //Update active step with review status + steps[activeStepIndex] = { + ...steps[activeStepIndex], + reviewStatus, + deadlinePassed + }; + } + //Update steps with user friendly review sections + let formattedSteps = [...steps].reduce((arr, item) => { + let step = { + ...item, + sections: [...item.sections].map( + (section) => helper.darPanelMapper[section] + ), + }; + arr.push(step); + return arr; + }, []); + + workflowStatus = { + workflowName, + steps: formattedSteps, + isCompleted: module.exports.getWorkflowCompleted(workflow) + }; + } + return workflowStatus; + }, + + getReviewStatus: (application, userId) => { + let inReviewMode = false, + reviewSections = [], + isActiveStepReviewer = false, + hasRecommended = false; + // Get current application status + let { applicationStatus } = application; + // Check if the current user is a reviewer on the current step of an attached workflow + let { workflow = {} } = application; + if (!_.isEmpty(workflow)) { + let { steps } = workflow; + let activeStep = steps.find((step) => { + return step.active === true; + }); + if (activeStep) { + isActiveStepReviewer = activeStep.reviewers.some( + (reviewer) => reviewer._id.toString() === userId.toString() + ); + reviewSections = [...activeStep.sections]; + + let { recommendations = [] } = activeStep; + if(!_.isEmpty(recommendations)) { + hasRecommended = recommendations.some( + (rec) => rec.reviewer.toString() === userId.toString() + ); + } + } + } + // Return active review mode if conditions apply + if (applicationStatus === 'inReview' && isActiveStepReviewer) { + inReviewMode = true; + } + + return { inReviewMode, reviewSections, hasRecommended }; } }; From 36d6a37829ab47533b7ec12bfbe059d7e470d637 Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Tue, 20 Oct 2020 11:20:25 +0100 Subject: [PATCH 065/144] Updatest to API --- .../datarequest/datarequest.controller.js | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 02f9c757..9ecba07f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -487,16 +487,22 @@ module.exports = { if (userType === userTypes.CUSTODIAN) { // Only a custodian manager can set the final status of an application authorised = false; - if (_.has(accessRecord.publisherObj.toObject(), 'team')) { - let { - publisherObj: { team }, - } = accessRecord; - authorised = teamController.checkTeamPermissions( - teamController.roleTypes.MANAGER, - team.toObject(), - _id - ); + let team = {}; + if (_.isNull(accessRecord.publisherObj)) { + ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); + } else { + ({ team = {} } = accessRecord.publisherObj.toObject()); + } + + if (!_.isEmpty(team)) { + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + team, + _id + ); + } + if (!authorised) { return res .status(401) From 9560693107f0aee3f6303bca57529e543d8c0feb Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 11:40:15 +0100 Subject: [PATCH 066/144] Fixed publisher workflow backward compatibility --- src/resources/datarequest/datarequest.controller.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 9ecba07f..ddc3d659 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1129,8 +1129,12 @@ module.exports = { // 4. Update application to submitted status accessRecord.applicationStatus = 'submitted'; // Check if workflow/5 Safes based application, set final status date if status will never change again - if (!accessRecord.datasets[0].publisher.workflowEnabled) { - accessRecord.dateFinalStatus = new Date(); + let workflowEnabled = false; + if(_.has(accessRecord.datasets[0], 'publisher')) { + if (!accessRecord.datasets[0].publisher.workflowEnabled) { + workflowEnabled = true; + accessRecord.dateFinalStatus = new Date(); + } } let dateSubmitted = new Date(); accessRecord.dateSubmitted = dateSubmitted; @@ -1148,7 +1152,7 @@ module.exports = { req.user ); // Start workflow process if publisher requires it - if (accessRecord.datasets[0].publisher.workflowEnabled) { + if (workflowEnabled) { // Call Camunda controller to start workflow for submitted application let { publisherObj: { name: publisher }, From b634416b310605a60f9b3db78c58e34bb57fc066 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 12:26:46 +0100 Subject: [PATCH 067/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 2 +- src/resources/message/message.controller.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index ddc3d659..9ca8e713 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1470,7 +1470,7 @@ module.exports = { } } // If user is not authenticated as a custodian, check if they are an author or the main applicant - if (_.isEmpty(userType)) { + if (application.applicationStatus === 'inProgress' || _.isEmpty(userType)) { if ( application.authorIds.includes(userId) || application.userId === userId diff --git a/src/resources/message/message.controller.js b/src/resources/message/message.controller.js index 6dcf1e39..8b24e7ae 100644 --- a/src/resources/message/message.controller.js +++ b/src/resources/message/message.controller.js @@ -56,7 +56,6 @@ module.exports = { // 5. Create new message const message = await MessagesModel.create({ messageID: parseInt(Math.random().toString().replace('0.', '')), - messageTo: 0, messageObjectID: parseInt(Math.random().toString().replace('0.', '')), messageDescription, topic, From 33a9423b2bb564042900b3be49bcaa6b6bbbe99b Mon Sep 17 00:00:00 2001 From: Ciara Date: Tue, 20 Oct 2020 13:36:22 +0100 Subject: [PATCH 068/144] IG-840 display fields for person profile added to data schema, post and put --- src/resources/person/person.route.js | 10 ++++++++-- src/resources/tool/data.model.js | 8 +++++++- src/resources/user/user.register.route.js | 13 ++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/resources/person/person.route.js b/src/resources/person/person.route.js index 1b222e30..94d0c3c2 100644 --- a/src/resources/person/person.route.js +++ b/src/resources/person/person.route.js @@ -5,7 +5,7 @@ import passport from "passport"; import { ROLES } from '../user/user.roles' import {addTool, editTool, deleteTool, setStatus, getTools, getToolsAdmin} from '../tool/data.repository'; import emailGenerator from '../utilities/emailGenerator.util'; -import { UserModel } from '../user/user.model' +import { UserModel } from '../user/user.model' const urlValidator = require('../utilities/urlValidator'); const inputSanitizer = require('../utilities/inputSanitizer'); @@ -44,7 +44,7 @@ router.put('/', passport.authenticate('jwt'), utils.checkIsInRole(ROLES.Admin, ROLES.Creator), async (req, res) => { - let { id, firstname, lastname, email, bio, emailNotifications, terms, sector, organisation, showOrganisation, tags } = req.body; + let { id, firstname, lastname, email, bio, displayBio, displayLink, displayOrcid, emailNotifications, terms, sector, displaySector, organisation, displayOrganisation, showOrganisation, tags, displayDomain } = req.body; const type = 'person'; let link = urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(req.body.link)); let orcid = urlValidator.validateOrcidURL(inputSanitizer.removeNonBreakingSpaces(req.body.orcid)); @@ -61,14 +61,20 @@ router.put('/', lastname, type, bio, + displayBio, link, + displayLink, orcid, + displayOrcid, emailNotifications, terms, sector, + displaySector, organisation, + displayOrganisation, showOrganisation, tags, + displayDomain, }, {new:true}); await UserModel.findOneAndUpdate({ id: id }, diff --git a/src/resources/tool/data.model.js b/src/resources/tool/data.model.js index 18a54f5b..9468c9f8 100644 --- a/src/resources/tool/data.model.js +++ b/src/resources/tool/data.model.js @@ -2,7 +2,7 @@ import { model, Schema } from 'mongoose'; //DO NOT DELETE publisher and team model below import { PublisherModel } from '../publisher/publisher.model'; import { TeamModel } from '../team/team.model'; - + // this will be our data base's data structure const DataSchema = new Schema( { @@ -50,12 +50,18 @@ const DataSchema = new Schema( firstname: String, lastname: String, bio: String, //institution + displayBio: Boolean, orcid: String, + displayOrcid: Boolean, emailNotifications: Boolean, terms: Boolean, sector: String, + displaySector: Boolean, organisation: String, + displayOrganisation: Boolean, showOrganisation: {type: Boolean, default: false }, + displayLink: Boolean, + displayDomain: Boolean, //dataset related fields datasetid: String, diff --git a/src/resources/user/user.register.route.js b/src/resources/user/user.register.route.js index f3c57b59..360bf78d 100644 --- a/src/resources/user/user.register.route.js +++ b/src/resources/user/user.register.route.js @@ -17,7 +17,7 @@ const router = express.Router() // @access Public router.get('/:personID', async (req, res) => { - const [err, user] = await to(getUserByUserId(req.params.personID)) + const [err, user] = await to(getUserByUserId(req.params.personID)) if (err) return res.json({ success: false, error: err }); return res.json({ success: true, data: user }); @@ -28,7 +28,7 @@ router.get('/:personID', // @access Public router.post('/', async (req, res) => { - const { id, firstname, lastname, email, bio, redirectURL, sector, organisation, emailNotifications, terms } = req.body + const { id, firstname, lastname, email, bio, displayBio, displayLink, displayOrcid, redirectURL, sector, displaySector, organisation, displayOrganisation, emailNotifications, terms, tags, displayDomain } = req.body let link = urlValidator.validateURL(req.body.link); let orcid = urlValidator.validateOrcidURL(req.body.orcid); let username = `${firstname.toLowerCase()}.${lastname.toLowerCase()}`; @@ -57,12 +57,19 @@ router.post('/', firstname, lastname, bio, + displayBio, link, + displayLink, orcid, + displayOrcid, emailNotifications, terms, sector, - organisation + displaySector, + organisation, + displayOrganisation, + tags, + displayDomain, }) ) From adcc104d049a9660c83fdd83f5a57995a4c6eccf Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 14:26:51 +0100 Subject: [PATCH 069/144] Fixed defects --- .../datarequest/datarequest.controller.js | 44 ++++++++++++------- src/resources/workflow/workflow.controller.js | 2 +- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 9ca8e713..73b1f6a2 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1101,21 +1101,34 @@ module.exports = { params: { id }, } = req; // 2. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ - path: 'datasets dataset mainApplicant authors publisherObj', - populate: { - path: 'publisher additionalInfo', - populate: { - path: 'team', + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate( + [ + { + path: 'datasets dataset', populate: { - path: 'users', + path: 'publisher', populate: { - path: 'additionalInfo', - }, - }, + path: 'team', + populate: { + path: 'users', + populate: { + path: 'additionalInfo', + }, + }, + } + } }, - }, - }); + { + path: 'mainApplicant authors', + populate: { + path: 'additionalInfo' + } + }, + { + path: 'publisherObj' + } + ] + ); if (!accessRecord) { return res .status(404) @@ -1130,10 +1143,11 @@ module.exports = { accessRecord.applicationStatus = 'submitted'; // Check if workflow/5 Safes based application, set final status date if status will never change again let workflowEnabled = false; - if(_.has(accessRecord.datasets[0], 'publisher')) { + if(_.has(accessRecord.datasets[0].toObject(), 'publisher')) { if (!accessRecord.datasets[0].publisher.workflowEnabled) { - workflowEnabled = true; accessRecord.dateFinalStatus = new Date(); + } else { + workflowEnabled = true; } } let dateSubmitted = new Date(); @@ -1552,7 +1566,7 @@ module.exports = { .map((user) => { return `${user.firstname} ${user.lastname}`; }); - if (applicationStatus === 'submitted') { + if (applicationStatus === 'submitted' || (applicationStatus === 'inReview' && !_.isEmpty(workflow))) { remainingActioners = managerUsers.join(', '); } if (!_.isEmpty(workflow)) { diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 8acf75b9..4b80059a 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -324,7 +324,7 @@ module.exports = { // Extract deadline and reminder offset in days from step definition let { deadline, reminderOffset } = step; // Subtract SLA reminder offset - let reminderPeriod = deadline - reminderOffset; + let reminderPeriod = +deadline - +reminderOffset; return `P${reminderPeriod}D`; }, From c7750bc4bc119c485f61758435f6b4b2a4dd5011 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 14:28:51 +0100 Subject: [PATCH 070/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 73b1f6a2..3d054483 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1566,7 +1566,7 @@ module.exports = { .map((user) => { return `${user.firstname} ${user.lastname}`; }); - if (applicationStatus === 'submitted' || (applicationStatus === 'inReview' && !_.isEmpty(workflow))) { + if (applicationStatus === 'submitted' || (applicationStatus === 'inReview' && _.isEmpty(workflow))) { remainingActioners = managerUsers.join(', '); } if (!_.isEmpty(workflow)) { From 447a44d2d7bc642959f0bd5aca35feb180e786da Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 14:48:16 +0100 Subject: [PATCH 071/144] Fixed defects --- src/resources/publisher/publisher.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 65b8361a..b4745644 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -168,7 +168,7 @@ module.exports = { let elapsedSteps = [...steps].slice(0, activeStepIndex + 1); let found = elapsedSteps.some((step) => - step.reviewers.some((reviewer) => reviewer_.id.equals(_id)) + step.reviewers.some((reviewer) => reviewer._id.equals(_id)) ); if (found) { From 40e2c49fddce92b52a091982c02567fe2d5b689d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 14:52:33 +0100 Subject: [PATCH 072/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 3d054483..6c8eb369 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1809,7 +1809,7 @@ module.exports = { }); let isReviewer = reviewers.some( - (reviewer) => reviewer.toString() === userId.toString() + (reviewer) => reviewer._id.toString() === userId.toString() ); let hasRecommended = recommendations.some( (rec) => rec.reviewer.toString() === userId.toString() From f751764877b53cae9bc0877e8fe4f9575df1eaa2 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 15:03:38 +0100 Subject: [PATCH 073/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 6c8eb369..623b647a 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1795,13 +1795,13 @@ module.exports = { remainingActioners = reviewers.filter( (reviewer) => !recommendations.some( - (rec) => rec.reviewer.toString() === reviewer.toString() + (rec) => rec.reviewer.toString() === reviewer._id.toString() ) ); remainingActioners = users .filter((user) => remainingActioners.some( - (actioner) => actioner.toString() === user._id.toString() + (actioner) => actioner._id.toString() === user._id.toString() ) ) .map((user) => { From 609109cc2c7b7d9e19026d80c296685b4d86ec1d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 15:15:00 +0100 Subject: [PATCH 074/144] Fixed defects --- .../datarequest/datarequest.controller.js | 120 ++++++++++-------- 1 file changed, 69 insertions(+), 51 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 623b647a..8cbc07ed 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -92,7 +92,7 @@ module.exports = { path: 'datasets dataset authors', populate: { path: 'publisher', populate: { path: 'team' } }, }, - { path: 'workflow.steps.reviewers', select: 'firstname lastname' } + { path: 'workflow.steps.reviewers', select: 'firstname lastname' }, ]); // 3. If no matching application found, return 404 if (!accessRecord) { @@ -127,21 +127,22 @@ module.exports = { readOnly = false; } // 7. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections, hasRecommended } = module.exports.getReviewStatus( - accessRecord, - req.user._id - ); + let { + inReviewMode, + reviewSections, + hasRecommended, + } = module.exports.getReviewStatus(accessRecord, req.user._id); // 8. Get the workflow/voting status let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); // 9. Check if the current user can override the current step - if(_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { - let isManager = teamController.checkTeamPermissions( + if (_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { + let isManager = teamController.checkTeamPermissions( teamController.roleTypes.MANAGER, accessRecord.datasets[0].publisher.team.toObject(), req.user._id ); // Set the workflow override capability if there is an active step and user is a manager - if(!_.isEmpty(workflow)) { + if (!_.isEmpty(workflow)) { workflow.canOverrideStep = !workflow.isCompleted && isManager; } } @@ -489,18 +490,17 @@ module.exports = { authorised = false; let team = {}; if (_.isNull(accessRecord.publisherObj)) { - ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); + ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); } else { - ({ team = {} } = accessRecord.publisherObj.toObject()); + ({ team = {} } = accessRecord.publisherObj.toObject()); } if (!_.isEmpty(team)) { - authorised = teamController.checkTeamPermissions( - teamController.roleTypes.MANAGER, - team, - _id - ); - + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + team, + _id + ); } if (!authorised) { @@ -1101,34 +1101,32 @@ module.exports = { params: { id }, } = req; // 2. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate( - [ - { - path: 'datasets dataset', + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'datasets dataset', + populate: { + path: 'publisher', populate: { - path: 'publisher', + path: 'team', populate: { - path: 'team', + path: 'users', populate: { - path: 'users', - populate: { - path: 'additionalInfo', - }, + path: 'additionalInfo', }, - } - } + }, + }, }, - { - path: 'mainApplicant authors', - populate: { - path: 'additionalInfo' - } + }, + { + path: 'mainApplicant authors', + populate: { + path: 'additionalInfo', }, - { - path: 'publisherObj' - } - ] - ); + }, + { + path: 'publisherObj', + }, + ]); if (!accessRecord) { return res .status(404) @@ -1143,7 +1141,7 @@ module.exports = { accessRecord.applicationStatus = 'submitted'; // Check if workflow/5 Safes based application, set final status date if status will never change again let workflowEnabled = false; - if(_.has(accessRecord.datasets[0].toObject(), 'publisher')) { + if (_.has(accessRecord.datasets[0].toObject(), 'publisher')) { if (!accessRecord.datasets[0].publisher.workflowEnabled) { accessRecord.dateFinalStatus = new Date(); } else { @@ -1280,7 +1278,7 @@ module.exports = { emailRecipients = [ accessRecord.mainApplicant, ...custodianUsers, - ...accessRecord.authors + ...accessRecord.authors, ]; let { dateSubmitted } = accessRecord; if (!dateSubmitted) ({ updatedAt: dateSubmitted } = accessRecord); @@ -1323,7 +1321,9 @@ module.exports = { _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { // Retrieve all custodian user Ids to generate notifications - custodianManagers = teamController.getTeamManagers(accessRecord.datasets[0].publisher.team); + custodianManagers = teamController.getTeamManagers( + accessRecord.datasets[0].publisher.team + ); let custodianUserIds = custodianManagers.map((user) => user.id); await notificationBuilder.triggerNotificationMessage( custodianUserIds, @@ -1332,7 +1332,8 @@ module.exports = { accessRecord._id ); } else { - const dataCustodianEmail = process.env.DATA_CUSTODIAN_EMAIL || contactPoint; + const dataCustodianEmail = + process.env.DATA_CUSTODIAN_EMAIL || contactPoint; custodianManagers = [{ email: dataCustodianEmail }]; } // Applicant notification @@ -1369,7 +1370,7 @@ module.exports = { // Send email to main applicant and contributors if they have opted in to email notifications emailRecipients = [ accessRecord.mainApplicant, - ...accessRecord.authors + ...accessRecord.authors, ]; } // Establish email context object @@ -1484,7 +1485,10 @@ module.exports = { } } // If user is not authenticated as a custodian, check if they are an author or the main applicant - if (application.applicationStatus === 'inProgress' || _.isEmpty(userType)) { + if ( + application.applicationStatus === 'inProgress' || + _.isEmpty(userType) + ) { if ( application.authorIds.includes(userId) || application.userId === userId @@ -1566,7 +1570,10 @@ module.exports = { .map((user) => { return `${user.firstname} ${user.lastname}`; }); - if (applicationStatus === 'submitted' || (applicationStatus === 'inReview' && _.isEmpty(workflow))) { + if ( + applicationStatus === 'submitted' || + (applicationStatus === 'inReview' && _.isEmpty(workflow)) + ) { remainingActioners = managerUsers.join(', '); } if (!_.isEmpty(workflow)) { @@ -1588,6 +1595,7 @@ module.exports = { isReviewer = false, reviewPanels = [], } = module.exports.getActiveStepStatus(activeStep, users, userId)); + activeStep = { ...activeStep, reviewStatus }; } else if ( _.isUndefined(activeStep) && applicationStatus === 'inReview' @@ -1602,6 +1610,16 @@ module.exports = { moment(dateFinalStatus).diff(dateSubmitted, 'days') ); } + // Set review section to display format + let formattedSteps = [...workflow.steps].reduce((arr, item) => { + let step = { + ...item, + sections: [...item.sections].map(section => helper.darPanelMapper[section]) + } + arr.push(step); + return arr; + }, []); + workflow.steps = [...formattedSteps]; } } @@ -1700,9 +1718,9 @@ module.exports = { (reviewer) => reviewer._id.toString() === userId.toString() ); reviewSections = [...activeStep.sections]; - + let { recommendations = [] } = activeStep; - if(!_.isEmpty(recommendations)) { + if (!_.isEmpty(recommendations)) { hasRecommended = recommendations.some( (rec) => rec.reviewer.toString() === userId.toString() ); @@ -1730,13 +1748,13 @@ module.exports = { if (activeStep) { let { reviewStatus, - deadlinePassed + deadlinePassed, } = module.exports.getActiveStepStatus(activeStep); //Update active step with review status steps[activeStepIndex] = { ...steps[activeStepIndex], reviewStatus, - deadlinePassed + deadlinePassed, }; } //Update steps with user friendly review sections @@ -1754,7 +1772,7 @@ module.exports = { workflowStatus = { workflowName, steps: formattedSteps, - isCompleted: module.exports.getWorkflowCompleted(workflow) + isCompleted: module.exports.getWorkflowCompleted(workflow), }; } return workflowStatus; @@ -1851,7 +1869,7 @@ module.exports = { decisionDate, decisionStatus, decisionComments, - reviewPanels + reviewPanels, }; }, From e0e8457cf01f80abbc2ee87869db6684d36cb25d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 15:26:07 +0100 Subject: [PATCH 075/144] Fixed defects --- .../datarequest/datarequest.controller.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 8cbc07ed..d4bdb8ce 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1595,7 +1595,13 @@ module.exports = { isReviewer = false, reviewPanels = [], } = module.exports.getActiveStepStatus(activeStep, users, userId)); - activeStep = { ...activeStep, reviewStatus }; + let activeStepIndex = workflow.steps.findIndex((step) => { + return step.active === true; + }); + workflow.steps[activeStepIndex] = { + ...workflow.steps[activeStepIndex], + reviewStatus, + }; } else if ( _.isUndefined(activeStep) && applicationStatus === 'inReview' @@ -1614,8 +1620,10 @@ module.exports = { let formattedSteps = [...workflow.steps].reduce((arr, item) => { let step = { ...item, - sections: [...item.sections].map(section => helper.darPanelMapper[section]) - } + sections: [...item.sections].map( + (section) => helper.darPanelMapper[section] + ), + }; arr.push(step); return arr; }, []); From 5cc37c7405d13f59bedd5e3c4758099f64f92efa Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 15:45:13 +0100 Subject: [PATCH 076/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index d4bdb8ce..b7656c3e 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1568,7 +1568,8 @@ module.exports = { ) ) .map((user) => { - return `${user.firstname} ${user.lastname}`; + let isCurrentUser = user._id.toString() === userId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)`:``}`; }); if ( applicationStatus === 'submitted' || @@ -1831,7 +1832,8 @@ module.exports = { ) ) .map((user) => { - return `${user.firstname} ${user.lastname}`; + let isCurrentUser = user._id.toString() === userId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)`:``}`; }); let isReviewer = reviewers.some( From 09ee36a6893014f405b6c79c264b1f77ed3fa8c8 Mon Sep 17 00:00:00 2001 From: Ciara Date: Tue, 20 Oct 2020 16:03:29 +0100 Subject: [PATCH 077/144] IG-840 fix for tags on profile, displayOrganisation removed --- src/resources/person/person.route.js | 6 +++--- src/resources/tool/data.model.js | 1 - src/resources/user/user.register.route.js | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/resources/person/person.route.js b/src/resources/person/person.route.js index 94d0c3c2..b556d280 100644 --- a/src/resources/person/person.route.js +++ b/src/resources/person/person.route.js @@ -44,7 +44,7 @@ router.put('/', passport.authenticate('jwt'), utils.checkIsInRole(ROLES.Admin, ROLES.Creator), async (req, res) => { - let { id, firstname, lastname, email, bio, displayBio, displayLink, displayOrcid, emailNotifications, terms, sector, displaySector, organisation, displayOrganisation, showOrganisation, tags, displayDomain } = req.body; + let { id, firstname, lastname, email, bio, displayBio, displayLink, displayOrcid, emailNotifications, terms, sector, displaySector, organisation, showOrganisation, tags, displayDomain } = req.body; const type = 'person'; let link = urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(req.body.link)); let orcid = urlValidator.validateOrcidURL(inputSanitizer.removeNonBreakingSpaces(req.body.orcid)); @@ -53,8 +53,9 @@ router.put('/', bio = inputSanitizer.removeNonBreakingSpaces(bio); sector = inputSanitizer.removeNonBreakingSpaces(sector); organisation = inputSanitizer.removeNonBreakingSpaces(organisation); - tags = inputSanitizer.removeNonBreakingSpaces(tags); + tags.topics = inputSanitizer.removeNonBreakingSpaces(tags.topics); console.log(req.body) + await Data.findOneAndUpdate({ id: id }, { firstname, @@ -71,7 +72,6 @@ router.put('/', sector, displaySector, organisation, - displayOrganisation, showOrganisation, tags, displayDomain, diff --git a/src/resources/tool/data.model.js b/src/resources/tool/data.model.js index 9468c9f8..1ea5233c 100644 --- a/src/resources/tool/data.model.js +++ b/src/resources/tool/data.model.js @@ -58,7 +58,6 @@ const DataSchema = new Schema( sector: String, displaySector: Boolean, organisation: String, - displayOrganisation: Boolean, showOrganisation: {type: Boolean, default: false }, displayLink: Boolean, displayDomain: Boolean, diff --git a/src/resources/user/user.register.route.js b/src/resources/user/user.register.route.js index 360bf78d..8f3253d0 100644 --- a/src/resources/user/user.register.route.js +++ b/src/resources/user/user.register.route.js @@ -28,7 +28,7 @@ router.get('/:personID', // @access Public router.post('/', async (req, res) => { - const { id, firstname, lastname, email, bio, displayBio, displayLink, displayOrcid, redirectURL, sector, displaySector, organisation, displayOrganisation, emailNotifications, terms, tags, displayDomain } = req.body + const { id, firstname, lastname, email, bio, displayBio, displayLink, displayOrcid, redirectURL, sector, displaySector, organisation, emailNotifications, terms, tags, displayDomain } = req.body let link = urlValidator.validateURL(req.body.link); let orcid = urlValidator.validateOrcidURL(req.body.orcid); let username = `${firstname.toLowerCase()}.${lastname.toLowerCase()}`; @@ -67,7 +67,6 @@ router.post('/', sector, displaySector, organisation, - displayOrganisation, tags, displayDomain, }) From 82b4a02760e59abceb346ff6d2ef5b4cb03e5f07 Mon Sep 17 00:00:00 2001 From: Ciara Date: Tue, 20 Oct 2020 16:18:20 +0100 Subject: [PATCH 078/144] IG-840 display fields renamed show --- src/resources/person/person.route.js | 12 ++++++------ src/resources/tool/data.model.js | 10 +++++----- src/resources/user/user.register.route.js | 12 ++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/resources/person/person.route.js b/src/resources/person/person.route.js index b556d280..70ed5c46 100644 --- a/src/resources/person/person.route.js +++ b/src/resources/person/person.route.js @@ -44,7 +44,7 @@ router.put('/', passport.authenticate('jwt'), utils.checkIsInRole(ROLES.Admin, ROLES.Creator), async (req, res) => { - let { id, firstname, lastname, email, bio, displayBio, displayLink, displayOrcid, emailNotifications, terms, sector, displaySector, organisation, showOrganisation, tags, displayDomain } = req.body; + let { id, firstname, lastname, email, bio, showBio, showLink, showOrcid, emailNotifications, terms, sector, showSector, organisation, showOrganisation, tags, showDomain } = req.body; const type = 'person'; let link = urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(req.body.link)); let orcid = urlValidator.validateOrcidURL(inputSanitizer.removeNonBreakingSpaces(req.body.orcid)); @@ -62,19 +62,19 @@ router.put('/', lastname, type, bio, - displayBio, + showBio, link, - displayLink, + showLink, orcid, - displayOrcid, + showOrcid, emailNotifications, terms, sector, - displaySector, + showSector, organisation, showOrganisation, tags, - displayDomain, + showDomain, }, {new:true}); await UserModel.findOneAndUpdate({ id: id }, diff --git a/src/resources/tool/data.model.js b/src/resources/tool/data.model.js index 1ea5233c..25d998af 100644 --- a/src/resources/tool/data.model.js +++ b/src/resources/tool/data.model.js @@ -50,17 +50,17 @@ const DataSchema = new Schema( firstname: String, lastname: String, bio: String, //institution - displayBio: Boolean, + showBio: Boolean, orcid: String, - displayOrcid: Boolean, + showOrcid: Boolean, emailNotifications: Boolean, terms: Boolean, sector: String, - displaySector: Boolean, + showSector: Boolean, organisation: String, showOrganisation: {type: Boolean, default: false }, - displayLink: Boolean, - displayDomain: Boolean, + showLink: Boolean, + showDomain: Boolean, //dataset related fields datasetid: String, diff --git a/src/resources/user/user.register.route.js b/src/resources/user/user.register.route.js index 8f3253d0..5a7d4dba 100644 --- a/src/resources/user/user.register.route.js +++ b/src/resources/user/user.register.route.js @@ -28,7 +28,7 @@ router.get('/:personID', // @access Public router.post('/', async (req, res) => { - const { id, firstname, lastname, email, bio, displayBio, displayLink, displayOrcid, redirectURL, sector, displaySector, organisation, emailNotifications, terms, tags, displayDomain } = req.body + const { id, firstname, lastname, email, bio, showBio, showLink, showOrcid, redirectURL, sector, showSector, organisation, emailNotifications, terms, tags, showDomain } = req.body let link = urlValidator.validateURL(req.body.link); let orcid = urlValidator.validateOrcidURL(req.body.orcid); let username = `${firstname.toLowerCase()}.${lastname.toLowerCase()}`; @@ -57,18 +57,18 @@ router.post('/', firstname, lastname, bio, - displayBio, + showBio, link, - displayLink, + showLink, orcid, - displayOrcid, + showOrcid, emailNotifications, terms, sector, - displaySector, + showSector, organisation, tags, - displayDomain, + showDomain, }) ) From f40a16f624b1b0a44d1186342546402dc6e72b86 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 20 Oct 2020 16:54:50 +0100 Subject: [PATCH 079/144] Team help FAQ - added missing import --- src/resources/help/help.router.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/resources/help/help.router.js b/src/resources/help/help.router.js index 378229df..e7d089b8 100644 --- a/src/resources/help/help.router.js +++ b/src/resources/help/help.router.js @@ -1,5 +1,6 @@ import express from "express"; import { Help } from "./help.model"; +import _ from 'lodash'; const router = express.Router(); From 04d3f71f5a3a4b33e0cc5f0797f339a099ca5a80 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Tue, 20 Oct 2020 19:09:06 +0100 Subject: [PATCH 080/144] Update to end session endpoint --- src/config/configuration.js | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index b9d05eb1..e51bb954 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -102,20 +102,6 @@ async function logoutSource(ctx, form) { async function postLogoutSuccessSource(ctx) { // @param ctx - koa request context - const { - clientId, clientName, clientUri, initiateLoginUri, logoUri, policyUri, tosUri, - } = ctx.oidc.client || {}; // client is defined if the user chose to stay logged in with the OP - const display = clientName || clientId; - ctx.body = ` - - Sign-out Success - - - -
-

Sign-out Success

-

Your sign-out ${display ? `with ${display}` : ''} was successful.

-
- - `; + ctx.res.clearCookie('jwt'); + ctx.res.status(200).redirect(process.env.homeURL+'/search?search='); } \ No newline at end of file From b95e71ef3a0fe9eca167695425de510beb67ac0a Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 21 Oct 2020 10:09:42 +0100 Subject: [PATCH 081/144] Continued email building --- .../datarequest/datarequest.controller.js | 175 +++++++++- .../datarequest/datarequest.route.js | 5 + .../utilities/emailGenerator.util.js | 309 +++++++++++++++++- src/resources/workflow/workflow.controller.js | 16 + 4 files changed, 490 insertions(+), 15 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index d71161e5..b170510b 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -761,8 +761,11 @@ module.exports = { reviewerList, }; bpmController.postStartStepReview(bpmContext); - // 14. TODO Create notifications for workflow assigned (step 1 reviewers and other managers) - // 15. Return workflow payload + // 14. Gather context for notifications + const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); + // 15. Create notifications to reviewers of the step that has been completed + module.exports.createNotifications('ReviewStepStart', emailContext, accessRecord, req.user); + // 16. Return workflow payload return res.status(200).json({ success: true, }); @@ -1084,13 +1087,15 @@ module.exports = { console.error(err); res.status(500).json({ status: 'error', message: err }); } else { - // 12. Create notifications to reviewers of the step that has been completed - module.exports.createNotifications('StepOverride', {}, accessRecord, req.user); - // 13. Call Camunda controller to start manager review process + // 12. Gather context for notifications + const emailContext = workflowController.getWorkflowEmailContext(workflow, activeStepIndex); + // 13. Create notifications to reviewers of the step that has been completed + module.exports.createNotifications('StepOverride', emailContext, accessRecord, req.user); + // 14. Call Camunda controller to start manager review process bpmController.postCompleteReview(bpmContext); } }); - // 14. Return aplication and successful response + // 15. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { console.log(err.message); @@ -1194,6 +1199,16 @@ module.exports = { } }, + //POST api/v1/data-access-request/:id/notify + notifyAccessRequestById: async (req, res) => { + // 1. Get workflow etc. + // 12. Gather context for notifications + //const emailContext = workflowController.getWorkflowEmailContext(workflow, activeStepIndex); + // 13. Create notifications to reviewers of the step that has been completed + //module.exports.createNotifications('DeadlineWarning', emailContext, accessRecord, req.user); + return res.status(200).json({ status: 'success' }); + }, + createNotifications: async (type, context, accessRecord, user) => { // Default from mail address const hdrukEmail = `enquiry@healthdatagateway.org`; @@ -1241,7 +1256,6 @@ module.exports = { return { firstname, lastname, email, id }; }); } - switch (type) { // DAR application status has been updated case 'StatusChange': @@ -1471,11 +1485,16 @@ module.exports = { } break; case 'StepOverride': + let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { - // Retrieve all user Ids for active step reviewers - stepReviewers = workflowController.getActiveStepReviewers(workflow); + if(!_.isEmpty(workflow)) { + // Retrieve all user Ids for active step reviewers + stepReviewers = workflowController.getActiveStepReviewers(workflow); + reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + } + // 1. Create reviewer notifications let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); await notificationBuilder.triggerNotificationMessage( @@ -1494,9 +1513,11 @@ module.exports = { userName: `${appFirstName} ${appLastName}`, actioner: `${firstname} ${lastname}`, applicants, + workflowName, stepName, reviewSections, - reviewers + reviewerNames, + nextStepName }; html = await emailGenerator.generateStepOverrideEmail(options); await emailGenerator.sendEmail( @@ -1508,6 +1529,140 @@ module.exports = { ); } break; + case 'ReviewStepStart': + let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; + if ( + _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') + ) { + if(!_.isEmpty(workflow)) { + // Retrieve all user Ids for active step reviewers + stepReviewers = workflowController.getActiveStepReviewers(workflow); + reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + } + + // 1. Create reviewer notifications + let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); + await notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + 'data access request', + accessRecord._id + ); + + // 2. Create reviewer emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + nextStepName + }; + html = await emailGenerator.generateNewReviewPhaseEmail(options); + await emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + html, + false + ); + } + break; + case 'DeadlineWarning': + let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; + if ( + _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') + ) { + if(!_.isEmpty(workflow)) { + // Retrieve all user Ids for active step reviewers + stepReviewers = workflowController.getActiveStepReviewers(workflow); + reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + } + + // 1. Create reviewer notifications + let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); + await notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + 'data access request', + accessRecord._id + ); + + // 2. Create reviewer emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + nextStepName + }; + html = await emailGenerator.generateReviewDeadlineWarning(options); + await emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + html, + false + ); + } + break; + case 'DeadlinePassed': + let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; + if ( + _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') + ) { + if(!_.isEmpty(workflow)) { + // Retrieve all user Ids for active step reviewers + stepReviewers = workflowController.getActiveStepReviewers(workflow); + reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + } + + // 1. Create reviewer notifications + let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); + await notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + 'data access request', + accessRecord._id + ); + + // 2. Create reviewer emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + nextStepName + }; + html = await emailGenerator.generateReviewDeadlinePassed(options); + await emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + html, + false + ); + } } }, diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index fffc0195..6b998254 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -61,4 +61,9 @@ router.put('/:id/stepoverride', passport.authenticate('jwt'), datarequestControl // @access Private - Applicant (Gateway User) router.post('/:id', passport.authenticate('jwt'), datarequestController.submitAccessRequestById); +// @route POST api/v1/data-access-request/:id/notify +// @desc External facing endpoint to trigger notifications for Data Access Request workflows +// @access Private +router.post('/:id', passport.authenticate('jwt'), datarequestController.notifyAccessRequestById); + module.exports = router; \ No newline at end of file diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index d26a75b8..3ad58843 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -383,7 +383,7 @@ const _getUserDetails = async (userObj) => { reject({fullname: '', email: ''}); } }); -} +}; const _generateEmail = async ( questions, @@ -427,7 +427,7 @@ const _displayConditionalStatusDesc = (applicationStatus, applicationStatusDesc) ` } return ''; -} +}; const _displayDARLink = (accessId) => { if(!accessId) @@ -435,7 +435,7 @@ const _displayDARLink = (accessId) => { let darLink = `${process.env.homeURL}/data-access-request/${accessId}`; return `View application`; -} +}; const _generateDARStatusChangedEmail = (options) => { let { id, applicationStatus, applicationStatusDesc, projectId, projectName, publisher, datasetTitles, dateSubmitted, applicants } = options; @@ -493,7 +493,6 @@ const _generateDARStatusChangedEmail = (options) => { ${_displayDARLink(id)} `; - return body; }; @@ -554,7 +553,303 @@ const _generateContributorEmail = (options) => { `; return body; -} +}; + +const _generateStepOverrideEmail = (options) => { + let { id, projectName, projectId, datasetTitles, actioner, applicants, workflowName, stepName, nextStepName, reviewSections, reviewerNames } = options; + let body = `
+ + + + + + + + + + + + + + +
+ Data access request application review phase completed +
+ ${actioner} has manually completed the review phase '${stepName}' for the following data access request application. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Review phase completed${workflowName} - ${stepName}
Review sections${reviewSections}
Reviewers${reviewerNames}
Next review phase${workflowName} - ${nextStepName}
+
+
+ ${_displayDARLink(id)} +
+
`; + return body; +}; + +const _generateNewReviewPhaseEmail = (options) => { + let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames } = options; + let body = `
+ + + + + + + + + + + + + + +
+ Data access request application review phase commenced +
+ You are now required to complete the review phase '${stepName}' for the following data access request application. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Review phase${workflowName} - ${stepName}
Review sections${reviewSections}
Reviewers${reviewerNames}
Deadline${moment(dateDeadline).format('D MMM YYYY HH:mm')}
+
+
+ ${_displayDARLink(id)} +
+
`; + return body; +}; + +const _generateReviewDeadlineWarning = (options) => { + let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames } = options; + let body = `
+ + + + + + + + + + + + + + +
+ Data access request application review phase approaching deadline in ${days} days +
+ The following data access request application is approaching the review deadline. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Review phase${workflowName} - ${stepName}
Review sections${reviewSections}
Reviewers${reviewerNames}
Deadline${moment(dateDeadline).format('D MMM YYYY HH:mm')}
+
+
+ ${_displayDARLink(id)} +
+
`; + return body; +}; + +const _generateReviewDeadlinePassed = (options) => { + let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames } = options; + let body = `
+ + + + + + + + + + + + + + +
+ Data access request application review phase deadlined passed +
+ The review phase '${stepName}' deadline has now passed for the following data access request application. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Review phase${workflowName} - ${stepName}
Review sections${reviewSections}
Reviewers${reviewerNames}
Deadline${moment(dateDeadline).format('D MMM YYYY HH:mm')}
+
+
+ ${_displayDARLink(id)} +
+
`; + return body; +}; /** * [_sendEmail] @@ -633,6 +928,10 @@ export default { generateEmail: _generateEmail, generateDARStatusChangedEmail: _generateDARStatusChangedEmail, generateContributorEmail: _generateContributorEmail, + generateStepOverrideEmail: _generateStepOverrideEmail, + generateNewReviewPhaseEmail: _generateNewReviewPhaseEmail, + generateReviewDeadlineWarning: _generateReviewDeadlineWarning, + generateReviewDeadlinePassed: _generateReviewDeadlinePassed, sendEmail: _sendEmail, generateEmailFooter: _generateEmailFooter }; diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 4c830633..93af66d0 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -594,4 +594,20 @@ module.exports = { return { inReviewMode, reviewSections, hasRecommended }; }, + + getWorkflowEmailContext: (workflow, activeStepIndex) => { + const { workflowName, steps } = workflow; + const { stepName } = steps[activeStepIndex]; + const stepReviewers = workflowController.getActiveStepReviewers(workflow); + const reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + const reviewSections = [...steps[activeStepIndex].sections].map((section) => helper.darPanelMapper[section]); + let nextStepName = ''; + //Find name of next step if this is not the final step + if(activeStepIndex + 1 > steps.length) { + nextStepName = 'No next step'; + } else { + ({ stepName: nextStepName } = steps[activeStepIndex + 1]); + } + return { workflowName, stepName, reviewerNames, reviewSections, nextStepName }; + }, }; \ No newline at end of file From 12648a6db86378de4f1104e8b83c9c04cc9fb7e4 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 21 Oct 2020 10:25:31 +0100 Subject: [PATCH 082/144] Continued email building --- .../datarequest/datarequest.controller.js | 60 ++++++++++++++- .../utilities/emailGenerator.util.js | 75 +++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b170510b..967fd3b2 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -986,11 +986,22 @@ module.exports = { console.error(err); res.status(500).json({ status: 'error', message: err }); } else { - // Call Camunda controller to update workflow process + if(bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { + // 15. Gather context for notifications + const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); + // 16. Create notifications to reviewers of the next step that has been activated + module.exports.createNotifications('ReviewStepStart', emailContext, accessRecord, req.user); + } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { + // 15. Gather context for notifications + const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); + // 16. Create notifications to managers that the application is awaiting final approval + module.exports.createNotifications('FinalDecisionRequired', emailContext, accessRecord, req.user); + } + // 17. Call Camunda controller to update workflow process bpmController.postCompleteReview(bpmContext); } }); - // 15. Return aplication and successful response + // 18. Return aplication and successful response return res .status(200) .json({ status: 'success', data: accessRecord._doc }); @@ -1574,6 +1585,51 @@ module.exports = { ); } break; + case 'FinalDecisionRequired': + let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; + if ( + _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') + ) { + if(!_.isEmpty(workflow)) { + // Retrieve all user Ids for active step reviewers + stepReviewers = workflowController.getActiveStepReviewers(workflow); + reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + } + + // 1. Create reviewer notifications + let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); + await notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + 'data access request', + accessRecord._id + ); + + // 2. Create reviewer emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + nextStepName + }; + html = await emailGenerator.generateFinalDecisionRequiredEmail(options); + await emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + html, + false + ); + } + break; case 'DeadlineWarning': let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 3ad58843..41c7298d 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -851,6 +851,80 @@ const _generateReviewDeadlinePassed = (options) => { return body; }; +const _generateFinalDecisionRequiredEmail = (options) => { + let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames } = options; + let body = `
+ + + + + + + + + + + + + + +
+ Data access request application is now awaiting final approval +
+ The final phase ${stepName} of workflow ${workflowName} has now been completed for the following data access request application. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Review phase completed${workflowName} - ${stepName}
Review sections${reviewSections}
Reviewers${reviewerNames}
Deadline${moment(dateDeadline).format('D MMM YYYY HH:mm')}
+
+
+ ${_displayDARLink(id)} +
+
`; + return body; +} + /** * [_sendEmail] * @@ -932,6 +1006,7 @@ export default { generateNewReviewPhaseEmail: _generateNewReviewPhaseEmail, generateReviewDeadlineWarning: _generateReviewDeadlineWarning, generateReviewDeadlinePassed: _generateReviewDeadlinePassed, + generateFinalDecisionRequiredEmail: _generateFinalDecisionRequiredEmail, sendEmail: _sendEmail, generateEmailFooter: _generateEmailFooter }; From 721c9b2753485048f13de05a1dfad14e4f900702 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 15 Oct 2020 13:52:05 +0100 Subject: [PATCH 083/144] Added additional parameter to determine if the user can override current phase --- .../datarequest/datarequest.controller.js | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 100fa1dd..bb9de028 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -133,8 +133,18 @@ module.exports = { ); // 8. Get the workflow/voting status let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); - - // 9. Return application form + // 9. Check if the current user can override the current step + let isManager = false; + if(_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { + isManager = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + accessRecord.datasets[0].publisher.team.toObject(), + req.user._id + ); + // Set the workflow override capability if there is an active step and user is a manager + workflow.canOverrideStep = !workflow.isCompleted && isManager; + } + // 10. Return application form return res.status(200).json({ status: 'success', data: { @@ -150,7 +160,7 @@ module.exports = { helper.generateFriendlyId(accessRecord._id), inReviewMode, reviewSections, - workflow, + workflow }, }); } catch (err) { @@ -1067,7 +1077,7 @@ module.exports = { bpmController.postCompleteReview(bpmContext); } }); - // 13. Return aplication and successful response + // 12. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { console.log(err.message); @@ -1710,7 +1720,7 @@ module.exports = { workflowStatus = { workflowName, steps: formattedSteps, - isCompleted: module.exports.getWorkflowCompleted(workflow), + isCompleted: module.exports.getWorkflowCompleted(workflow) }; } return workflowStatus; From 49ef0c8f06c132c63e7b1f0efc160d5f8cd753ec Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 16 Oct 2020 11:18:36 +0100 Subject: [PATCH 084/144] Fixed defect detecting active step reviewer --- src/resources/datarequest/datarequest.controller.js | 5 ++--- src/resources/publisher/publisher.controller.js | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index bb9de028..a66c6b44 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -134,9 +134,8 @@ module.exports = { // 8. Get the workflow/voting status let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); // 9. Check if the current user can override the current step - let isManager = false; if(_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { - isManager = teamController.checkTeamPermissions( + let isManager = teamController.checkTeamPermissions( teamController.roleTypes.MANAGER, accessRecord.datasets[0].publisher.team.toObject(), req.user._id @@ -1670,7 +1669,7 @@ module.exports = { }); if (activeStep) { isActiveStepReviewer = activeStep.reviewers.some( - (reviewer) => reviewer.toString() === userId.toString() + (reviewer) => reviewer._id.toString() === userId.toString() ); reviewSections = [...activeStep.sections]; } diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index e5ac828b..931907e2 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -291,4 +291,4 @@ module.exports = { }); } }, -}; +}; \ No newline at end of file From 994be4a4494f89210b6a82db77e85ebd354de233 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 16 Oct 2020 15:00:42 +0100 Subject: [PATCH 085/144] Added endpoint to search any entity by tag --- src/resources/project/project.route.js | 253 +++++---- src/resources/tool/tool.route.js | 696 +++++++++++++------------ 2 files changed, 518 insertions(+), 431 deletions(-) diff --git a/src/resources/project/project.route.js b/src/resources/project/project.route.js index b5c4b444..9162c9d2 100644 --- a/src/resources/project/project.route.js +++ b/src/resources/project/project.route.js @@ -1,144 +1,183 @@ -import express from 'express' -import { Data } from '../tool/data.model' -import { ROLES } from '../user/user.roles' -import passport from "passport"; -import { utils } from "../auth"; -import {addTool, editTool, deleteTool, setStatus, getTools, getToolsAdmin} from '../tool/data.repository'; +import express from 'express'; +import { Data } from '../tool/data.model'; +import { ROLES } from '../user/user.roles'; +import passport from 'passport'; +import { utils } from '../auth'; +import { + addTool, + editTool, + deleteTool, + setStatus, + getTools, + getToolsAdmin, +} from '../tool/data.repository'; const router = express.Router(); // @router POST /api/v1/ // @desc Add project user // @access Private -router.post('/', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await addTool(req) - .then(response => { - return res.json({ success: true, response}); - }) - .catch(err => { - return res.json({ success: false, err}); - }) - } +router.post( + '/', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await addTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router GET /api/v1/ // @desc Returns List of Project Objects Authenticated // @access Private router.get( - '/getList', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - req.params.type = 'project'; - let role = req.user.role; + '/getList', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + req.params.type = 'project'; + let role = req.user.role; - if (role === ROLES.Admin) { - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } else if (role === ROLES.Creator) { - await getTools(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } - } + if (role === ROLES.Admin) { + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } else if (role === ROLES.Creator) { + await getTools(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } + } ); // @router GET /api/v1/ // @desc Returns List of Project Objects No auth // This unauthenticated route was created specifically for API-docs // @access Public -router.get( - '/', - async (req, res) => { - req.params.type = 'project'; - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } -); +router.get('/', async (req, res) => { + req.params.type = 'project'; + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); +}); // @router GET /api/v1/ // @desc Returns a Project object // @access Public router.get('/:projectID', async (req, res) => { - var q = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.projectID) }, {type: 'project'}] } }, - { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } } - ]); - q.exec((err, data) => { - if (data.length > 0) { - var p = Data.aggregate([ - { $match: { $and: [{ "relatedObjects": { $elemMatch: { "objectId": req.params.projectID } } }] } }, - ]); + var q = Data.aggregate([ + { + $match: { + $and: [{ id: parseInt(req.params.projectID) }, { type: 'project' }], + }, + }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + ]); + q.exec((err, data) => { + if (data.length > 0) { + var p = Data.aggregate([ + { + $match: { + $and: [ + { + relatedObjects: { + $elemMatch: { objectId: req.params.projectID }, + }, + }, + ], + }, + }, + ]); - p.exec( async (err, relatedData) => { - relatedData.forEach((dat) => { - dat.relatedObjects.forEach((x) => { - if (x.objectId === req.params.projectID && dat.id !== req.params.projectID) { - if (typeof data[0].relatedObjects === "undefined") data[0].relatedObjects=[]; - data[0].relatedObjects.push({ objectId: dat.id, reason: x.reason, objectType: dat.type, user: x.user, updated: x.updated }) - } - }) - }); + p.exec(async (err, relatedData) => { + relatedData.forEach((dat) => { + dat.relatedObjects.forEach((x) => { + if ( + x.objectId === req.params.projectID && + dat.id !== req.params.projectID + ) { + if (typeof data[0].relatedObjects === 'undefined') + data[0].relatedObjects = []; + data[0].relatedObjects.push({ + objectId: dat.id, + reason: x.reason, + objectType: dat.type, + user: x.user, + updated: x.updated, + }); + } + }); + }); - if (err) return res.json({ success: false, error: err }); - return res.json({ success: true, data: data }); - }); - } - else{ - return res.status(404).send(`Project not found for Id: ${req.params.projectID}`); - } - }); + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true, data: data }); + }); + } else { + return res + .status(404) + .send(`Project not found for Id: ${req.params.projectID}`); + } + }); }); // @router PATCH /api/v1/status // @desc Set project status // @access Private -router.patch('/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - await setStatus(req) - .then(response => { - return res.json({success: true, response}); - }) - .catch(err => { - return res.json({success: false, err}); - }); - } +router.patch( + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + await setStatus(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router PUT /api/v1/ -// @desc Edit project +// @desc Edit project // @access Private -router.put('/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await editTool(req) - .then(response => { - return res.json({ success: true, response}); - }) - .catch(err => { - return res.json({ success: false, err}); - }) - } +router.put( + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await editTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/resources/tool/tool.route.js b/src/resources/tool/tool.route.js index 2d88909d..b5e5029d 100644 --- a/src/resources/tool/tool.route.js +++ b/src/resources/tool/tool.route.js @@ -7,15 +7,16 @@ import { utils } from '../auth'; import { UserModel } from '../user/user.model'; import { MessagesModel } from '../message/message.model'; import { - addTool, - editTool, - deleteTool, - setStatus, - getTools, - getToolsAdmin, + addTool, + editTool, + deleteTool, + setStatus, + getTools, + getToolsAdmin, } from '../tool/data.repository'; import emailGenerator from '../utilities/emailGenerator.util'; import inputSanitizer from '../utilities/inputSanitizer'; +import _ from 'lodash'; const hdrukEmail = `enquiry@healthdatagateway.org`; const router = express.Router(); @@ -23,18 +24,18 @@ const router = express.Router(); // @desc Add tools user // @access Private router.post( - '/', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await addTool(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } + '/', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await addTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router PUT /api/v1/{id} @@ -42,85 +43,82 @@ router.post( // @access Private // router.put('/{id}', router.put( - '/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await editTool(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, error: err.message }); - }); - } + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await editTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, error: err.message }); + }); + } ); // @router GET /api/v1/get/admin // @desc Returns List of Tool objects // @access Private router.get( - '/getList', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - req.params.type = 'tool'; - let role = req.user.role; + '/getList', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + req.params.type = 'tool'; + let role = req.user.role; - if (role === ROLES.Admin) { - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } else if (role === ROLES.Creator) { - await getTools(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } - } + if (role === ROLES.Admin) { + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } else if (role === ROLES.Creator) { + await getTools(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } + } ); // @router GET /api/v1/ // @desc Returns List of Tool Objects No auth // This unauthenticated route was created specifically for API-docs // @access Public -router.get( - '/', - async (req, res) => { - req.params.type = 'tool'; - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } -); +router.get('/', async (req, res) => { + req.params.type = 'tool'; + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); +}); // @router PATCH /api/v1/status // @desc Set tool status // @access Private router.patch( - '/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - await setStatus(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, error: err.message }); - }); - } + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + await setStatus(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, error: err.message }); + }); + } ); /** @@ -129,93 +127,96 @@ router.patch( * Return the details on the tool based on the tool ID. */ router.get('/:id', async (req, res) => { - var query = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.id) }, {type: 'tool'}]} }, - { - $lookup: { - from: 'tools', - localField: 'authors', - foreignField: 'id', - as: 'persons', - }, - }, - { - $lookup: { - from: 'tools', - localField: 'uploader', - foreignField: 'id', - as: 'uploaderIs', - }, - }, - ]); - query.exec((err, data) => { - if (data.length > 0) { - var p = Data.aggregate([ - { - $match: { - $and: [ - { relatedObjects: { $elemMatch: { objectId: req.params.id } } }, - ], - }, - }, - ]); - p.exec((err, relatedData) => { - relatedData.forEach((dat) => { - dat.relatedObjects.forEach((x) => { - if (x.objectId === req.params.id && dat.id !== req.params.id) { - let relatedObject = { - objectId: dat.id, - reason: x.reason, - objectType: dat.type, - user: x.user, - updated: x.updated - }; - data[0].relatedObjects = [relatedObject, ...data[0].relatedObjects || []]; - } - }); - }); + var query = Data.aggregate([ + { $match: { $and: [{ id: parseInt(req.params.id) }, { type: 'tool' }] } }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + { + $lookup: { + from: 'tools', + localField: 'uploader', + foreignField: 'id', + as: 'uploaderIs', + }, + }, + ]); + query.exec((err, data) => { + if (data.length > 0) { + var p = Data.aggregate([ + { + $match: { + $and: [ + { relatedObjects: { $elemMatch: { objectId: req.params.id } } }, + ], + }, + }, + ]); + p.exec((err, relatedData) => { + relatedData.forEach((dat) => { + dat.relatedObjects.forEach((x) => { + if (x.objectId === req.params.id && dat.id !== req.params.id) { + let relatedObject = { + objectId: dat.id, + reason: x.reason, + objectType: dat.type, + user: x.user, + updated: x.updated, + }; + data[0].relatedObjects = [ + relatedObject, + ...(data[0].relatedObjects || []), + ]; + } + }); + }); - var r = Reviews.aggregate([ - { - $match: { - $and: [ - { toolID: parseInt(req.params.id) }, - { activeflag: 'active' }, - ], - }, - }, - { $sort: { date: -1 } }, - { - $lookup: { - from: 'tools', - localField: 'reviewerID', - foreignField: 'id', - as: 'person', - }, - }, - { - $lookup: { - from: 'tools', - localField: 'replierID', - foreignField: 'id', - as: 'owner', - }, - }, - ]); - r.exec(async (err, reviewData) => { - if (err) return res.json({ success: false, error: err }); + var r = Reviews.aggregate([ + { + $match: { + $and: [ + { toolID: parseInt(req.params.id) }, + { activeflag: 'active' }, + ], + }, + }, + { $sort: { date: -1 } }, + { + $lookup: { + from: 'tools', + localField: 'reviewerID', + foreignField: 'id', + as: 'person', + }, + }, + { + $lookup: { + from: 'tools', + localField: 'replierID', + foreignField: 'id', + as: 'owner', + }, + }, + ]); + r.exec(async (err, reviewData) => { + if (err) return res.json({ success: false, error: err }); - return res.json({ - success: true, - data: data, - reviewData: reviewData - }); - }); - }); - } else { - return res.status(404).send(`Tool not found for Id: ${req.params.id}`); - } - }); + return res.json({ + success: true, + data: data, + reviewData: reviewData, + }); + }); + }); + } else { + return res.status(404).send(`Tool not found for Id: ${req.params.id}`); + } + }); }); /** @@ -224,27 +225,27 @@ router.get('/:id', async (req, res) => { * Return the details on the tool based on the tool ID for edit. */ router.get('/edit/:id', async (req, res) => { - var query = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.id) }] } }, - { - $lookup: { - from: 'tools', - localField: 'authors', - foreignField: 'id', - as: 'persons', - }, - }, - ]); - query.exec((err, data) => { - if (data.length > 0) { - return res.json({ success: true, data: data }); - } else { - return res.json({ - success: false, - error: `Tool not found for tool id ${req.params.id}`, - }); - } - }); + var query = Data.aggregate([ + { $match: { $and: [{ id: parseInt(req.params.id) }] } }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + ]); + query.exec((err, data) => { + if (data.length > 0) { + return res.json({ success: true, data: data }); + } else { + return res.json({ + success: false, + error: `Tool not found for tool id ${req.params.id}`, + }); + } + }); }); /** @@ -255,30 +256,30 @@ router.get('/edit/:id', async (req, res) => { * We will also check the review (Free word entry) for exclusion data (node module?) */ router.post( - '/review/add', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - let reviews = new Reviews(); - const { toolID, reviewerID, rating, projectName, review } = req.body; + '/review/add', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + let reviews = new Reviews(); + const { toolID, reviewerID, rating, projectName, review } = req.body; - reviews.reviewID = parseInt(Math.random().toString().replace('0.', '')); - reviews.toolID = toolID; - reviews.reviewerID = reviewerID; - reviews.rating = rating; - reviews.projectName = inputSanitizer.removeNonBreakingSpaces(projectName); - reviews.review = inputSanitizer.removeNonBreakingSpaces(review); - reviews.activeflag = 'review'; - reviews.date = Date.now(); + reviews.reviewID = parseInt(Math.random().toString().replace('0.', '')); + reviews.toolID = toolID; + reviews.reviewerID = reviewerID; + reviews.rating = rating; + reviews.projectName = inputSanitizer.removeNonBreakingSpaces(projectName); + reviews.review = inputSanitizer.removeNonBreakingSpaces(review); + reviews.activeflag = 'review'; + reviews.date = Date.now(); - reviews.save(async (err) => { - if (err) { - return res.json({ success: false, error: err }); - } else { - return res.json({ success: true, id: reviews.reviewID }); - } - }); - } + reviews.save(async (err) => { + if (err) { + return res.json({ success: false, error: err }); + } else { + return res.json({ success: true, id: reviews.reviewID }); + } + }); + } ); /** @@ -289,56 +290,56 @@ router.post( * We will also check the review (Free word entry) for exclusion data (node module?) */ router.post( - '/reply', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - const { reviewID, replierID, reply } = req.body; - Reviews.findOneAndUpdate( - { reviewID: reviewID }, - { - replierID: replierID, - reply: inputSanitizer.removeNonBreakingSpaces(reply), - replydate: Date.now(), - }, - (err) => { - if (err) return res.json({ success: false, error: err }); - return res.json({ success: true }); - } - ); - } + '/reply', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + const { reviewID, replierID, reply } = req.body; + Reviews.findOneAndUpdate( + { reviewID: reviewID }, + { + replierID: replierID, + reply: inputSanitizer.removeNonBreakingSpaces(reply), + replydate: Date.now(), + }, + (err) => { + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true }); + } + ); + } ); - + /** * {post} /tool/review/approve Approve review * * Authenticate user to see if user can approve. */ router.put( - '/review/approve', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - const { id, activeflag } = req.body; - Reviews.findOneAndUpdate( - { reviewID: id }, - { - activeflag: activeflag, - }, - (err) => { - if (err) return res.json({ success: false, error: err }); + '/review/approve', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + const { id, activeflag } = req.body; + Reviews.findOneAndUpdate( + { reviewID: id }, + { + activeflag: activeflag, + }, + (err) => { + if (err) return res.json({ success: false, error: err }); - return res.json({ success: true }); - } - ).then(async (res) => { - const review = await Reviews.findOne({ reviewID: id }); + return res.json({ success: true }); + } + ).then(async (res) => { + const review = await Reviews.findOne({ reviewID: id }); - await storeNotificationMessages(review); + await storeNotificationMessages(review); - // Send email notififcation of approval to authors and admins who have opted in - await sendEmailNotifications(review); - }); - } + // Send email notififcation of approval to authors and admins who have opted in + await sendEmailNotifications(review); + }); + } ); /** @@ -347,16 +348,16 @@ router.put( * Authenticate user to see if user can reject. */ router.delete( - '/review/reject', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - const { id } = req.body; - Reviews.findOneAndDelete({ reviewID: id }, (err) => { - if (err) return res.send(err); - return res.json({ success: true }); - }); - } + '/review/reject', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + const { id } = req.body; + Reviews.findOneAndDelete({ reviewID: id }, (err) => { + if (err) return res.send(err); + return res.json({ success: true }); + }); + } ); /** @@ -365,16 +366,16 @@ router.delete( * When they delete, authenticate the user and remove the review data from the DB. */ router.delete( - '/review/delete', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - const { id } = req.body; - Data.findOneAndDelete({ id: id }, (err) => { - if (err) return res.send(err); - return res.json({ success: true }); - }); - } + '/review/delete', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + const { id } = req.body; + Data.findOneAndDelete({ id: id }, (err) => { + if (err) return res.send(err); + return res.json({ success: true }); + }); + } ); //Validation required if Delete is to be implemented @@ -392,70 +393,117 @@ router.delete( // } // ); +// @router GET /api/v1/project/tag/name +// @desc Get tools by tag search +// @access Public +router.get('/:type/tag/:name', async (req, res) => { + try { + // 1. Destructure tag name parameter passed + let { type, name } = req.params; + // 2. Check if parameters are empty + if (_.isEmpty(name) || _.isEmpty(type)) { + return res + .status(400) + .json({ success: false, message: 'Entity type and tag are required' }); + } + // 3. Find matching projects in MongoDb selecting name and id + let entities = await Data.find({ + $and: [ + { type }, + { $or: [{ 'tags.topics': name }, { 'tags.features': name }] }, + ], + }).select('id name'); + // 4. Return projects + return res.status(200).json({ success: true, entities }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for tools by tag', + }); + } +}); + module.exports = router; async function storeNotificationMessages(review) { - const tool = await Data.findOne({ id: review.toolID }); - //Get reviewer name - const reviewer = await UserModel.findOne({ id: review.reviewerID }); - const toolLink = - process.env.homeURL + '/tool/' + review.toolID + '/' + tool.name; - //admins - let message = new MessagesModel(); - message.messageID = parseInt(Math.random().toString().replace('0.', '')); - message.messageTo = 0; - message.messageObjectID = review.toolID; - message.messageType = 'review'; - message.messageSent = Date.now(); - message.isRead = false; - message.messageDescription = `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name} ${toolLink}`; + const tool = await Data.findOne({ id: review.toolID }); + //Get reviewer name + const reviewer = await UserModel.findOne({ id: review.reviewerID }); + const toolLink = + process.env.homeURL + '/tool/' + review.toolID + '/' + tool.name; + //admins + let message = new MessagesModel(); + message.messageID = parseInt(Math.random().toString().replace('0.', '')); + message.messageTo = 0; + message.messageObjectID = review.toolID; + message.messageType = 'review'; + message.messageSent = Date.now(); + message.isRead = false; + message.messageDescription = `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name} ${toolLink}`; - await message.save(async (err) => { - if (err) { - return new Error({ success: false, error: err }); - } - }); - //authors - const authors = tool.authors; - authors.forEach(async (author) => { - message.messageTo = author; - await message.save(async (err) => { - if (err) { - return new Error({ success: false, error: err }); - } - }); - }); - return { success: true, id: message.messageID }; + await message.save(async (err) => { + if (err) { + return new Error({ success: false, error: err }); + } + }); + //authors + const authors = tool.authors; + authors.forEach(async (author) => { + message.messageTo = author; + await message.save(async (err) => { + if (err) { + return new Error({ success: false, error: err }); + } + }); + }); + return { success: true, id: message.messageID }; } async function sendEmailNotifications(review) { - // 1. Retrieve tool for authors and reviewer user plus generate URL for linking tool - const tool = await Data.findOne({ id: review.toolID }); - const reviewer = await UserModel.findOne({ id: review.reviewerID }); - const toolLink = process.env.homeURL + '/tool/' + tool.id + '/' + tool.name; + // 1. Retrieve tool for authors and reviewer user plus generate URL for linking tool + const tool = await Data.findOne({ id: review.toolID }); + const reviewer = await UserModel.findOne({ id: review.reviewerID }); + const toolLink = process.env.homeURL + '/tool/' + tool.id + '/' + tool.name; - // 2. Query Db for all admins or authors of the tool who have opted in to email updates - var q = UserModel.aggregate([ - // Find all users who are admins or authors of this tool - { $match: { $or: [{ role: 'Admin' }, { id: { $in: tool.authors } }] } }, - // Perform lookup to check opt in/out flag in tools schema - { $lookup: { from: 'tools', localField: 'id', foreignField: 'id', as: 'tool' } }, - // Filter out any user who has opted out of email notifications - { $match: { 'tool.emailNotifications': true } }, - // Reduce response payload size to required fields - { $project: { _id: 1, firstname: 1, lastname: 1, email: 1, role: 1, 'tool.emailNotifications': 1 } } - ]); + // 2. Query Db for all admins or authors of the tool who have opted in to email updates + var q = UserModel.aggregate([ + // Find all users who are admins or authors of this tool + { $match: { $or: [{ role: 'Admin' }, { id: { $in: tool.authors } }] } }, + // Perform lookup to check opt in/out flag in tools schema + { + $lookup: { + from: 'tools', + localField: 'id', + foreignField: 'id', + as: 'tool', + }, + }, + // Filter out any user who has opted out of email notifications + { $match: { 'tool.emailNotifications': true } }, + // Reduce response payload size to required fields + { + $project: { + _id: 1, + firstname: 1, + lastname: 1, + email: 1, + role: 1, + 'tool.emailNotifications': 1, + }, + }, + ]); - // 3. Use the returned array of email recipients to generate and send emails with SendGrid - q.exec((err, emailRecipients) => { - if (err) { - return new Error({ success: false, error: err }); - } - emailGenerator.sendEmail( - emailRecipients, - `${hdrukEmail}`, - `Someone reviewed your tool`, - `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name}

${toolLink}` - ); - }); + // 3. Use the returned array of email recipients to generate and send emails with SendGrid + q.exec((err, emailRecipients) => { + if (err) { + return new Error({ success: false, error: err }); + } + emailGenerator.sendEmail( + emailRecipients, + `${hdrukEmail}`, + `Someone reviewed your tool`, + `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name}

${toolLink}` + ); + }); } From 44c5476ddaf8957c41adfc20edf34fe3def3146e Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 16 Oct 2020 16:21:55 +0100 Subject: [PATCH 086/144] Added hasRecommended flag --- .../datarequest/datarequest.controller.js | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index a66c6b44..7c0dba69 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -127,7 +127,7 @@ module.exports = { readOnly = false; } // 7. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections } = module.exports.getReviewStatus( + let { inReviewMode, reviewSections, hasRecommended } = module.exports.getReviewStatus( accessRecord, req.user._id ); @@ -159,7 +159,8 @@ module.exports = { helper.generateFriendlyId(accessRecord._id), inReviewMode, reviewSections, - workflow + hasRecommended, + workflow, }, }); } catch (err) { @@ -1657,7 +1658,8 @@ module.exports = { getReviewStatus: (application, userId) => { let inReviewMode = false, reviewSections = [], - isActiveStepReviewer = false; + isActiveStepReviewer = false, + hasRecommended = false; // Get current application status let { applicationStatus } = application; // Check if the current user is a reviewer on the current step of an attached workflow @@ -1672,6 +1674,13 @@ module.exports = { (reviewer) => reviewer._id.toString() === userId.toString() ); reviewSections = [...activeStep.sections]; + + let { recommendations = [] } = activeStep; + if(!_.isEmpty(recommendations)) { + hasRecommended = recommendations.some( + (rec) => rec.reviewer.toString() === userId.toString() + ); + } } } // Return active review mode if conditions apply @@ -1679,7 +1688,7 @@ module.exports = { inReviewMode = true; } - return { inReviewMode, reviewSections }; + return { inReviewMode, reviewSections, hasRecommended }; }, getWorkflowStatus: (application) => { @@ -1701,7 +1710,7 @@ module.exports = { steps[activeStepIndex] = { ...steps[activeStepIndex], reviewStatus, - deadlinePassed, + deadlinePassed }; } //Update steps with user friendly review sections @@ -1816,7 +1825,7 @@ module.exports = { decisionDate, decisionStatus, decisionComments, - reviewPanels, + reviewPanels }; }, From 5038e04c58fd55ec1f4405ee5bca96fd7e5a6dce Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 19 Oct 2020 15:12:32 +0100 Subject: [PATCH 087/144] Added firstname and lastname output to DAR console reviewers --- src/resources/publisher/publisher.controller.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 931907e2..7a0c2bf2 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -144,6 +144,10 @@ module.exports = { }, }, }, + { + path: 'workflow.steps.reviewers', + select: 'firstname lastname' + } ]); if (!isManager) { From c252c997a5447cc93a9a9978a9488516f0086b82 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 19 Oct 2020 16:14:24 +0100 Subject: [PATCH 088/144] Added check to canOverrideStep on DAR --- src/resources/datarequest/datarequest.controller.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 7c0dba69..02f9c757 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -141,7 +141,9 @@ module.exports = { req.user._id ); // Set the workflow override capability if there is an active step and user is a manager - workflow.canOverrideStep = !workflow.isCompleted && isManager; + if(!_.isEmpty(workflow)) { + workflow.canOverrideStep = !workflow.isCompleted && isManager; + } } // 10. Return application form return res.status(200).json({ From 9bf662ee2745cf9757d12b7508472b0a70077cc5 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 19 Oct 2020 16:46:45 +0100 Subject: [PATCH 089/144] Added display sections output --- src/resources/publisher/publisher.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 7a0c2bf2..24c9597f 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -258,7 +258,7 @@ module.exports = { let formattedSteps = [...steps].reduce((arr, item) => { let step = { ...item, - sections: [...item.sections].map(section => helper.darPanelMapper[section]) + displaySections: [...item.sections].map(section => helper.darPanelMapper[section]) } arr.push(step); return arr; From d6702af83630b3fbaaa1232fe889c57dda11beb2 Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Tue, 20 Oct 2020 10:29:01 +0100 Subject: [PATCH 090/144] Update publisher reviewe check --- src/resources/publisher/publisher.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 24c9597f..65b8361a 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -168,7 +168,7 @@ module.exports = { let elapsedSteps = [...steps].slice(0, activeStepIndex + 1); let found = elapsedSteps.some((step) => - step.reviewers.some((reviewer) => reviewer.equals(_id)) + step.reviewers.some((reviewer) => reviewer_.id.equals(_id)) ); if (found) { From 23db82e811da1461d4229488362e71384339061c Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Tue, 20 Oct 2020 11:20:25 +0100 Subject: [PATCH 091/144] Updatest to API --- .../datarequest/datarequest.controller.js | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 02f9c757..9ecba07f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -487,16 +487,22 @@ module.exports = { if (userType === userTypes.CUSTODIAN) { // Only a custodian manager can set the final status of an application authorised = false; - if (_.has(accessRecord.publisherObj.toObject(), 'team')) { - let { - publisherObj: { team }, - } = accessRecord; - authorised = teamController.checkTeamPermissions( - teamController.roleTypes.MANAGER, - team.toObject(), - _id - ); + let team = {}; + if (_.isNull(accessRecord.publisherObj)) { + ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); + } else { + ({ team = {} } = accessRecord.publisherObj.toObject()); + } + + if (!_.isEmpty(team)) { + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + team, + _id + ); + } + if (!authorised) { return res .status(401) From f941c35be5ab80b53644d6acc5a074c353e83830 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 11:40:15 +0100 Subject: [PATCH 092/144] Fixed publisher workflow backward compatibility --- src/resources/datarequest/datarequest.controller.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 9ecba07f..ddc3d659 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1129,8 +1129,12 @@ module.exports = { // 4. Update application to submitted status accessRecord.applicationStatus = 'submitted'; // Check if workflow/5 Safes based application, set final status date if status will never change again - if (!accessRecord.datasets[0].publisher.workflowEnabled) { - accessRecord.dateFinalStatus = new Date(); + let workflowEnabled = false; + if(_.has(accessRecord.datasets[0], 'publisher')) { + if (!accessRecord.datasets[0].publisher.workflowEnabled) { + workflowEnabled = true; + accessRecord.dateFinalStatus = new Date(); + } } let dateSubmitted = new Date(); accessRecord.dateSubmitted = dateSubmitted; @@ -1148,7 +1152,7 @@ module.exports = { req.user ); // Start workflow process if publisher requires it - if (accessRecord.datasets[0].publisher.workflowEnabled) { + if (workflowEnabled) { // Call Camunda controller to start workflow for submitted application let { publisherObj: { name: publisher }, From 3c35e706d94e938b3dbecd9a48f2283d83c1bc9d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 12:26:46 +0100 Subject: [PATCH 093/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 2 +- src/resources/message/message.controller.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index ddc3d659..9ca8e713 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1470,7 +1470,7 @@ module.exports = { } } // If user is not authenticated as a custodian, check if they are an author or the main applicant - if (_.isEmpty(userType)) { + if (application.applicationStatus === 'inProgress' || _.isEmpty(userType)) { if ( application.authorIds.includes(userId) || application.userId === userId diff --git a/src/resources/message/message.controller.js b/src/resources/message/message.controller.js index 6dcf1e39..8b24e7ae 100644 --- a/src/resources/message/message.controller.js +++ b/src/resources/message/message.controller.js @@ -56,7 +56,6 @@ module.exports = { // 5. Create new message const message = await MessagesModel.create({ messageID: parseInt(Math.random().toString().replace('0.', '')), - messageTo: 0, messageObjectID: parseInt(Math.random().toString().replace('0.', '')), messageDescription, topic, From ac72140fbfcdfb1851fadf5a4788f22a3b230fe8 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 14:26:51 +0100 Subject: [PATCH 094/144] Fixed defects --- .../datarequest/datarequest.controller.js | 44 ++++++++++++------- src/resources/workflow/workflow.controller.js | 2 +- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 9ca8e713..73b1f6a2 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1101,21 +1101,34 @@ module.exports = { params: { id }, } = req; // 2. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ - path: 'datasets dataset mainApplicant authors publisherObj', - populate: { - path: 'publisher additionalInfo', - populate: { - path: 'team', + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate( + [ + { + path: 'datasets dataset', populate: { - path: 'users', + path: 'publisher', populate: { - path: 'additionalInfo', - }, - }, + path: 'team', + populate: { + path: 'users', + populate: { + path: 'additionalInfo', + }, + }, + } + } }, - }, - }); + { + path: 'mainApplicant authors', + populate: { + path: 'additionalInfo' + } + }, + { + path: 'publisherObj' + } + ] + ); if (!accessRecord) { return res .status(404) @@ -1130,10 +1143,11 @@ module.exports = { accessRecord.applicationStatus = 'submitted'; // Check if workflow/5 Safes based application, set final status date if status will never change again let workflowEnabled = false; - if(_.has(accessRecord.datasets[0], 'publisher')) { + if(_.has(accessRecord.datasets[0].toObject(), 'publisher')) { if (!accessRecord.datasets[0].publisher.workflowEnabled) { - workflowEnabled = true; accessRecord.dateFinalStatus = new Date(); + } else { + workflowEnabled = true; } } let dateSubmitted = new Date(); @@ -1552,7 +1566,7 @@ module.exports = { .map((user) => { return `${user.firstname} ${user.lastname}`; }); - if (applicationStatus === 'submitted') { + if (applicationStatus === 'submitted' || (applicationStatus === 'inReview' && !_.isEmpty(workflow))) { remainingActioners = managerUsers.join(', '); } if (!_.isEmpty(workflow)) { diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 8acf75b9..4b80059a 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -324,7 +324,7 @@ module.exports = { // Extract deadline and reminder offset in days from step definition let { deadline, reminderOffset } = step; // Subtract SLA reminder offset - let reminderPeriod = deadline - reminderOffset; + let reminderPeriod = +deadline - +reminderOffset; return `P${reminderPeriod}D`; }, From b0bb18da6874db86126577d3b3e8775db1fec65b Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 14:28:51 +0100 Subject: [PATCH 095/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 73b1f6a2..3d054483 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1566,7 +1566,7 @@ module.exports = { .map((user) => { return `${user.firstname} ${user.lastname}`; }); - if (applicationStatus === 'submitted' || (applicationStatus === 'inReview' && !_.isEmpty(workflow))) { + if (applicationStatus === 'submitted' || (applicationStatus === 'inReview' && _.isEmpty(workflow))) { remainingActioners = managerUsers.join(', '); } if (!_.isEmpty(workflow)) { From ff3180933e956e7ce6b93dc99383eea421371d0d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 14:48:16 +0100 Subject: [PATCH 096/144] Fixed defects --- src/resources/publisher/publisher.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 65b8361a..b4745644 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -168,7 +168,7 @@ module.exports = { let elapsedSteps = [...steps].slice(0, activeStepIndex + 1); let found = elapsedSteps.some((step) => - step.reviewers.some((reviewer) => reviewer_.id.equals(_id)) + step.reviewers.some((reviewer) => reviewer._id.equals(_id)) ); if (found) { From 6d7201d2d972c713ef0f59a07f08ca3ae7bb8b29 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 14:52:33 +0100 Subject: [PATCH 097/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 3d054483..6c8eb369 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1809,7 +1809,7 @@ module.exports = { }); let isReviewer = reviewers.some( - (reviewer) => reviewer.toString() === userId.toString() + (reviewer) => reviewer._id.toString() === userId.toString() ); let hasRecommended = recommendations.some( (rec) => rec.reviewer.toString() === userId.toString() From dbd456dc8df4d04e03ad428ffa2970c701b645a3 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 15:03:38 +0100 Subject: [PATCH 098/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 6c8eb369..623b647a 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1795,13 +1795,13 @@ module.exports = { remainingActioners = reviewers.filter( (reviewer) => !recommendations.some( - (rec) => rec.reviewer.toString() === reviewer.toString() + (rec) => rec.reviewer.toString() === reviewer._id.toString() ) ); remainingActioners = users .filter((user) => remainingActioners.some( - (actioner) => actioner.toString() === user._id.toString() + (actioner) => actioner._id.toString() === user._id.toString() ) ) .map((user) => { From 534356aa2688faf179321b33f3b4a4cc0dc52ede Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 15:15:00 +0100 Subject: [PATCH 099/144] Fixed defects --- .../datarequest/datarequest.controller.js | 120 ++++++++++-------- 1 file changed, 69 insertions(+), 51 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 623b647a..8cbc07ed 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -92,7 +92,7 @@ module.exports = { path: 'datasets dataset authors', populate: { path: 'publisher', populate: { path: 'team' } }, }, - { path: 'workflow.steps.reviewers', select: 'firstname lastname' } + { path: 'workflow.steps.reviewers', select: 'firstname lastname' }, ]); // 3. If no matching application found, return 404 if (!accessRecord) { @@ -127,21 +127,22 @@ module.exports = { readOnly = false; } // 7. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections, hasRecommended } = module.exports.getReviewStatus( - accessRecord, - req.user._id - ); + let { + inReviewMode, + reviewSections, + hasRecommended, + } = module.exports.getReviewStatus(accessRecord, req.user._id); // 8. Get the workflow/voting status let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); // 9. Check if the current user can override the current step - if(_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { - let isManager = teamController.checkTeamPermissions( + if (_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { + let isManager = teamController.checkTeamPermissions( teamController.roleTypes.MANAGER, accessRecord.datasets[0].publisher.team.toObject(), req.user._id ); // Set the workflow override capability if there is an active step and user is a manager - if(!_.isEmpty(workflow)) { + if (!_.isEmpty(workflow)) { workflow.canOverrideStep = !workflow.isCompleted && isManager; } } @@ -489,18 +490,17 @@ module.exports = { authorised = false; let team = {}; if (_.isNull(accessRecord.publisherObj)) { - ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); + ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); } else { - ({ team = {} } = accessRecord.publisherObj.toObject()); + ({ team = {} } = accessRecord.publisherObj.toObject()); } if (!_.isEmpty(team)) { - authorised = teamController.checkTeamPermissions( - teamController.roleTypes.MANAGER, - team, - _id - ); - + authorised = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + team, + _id + ); } if (!authorised) { @@ -1101,34 +1101,32 @@ module.exports = { params: { id }, } = req; // 2. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate( - [ - { - path: 'datasets dataset', + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'datasets dataset', + populate: { + path: 'publisher', populate: { - path: 'publisher', + path: 'team', populate: { - path: 'team', + path: 'users', populate: { - path: 'users', - populate: { - path: 'additionalInfo', - }, + path: 'additionalInfo', }, - } - } + }, + }, }, - { - path: 'mainApplicant authors', - populate: { - path: 'additionalInfo' - } + }, + { + path: 'mainApplicant authors', + populate: { + path: 'additionalInfo', }, - { - path: 'publisherObj' - } - ] - ); + }, + { + path: 'publisherObj', + }, + ]); if (!accessRecord) { return res .status(404) @@ -1143,7 +1141,7 @@ module.exports = { accessRecord.applicationStatus = 'submitted'; // Check if workflow/5 Safes based application, set final status date if status will never change again let workflowEnabled = false; - if(_.has(accessRecord.datasets[0].toObject(), 'publisher')) { + if (_.has(accessRecord.datasets[0].toObject(), 'publisher')) { if (!accessRecord.datasets[0].publisher.workflowEnabled) { accessRecord.dateFinalStatus = new Date(); } else { @@ -1280,7 +1278,7 @@ module.exports = { emailRecipients = [ accessRecord.mainApplicant, ...custodianUsers, - ...accessRecord.authors + ...accessRecord.authors, ]; let { dateSubmitted } = accessRecord; if (!dateSubmitted) ({ updatedAt: dateSubmitted } = accessRecord); @@ -1323,7 +1321,9 @@ module.exports = { _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { // Retrieve all custodian user Ids to generate notifications - custodianManagers = teamController.getTeamManagers(accessRecord.datasets[0].publisher.team); + custodianManagers = teamController.getTeamManagers( + accessRecord.datasets[0].publisher.team + ); let custodianUserIds = custodianManagers.map((user) => user.id); await notificationBuilder.triggerNotificationMessage( custodianUserIds, @@ -1332,7 +1332,8 @@ module.exports = { accessRecord._id ); } else { - const dataCustodianEmail = process.env.DATA_CUSTODIAN_EMAIL || contactPoint; + const dataCustodianEmail = + process.env.DATA_CUSTODIAN_EMAIL || contactPoint; custodianManagers = [{ email: dataCustodianEmail }]; } // Applicant notification @@ -1369,7 +1370,7 @@ module.exports = { // Send email to main applicant and contributors if they have opted in to email notifications emailRecipients = [ accessRecord.mainApplicant, - ...accessRecord.authors + ...accessRecord.authors, ]; } // Establish email context object @@ -1484,7 +1485,10 @@ module.exports = { } } // If user is not authenticated as a custodian, check if they are an author or the main applicant - if (application.applicationStatus === 'inProgress' || _.isEmpty(userType)) { + if ( + application.applicationStatus === 'inProgress' || + _.isEmpty(userType) + ) { if ( application.authorIds.includes(userId) || application.userId === userId @@ -1566,7 +1570,10 @@ module.exports = { .map((user) => { return `${user.firstname} ${user.lastname}`; }); - if (applicationStatus === 'submitted' || (applicationStatus === 'inReview' && _.isEmpty(workflow))) { + if ( + applicationStatus === 'submitted' || + (applicationStatus === 'inReview' && _.isEmpty(workflow)) + ) { remainingActioners = managerUsers.join(', '); } if (!_.isEmpty(workflow)) { @@ -1588,6 +1595,7 @@ module.exports = { isReviewer = false, reviewPanels = [], } = module.exports.getActiveStepStatus(activeStep, users, userId)); + activeStep = { ...activeStep, reviewStatus }; } else if ( _.isUndefined(activeStep) && applicationStatus === 'inReview' @@ -1602,6 +1610,16 @@ module.exports = { moment(dateFinalStatus).diff(dateSubmitted, 'days') ); } + // Set review section to display format + let formattedSteps = [...workflow.steps].reduce((arr, item) => { + let step = { + ...item, + sections: [...item.sections].map(section => helper.darPanelMapper[section]) + } + arr.push(step); + return arr; + }, []); + workflow.steps = [...formattedSteps]; } } @@ -1700,9 +1718,9 @@ module.exports = { (reviewer) => reviewer._id.toString() === userId.toString() ); reviewSections = [...activeStep.sections]; - + let { recommendations = [] } = activeStep; - if(!_.isEmpty(recommendations)) { + if (!_.isEmpty(recommendations)) { hasRecommended = recommendations.some( (rec) => rec.reviewer.toString() === userId.toString() ); @@ -1730,13 +1748,13 @@ module.exports = { if (activeStep) { let { reviewStatus, - deadlinePassed + deadlinePassed, } = module.exports.getActiveStepStatus(activeStep); //Update active step with review status steps[activeStepIndex] = { ...steps[activeStepIndex], reviewStatus, - deadlinePassed + deadlinePassed, }; } //Update steps with user friendly review sections @@ -1754,7 +1772,7 @@ module.exports = { workflowStatus = { workflowName, steps: formattedSteps, - isCompleted: module.exports.getWorkflowCompleted(workflow) + isCompleted: module.exports.getWorkflowCompleted(workflow), }; } return workflowStatus; @@ -1851,7 +1869,7 @@ module.exports = { decisionDate, decisionStatus, decisionComments, - reviewPanels + reviewPanels, }; }, From 406f976d1c4b4afc7aecfacad38dcde26e3bfc52 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 15:26:07 +0100 Subject: [PATCH 100/144] Fixed defects --- .../datarequest/datarequest.controller.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 8cbc07ed..d4bdb8ce 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1595,7 +1595,13 @@ module.exports = { isReviewer = false, reviewPanels = [], } = module.exports.getActiveStepStatus(activeStep, users, userId)); - activeStep = { ...activeStep, reviewStatus }; + let activeStepIndex = workflow.steps.findIndex((step) => { + return step.active === true; + }); + workflow.steps[activeStepIndex] = { + ...workflow.steps[activeStepIndex], + reviewStatus, + }; } else if ( _.isUndefined(activeStep) && applicationStatus === 'inReview' @@ -1614,8 +1620,10 @@ module.exports = { let formattedSteps = [...workflow.steps].reduce((arr, item) => { let step = { ...item, - sections: [...item.sections].map(section => helper.darPanelMapper[section]) - } + sections: [...item.sections].map( + (section) => helper.darPanelMapper[section] + ), + }; arr.push(step); return arr; }, []); From 5bceb3ade317e309012ace67f722d70cee05279a Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 20 Oct 2020 15:45:13 +0100 Subject: [PATCH 101/144] Fixed defects --- src/resources/datarequest/datarequest.controller.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index d4bdb8ce..b7656c3e 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1568,7 +1568,8 @@ module.exports = { ) ) .map((user) => { - return `${user.firstname} ${user.lastname}`; + let isCurrentUser = user._id.toString() === userId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)`:``}`; }); if ( applicationStatus === 'submitted' || @@ -1831,7 +1832,8 @@ module.exports = { ) ) .map((user) => { - return `${user.firstname} ${user.lastname}`; + let isCurrentUser = user._id.toString() === userId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)`:``}`; }); let isReviewer = reviewers.some( From c4337f3dd7b0f1887c720eb326f5e8e655c36f09 Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Wed, 21 Oct 2020 09:29:40 +0100 Subject: [PATCH 102/144] Update for schema --- src/resources/datarequest/datarequest.controller.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b7656c3e..e835e52d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -378,7 +378,7 @@ module.exports = { } = req; // 2. Destructure body and update only specific fields by building a segregated non-user specified update object let updateObj; - let { aboutApplication, questionAnswers } = req.body; + let { aboutApplication, questionAnswers, jsonSchema = '' } = req.body; if (aboutApplication) { let parsedObj = JSON.parse(aboutApplication); let updatedDatasetIds = parsedObj.selectedDatasets.map( @@ -389,6 +389,11 @@ module.exports = { if (questionAnswers) { updateObj = { ...updateObj, questionAnswers }; } + + if(!_.isEmpty(jsonSchema)) { + updateObj = {...updateObj, jsonSchema} + } + // 3. Find data request by _id and update via body let accessRequestRecord = await DataRequestModel.findByIdAndUpdate( id, From ea5ce42d5f326990883ffa869f74cd9b289ac43b Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Wed, 21 Oct 2020 14:36:18 +0100 Subject: [PATCH 103/144] Adding https://atlas-test.uksouth.cloudapp.azure.com/auth/ as a logout redirect --- src/config/configuration.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index e51bb954..d93230ec 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -24,7 +24,8 @@ export const clients = [ response_types: ['code'], //response_types: ['code'], redirect_uris: process.env.MDWRedirectURI.split(",") || [''], - id_token_signed_response_alg: 'HS256' + id_token_signed_response_alg: 'HS256', + post_logout_redirect_uris: ['https://atlas-test.uksouth.cloudapp.azure.com/auth/'] }, { //BC Platforms From a925a7f8bf8f4478ed65859de052eae748cdcab1 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 22 Oct 2020 10:00:26 +0100 Subject: [PATCH 104/144] Continued email build out --- .../datarequest/datarequest.controller.js | 57 ++++++++++++------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 967fd3b2..b6311431 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -20,6 +20,21 @@ const userTypes = { APPLICANT: 'applicant', }; +const notificationTypes = { + STATUSCHANGE: 'StatusChange', + SUBMITTED: 'Submitted', + CONTRIBUTORCHANGE: 'ContributorChange', + STEPOVERRIDE: 'StepOverride', + REVIEWSTEPSTART: 'ReviewStepStart', + FINALDECISIONREQUIRED: 'FinalDecisionRequired', + DEADLINEWARNING: 'DeadlineWarning', + DEADLINEPASSED: 'DeadlinePassed' +} + +const applicationStatuses = { + SUBMITTED: 'submitted' +} + module.exports = { //GET api/v1/data-access-request getAccessRequestsByUser: async (req, res) => { @@ -514,7 +529,7 @@ module.exports = { // Extract params from body ({ applicationStatus, applicationStatusDesc } = req.body); const finalStatuses = [ - 'submitted', + applicationStatuses.SUBMITTED, 'approved', 'rejected', 'approved with conditions', @@ -581,7 +596,7 @@ module.exports = { // Send notifications to added/removed contributors if (contributorChange) { await module.exports.createNotifications( - 'ContributorChange', + notificationTypes.CONTRIBUTORCHANGE, { newAuthors, currentAuthors }, accessRecord, req.user @@ -590,7 +605,7 @@ module.exports = { if (statusChange) { // Send notifications to custodian team, main applicant and contributors regarding status change await module.exports.createNotifications( - 'StatusChange', + notificationTypes.STATUSCHANGE, { applicationStatus, applicationStatusDesc }, accessRecord, req.user @@ -764,7 +779,7 @@ module.exports = { // 14. Gather context for notifications const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); // 15. Create notifications to reviewers of the step that has been completed - module.exports.createNotifications('ReviewStepStart', emailContext, accessRecord, req.user); + module.exports.createNotifications(notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); // 16. Return workflow payload return res.status(200).json({ success: true, @@ -818,7 +833,7 @@ module.exports = { } // 5. Check application is in submitted state let { applicationStatus } = accessRecord; - if (applicationStatus !== 'submitted') { + if (applicationStatus !== applicationStatuses.SUBMITTED) { return res.status(400).json({ success: false, message: @@ -990,12 +1005,12 @@ module.exports = { // 15. Gather context for notifications const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); // 16. Create notifications to reviewers of the next step that has been activated - module.exports.createNotifications('ReviewStepStart', emailContext, accessRecord, req.user); + module.exports.createNotifications(notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { // 15. Gather context for notifications const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); // 16. Create notifications to managers that the application is awaiting final approval - module.exports.createNotifications('FinalDecisionRequired', emailContext, accessRecord, req.user); + module.exports.createNotifications(notificationTypes.FINALDECISIONREQUIRED, emailContext, accessRecord, req.user); } // 17. Call Camunda controller to update workflow process bpmController.postCompleteReview(bpmContext); @@ -1101,7 +1116,7 @@ module.exports = { // 12. Gather context for notifications const emailContext = workflowController.getWorkflowEmailContext(workflow, activeStepIndex); // 13. Create notifications to reviewers of the step that has been completed - module.exports.createNotifications('StepOverride', emailContext, accessRecord, req.user); + module.exports.createNotifications(notificationTypes.STEPOVERRIDE, emailContext, accessRecord, req.user); // 14. Call Camunda controller to start manager review process bpmController.postCompleteReview(bpmContext); } @@ -1159,7 +1174,7 @@ module.exports = { } // 4. Update application to submitted status - accessRecord.applicationStatus = 'submitted'; + accessRecord.applicationStatus = applicationStatuses.SUBMITTED; // Check if workflow/5 Safes based application, set final status date if status will never change again let workflowEnabled = false; if (_.has(accessRecord.datasets[0].toObject(), 'publisher')) { @@ -1179,7 +1194,7 @@ module.exports = { // If save has succeeded - send notifications // Send notifications and emails to custodian team and main applicant await module.exports.createNotifications( - 'Submitted', + notificationTypes.SUBMITTED, {}, accessRecord, req.user @@ -1192,7 +1207,7 @@ module.exports = { } = accessRecord; let bpmContext = { dateSubmitted, - applicationStatus: 'submitted', + applicationStatus: applicationStatuses.SUBMITTED, publisher, businessKey: id, }; @@ -1216,7 +1231,7 @@ module.exports = { // 12. Gather context for notifications //const emailContext = workflowController.getWorkflowEmailContext(workflow, activeStepIndex); // 13. Create notifications to reviewers of the step that has been completed - //module.exports.createNotifications('DeadlineWarning', emailContext, accessRecord, req.user); + //module.exports.createNotifications(notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user); return res.status(200).json({ status: 'success' }); }, @@ -1269,7 +1284,7 @@ module.exports = { } switch (type) { // DAR application status has been updated - case 'StatusChange': + case notificationTypes.STATUSCHANGE: // 1. Create notifications // Custodian manager and current step reviewer notifications if ( @@ -1338,7 +1353,7 @@ module.exports = { false ); break; - case 'Submitted': + case notificationTypes.SUBMITTED: // 1. Prepare data for notifications const emailRecipientTypes = ['applicant', 'dataCustodian']; // Destructure the application @@ -1426,7 +1441,7 @@ module.exports = { } } break; - case 'ContributorChange': + case notificationTypes.CONTRIBUTORCHANGE: // 1. Deconstruct authors array from context to compare with existing Mongo authors const { newAuthors, currentAuthors } = context; // 2. Determine authors who have been removed @@ -1495,7 +1510,7 @@ module.exports = { ); } break; - case 'StepOverride': + case notificationTypes.STEPOVERRIDE: let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') @@ -1540,7 +1555,7 @@ module.exports = { ); } break; - case 'ReviewStepStart': + case notificationTypes.REVIEWSTEPSTART: let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') @@ -1585,7 +1600,7 @@ module.exports = { ); } break; - case 'FinalDecisionRequired': + case notificationTypes.FINALDECISIONREQUIRED: let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') @@ -1630,7 +1645,7 @@ module.exports = { ); } break; - case 'DeadlineWarning': + case notificationTypes.DEADLINEWARNING: let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') @@ -1675,7 +1690,7 @@ module.exports = { ); } break; - case 'DeadlinePassed': + case notificationTypes.DEADLINEPASSED: let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') @@ -1827,7 +1842,7 @@ module.exports = { return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)`:``}`; }); if ( - applicationStatus === 'submitted' || + applicationStatus === applicationStatuses.SUBMITTED || (applicationStatus === 'inReview' && _.isEmpty(workflow)) ) { remainingActioners = managerUsers.join(', '); From 5460685213b84ee30343109f1333ccc561739db3 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 22 Oct 2020 10:01:17 +0100 Subject: [PATCH 105/144] Continued email build out --- .../datarequest/datarequest.controller.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b6311431..950a0c6f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -32,7 +32,8 @@ const notificationTypes = { } const applicationStatuses = { - SUBMITTED: 'submitted' + SUBMITTED: 'submitted', + INPROGRESS: 'inProgress' } module.exports = { @@ -137,7 +138,7 @@ module.exports = { // 6. Set edit mode for applicants who have not yet submitted if ( userType === userTypes.APPLICANT && - accessRecord.applicationStatus === 'inProgress' + accessRecord.applicationStatus === applicationStatuses.INPROGRESS ) { readOnly = false; } @@ -202,7 +203,7 @@ module.exports = { accessRecord = await DataRequestModel.findOne({ dataSetId, userId, - applicationStatus: 'inProgress', + applicationStatus: applicationStatuses.INPROGRESS, }).populate({ path: 'mainApplicant', select: 'firstname lastname -id -_id', @@ -246,7 +247,7 @@ module.exports = { publisher, questionAnswers: '{}', aboutApplication: '{}', - applicationStatus: 'inProgress', + applicationStatus: applicationStatuses.INPROGRESS, }); // 4. save record const newApplication = await record.save(); @@ -301,7 +302,7 @@ module.exports = { accessRecord = await DataRequestModel.findOne({ datasetIds: { $all: arrDatasetIds }, userId, - applicationStatus: 'inProgress', + applicationStatus: applicationStatuses.INPROGRESS, }) .populate({ path: 'mainApplicant', @@ -346,7 +347,7 @@ module.exports = { publisher, questionAnswers: '{}', aboutApplication: '{}', - applicationStatus: 'inProgress', + applicationStatus: applicationStatuses.INPROGRESS, }); // 4. save record const newApplication = await record.save(); @@ -1756,7 +1757,7 @@ module.exports = { } // If user is not authenticated as a custodian, check if they are an author or the main applicant if ( - application.applicationStatus === 'inProgress' || + application.applicationStatus === applicationStatuses.INPROGRESS || _.isEmpty(userType) ) { if ( From d0f6e8ed9654c7aeb411a2052e3889448693ab28 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Thu, 22 Oct 2020 11:36:42 +0100 Subject: [PATCH 106/144] First checkin of api code --- src/config/server.js | 1 + src/resources/course/course.model.js | 58 +++ src/resources/course/course.repository.js | 473 ++++++++++++++++++++++ src/resources/course/course.route.js | 233 +++++++++++ 4 files changed, 765 insertions(+) create mode 100644 src/resources/course/course.model.js create mode 100644 src/resources/course/course.repository.js create mode 100644 src/resources/course/course.route.js diff --git a/src/config/server.js b/src/config/server.js index 57011dff..20648cc6 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -197,6 +197,7 @@ app.use('/api/v1/linkchecker', require('../resources/linkchecker/linkchecker.rou app.use('/api/v1/stats', require('../resources/stats/stats.router')); app.use('/api/v1/kpis', require('../resources/stats/kpis.router')); +app.use('/api/v1/course', require('../resources/course/course.route')); app.use('/api/v1/person', require('../resources/person/person.route')); diff --git a/src/resources/course/course.model.js b/src/resources/course/course.model.js new file mode 100644 index 00000000..fafe1173 --- /dev/null +++ b/src/resources/course/course.model.js @@ -0,0 +1,58 @@ +import { model, Schema } from 'mongoose'; + +const CourseSchema = new Schema( + { + id: Number, + type: String, + creator: Number, + activeflag: String, + //updatedon: Date, + counter: Number, + discourseTopicId: Number, + relatedObjects: [{ + objectId: String, + reason: String, + objectType: String, + user: String, + updated: String + }], + + title: String, + link: String, + provider: String, + description: String, + courseDelivery: String, + location: String, + keywords: [String], + domains: [String], + courseOptions: [{ + flexibleDates: String, + startDate: Date, + studyMode: String, + studyDurationNumber: Number, + studyDurationMeasure: String, + fees: [{ + feeDescription: String, + feeAmount: Number + }], + }], + entries: [ + { + level: String, + subject: String + } + ], + restrictions: String, + award: [String], + competencyFramework: String, + nationalPriority: String + }, + { + collection: 'course', + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } + } +); + +export const Course = model('Course', CourseSchema) \ No newline at end of file diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js new file mode 100644 index 00000000..0893a6f8 --- /dev/null +++ b/src/resources/course/course.repository.js @@ -0,0 +1,473 @@ +import { Course } from './course.model'; +import { MessagesModel } from '../message/message.model' +import { UserModel } from '../user/user.model' +import { createDiscourseTopic } from '../discourse/discourse.service' +import emailGenerator from '../utilities/emailGenerator.util'; +const asyncModule = require('async'); +const hdrukEmail = `enquiry@healthdatagateway.org`; +const urlValidator = require('../utilities/urlValidator'); +const inputSanitizer = require('../utilities/inputSanitizer'); + +export async function getObjectById(id) { + return await Course.findOne({ id }).exec() +} + +const addCourse = async (req, res) => { + return new Promise(async(resolve, reject) => { + let course = new Course(); + course.id = parseInt(Math.random().toString().replace('0.', '')); + course.type = 'course'; + course.creator = req.user.id; + course.activeflag = 'review'; + course.updatedon = Date.now(); + course.relatedObjects = req.body.relatedObjects; + + course.title = inputSanitizer.removeNonBreakingSpaces(req.body.title); + course.link = inputSanitizer.removeNonBreakingSpaces(req.body.link); + course.provider = inputSanitizer.removeNonBreakingSpaces(req.body.provider); + course.description = inputSanitizer.removeNonBreakingSpaces(req.body.description); + course.courseDelivery = inputSanitizer.removeNonBreakingSpaces(req.body.courseDelivery); + course.location = inputSanitizer.removeNonBreakingSpaces(req.body.location); + course.keywords = inputSanitizer.removeNonBreakingSpaces(req.body.keywords); + course.domains = inputSanitizer.removeNonBreakingSpaces(req.body.domains); + + if (req.body.courseOptions) { + req.body.courseOptions.forEach((x) => { + x.flexibleDates = inputSanitizer.removeNonBreakingSpaces(x.flexibleDates); + x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); + x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); + x.studyDurationNumber = x.studyDurationNumber; + x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); + if (req.body.fees) { + req.body.fees.forEach((y) => { + y.feeDescription = inputSanitizer.removeNonBreakingSpaces(y.feeDescription); + x.feeAmount = inputSanitizer.removeNonBreakingSpaces(x.feeAmount); + }); + } + course.fees = req.body.fees; + }); + } + course.courseOptions = req.body.courseOptions; + + if (req.body.entries) { + req.body.entries.forEach((x) => { + x.level = inputSanitizer.removeNonBreakingSpaces(x.level); + x.subject = inputSanitizer.removeNonBreakingSpaces(x.subject); + }); + } + course.entries = req.body.entries; + + course.restrictions = inputSanitizer.removeNonBreakingSpaces(req.body.restrictions); + course.award = inputSanitizer.removeNonBreakingSpaces(req.body.award); + course.competencyFramework = inputSanitizer.removeNonBreakingSpaces(req.body.competencyFramework); + course.nationalPriority = inputSanitizer.removeNonBreakingSpaces(req.body.nationalPriority); + + + + + + + + + + + let newCourse = await course.save(); + if(!newCourse) + reject(new Error(`Can't persist data object to DB.`)); + + let message = new MessagesModel(); + message.messageID = parseInt(Math.random().toString().replace('0.', '')); + message.messageTo = 0; + message.messageObjectID = course.id; + message.messageType = 'add'; + message.messageDescription = `Approval needed: new ${course.type} added ${course.title}` + message.messageSent = Date.now(); + message.isRead = false; + let newMessageObj = await message.save(); + if(!newMessageObj) + reject(new Error(`Can't persist message to DB.`)); + + // 1. Generate URL for linking tool from email + const courseLink = process.env.homeURL + '/' + course.type + '/' + course.id + + // 2. Query Db for all admins who have opted in to email updates + var q = UserModel.aggregate([ + // Find all users who are admins + { $match: { role: 'Admin' } }, + // Perform lookup to check opt in/out flag in tools schema + { $lookup: { from: 'tools', localField: 'id', foreignField: 'id', as: 'tool' } }, + // Filter out any user who has opted out of email notifications + { $match: { 'tool.emailNotifications': true } }, + // Reduce response payload size to required fields + { $project: { _id: 1, firstname: 1, lastname: 1, email: 1, role: 1, 'tool.emailNotifications': 1 } } + ]); + + // 3. Use the returned array of email recipients to generate and send emails with SendGrid + q.exec((err, emailRecipients) => { + if (err) { + return new Error({ success: false, error: err }); + } + emailGenerator.sendEmail( + emailRecipients, + `${hdrukEmail}`, + `A new ${course.type} has been added and is ready for review`, + `Approval needed: new ${course.type} ${course.name}

${courseLink}` + ); + }); + + if (course.type === 'course') { + await sendEmailNotificationToAuthors(course, course.creator); + } + await storeNotificationsForAuthors(course, course.creator); + + resolve(newCourse); + }) +}; + + + + + + + +const editTool = async (req, res) => { + return new Promise(async(resolve, reject) => { + + const toolCreator = req.body.toolCreator; + let { type, name, link, description, resultsInsights, categories, license, authors, tags, journal, journalYear, relatedObjects, isPreprint } = req.body; + let id = req.params.id; + let programmingLanguage = req.body.programmingLanguage; + + if (!categories || typeof categories === undefined) categories = {'category':'', 'programmingLanguage':[], 'programmingLanguageVersion':''} + + if(programmingLanguage){ + programmingLanguage.forEach((p) => + { + p.programmingLanguage = inputSanitizer.removeNonBreakingSpaces(p.programmingLanguage); + p.version = (inputSanitizer.removeNonBreakingSpaces(p.version)); + }); + } + + let data = { + id: id, + name: name, + authors: authors, + }; + + Course.findOneAndUpdate({ id: id }, + { + type: inputSanitizer.removeNonBreakingSpaces(type), + name: inputSanitizer.removeNonBreakingSpaces(name), + link: urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(link)), + description: inputSanitizer.removeNonBreakingSpaces(description), + resultsInsights: inputSanitizer.removeNonBreakingSpaces(resultsInsights), + journal: inputSanitizer.removeNonBreakingSpaces(journal), + journalYear: inputSanitizer.removeNonBreakingSpaces(journalYear), + categories: { + category: inputSanitizer.removeNonBreakingSpaces(categories.category), + programmingLanguage: categories.programmingLanguage, + programmingLanguageVersion: categories.programmingLanguageVersion + }, + license: inputSanitizer.removeNonBreakingSpaces(license), + authors: authors, + programmingLanguage: programmingLanguage, + tags: { + features: inputSanitizer.removeNonBreakingSpaces(tags.features), + topics: inputSanitizer.removeNonBreakingSpaces(tags.topics) + }, + relatedObjects: relatedObjects, + isPreprint: isPreprint + }, (err) => { + if (err) { + reject(new Error(`Failed to update.`)); + } + }).then((tool) => { + if(tool == null){ + reject(new Error(`No record found with id of ${id}.`)); + } + else if (type === 'tool') { + // Send email notification of update to all authors who have opted in to updates + sendEmailNotificationToAuthors(data, toolCreator); + storeNotificationsForAuthors(data, toolCreator); + } + resolve(tool); + }); + }) + }; + + const deleteTool = async(req, res) => { + return new Promise(async(resolve, reject) => { + const { id } = req.params.id; + Course.findOneAndDelete({ id: req.params.id }, (err) => { + if (err) reject(err); + + + }).then((tool) => { + if(tool == null){ + reject(`No Content`); + } + else{ + resolve(id); + } + } + ) + })}; + + const getToolsAdmin = async (req, res) => { + return new Promise(async (resolve, reject) => { + + let startIndex = 0; + let limit = 1000; + let typeString = ""; + let searchString = ""; + + if (req.query.offset) { + startIndex = req.query.offset; + } + if (req.query.limit) { + limit = req.query.limit; + } + if (req.params.type) { + typeString = req.params.type; + } + if (req.query.q) { + searchString = req.query.q || "";; + } + + let searchQuery = { $and: [{ type: typeString }] }; + let searchAll = false; + + if (searchString.length > 0) { + searchQuery["$and"].push({ $text: { $search: searchString } }); + } + else { + searchAll = true; + } + await Promise.all([ + getObjectResult(typeString, searchAll, searchQuery, startIndex, limit), + ]).then((values) => { + resolve(values[0]); + }); + }); + } + + const getTools = async (req, res) => { + return new Promise(async (resolve, reject) => { + let startIndex = 0; + let limit = 1000; + let typeString = ""; + let idString = req.user.id; + + if (req.query.startIndex) { + startIndex = req.query.startIndex; + } + if (req.query.limit) { + limit = req.query.limit; + } + if (req.params.type) { + typeString = req.params.type; + } + if (req.query.id) { + idString = req.query.id; + } + + let query = Course.aggregate([ + { $match: { $and: [{ type: typeString }, { authors: parseInt(idString) }] } }, + { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } }, + { $sort: { updatedAt : -1}} + ])//.skip(parseInt(startIndex)).limit(parseInt(maxResults)); + query.exec((err, data) => { + if (err) reject({ success: false, error: err }); + resolve(data); + }); + }); + } + + const setStatus = async (req, res) => { + return new Promise(async (resolve, reject) => { + try { + const { activeflag, rejectionReason } = req.body; + const id = req.params.id; + + let tool = await Course.findOneAndUpdate({ id: id }, { $set: { activeflag: activeflag } }); + if (!tool) { + reject(new Error('Tool not found')); + } + + if (tool.authors) { + tool.authors.forEach(async (authorId) => { + await createMessage(authorId, id, tool.name, tool.type, activeflag, rejectionReason); + }); + } + await createMessage(0, id, tool.name, tool.type, activeflag, rejectionReason); + + if (!tool.discourseTopicId && tool.activeflag === 'active') { + await createDiscourseTopic(tool); + } + + // Send email notification of status update to admins and authors who have opted in + await sendEmailNotifications(tool, activeflag, rejectionReason); + + resolve(id); + + } catch (err) { + console.log(err); + reject(new Error(err)); + } + }); + }; + + async function createMessage(authorId, toolId, toolName, toolType, activeflag, rejectionReason) { + let message = new MessagesModel(); + const toolLink = process.env.homeURL + '/' + toolType + '/' + toolId; + + if (activeflag === 'active') { + message.messageType = 'approved'; + message.messageDescription = `Your ${toolType} ${toolName} has been approved and is now live ${toolLink}` + } else if (activeflag === 'archive') { + message.messageType = 'archive'; + message.messageDescription = `Your ${toolType} ${toolName} has been archived ${toolLink}` + } else if (activeflag === 'rejected') { + message.messageType = 'rejected'; + message.messageDescription = `Your ${toolType} ${toolName} has been rejected ${toolLink}` + message.messageDescription = (rejectionReason) ? message.messageDescription.concat(` Rejection reason: ${rejectionReason}`) : message.messageDescription + } + message.messageID = parseInt(Math.random().toString().replace('0.', '')); + message.messageTo = authorId; + message.messageObjectID = toolId; + message.messageSent = Date.now(); + message.isRead = false; + await message.save(); + } + + async function sendEmailNotifications(tool, activeflag, rejectionReason) { + let subject; + let html; + // 1. Generate tool URL for linking user from email + const toolLink = process.env.homeURL + '/' + tool.type + '/' + tool.id + + // 2. Build email body + if (activeflag === 'active') { + subject = `Your ${tool.type} ${tool.name} has been approved and is now live` + html = `Your ${tool.type} ${tool.name} has been approved and is now live

${toolLink}` + } else if (activeflag === 'archive') { + subject = `Your ${tool.type} ${tool.name} has been archived` + html = `Your ${tool.type} ${tool.name} has been archived

${toolLink}` + } else if (activeflag === 'rejected') { + subject = `Your ${tool.type} ${tool.name} has been rejected` + html = `Your ${tool.type} ${tool.name} has been rejected

Rejection reason: ${rejectionReason}

${toolLink}` + } + + // 3. Find all authors of the tool who have opted in to email updates + var q = UserModel.aggregate([ + // Find all authors of this tool + { $match: { $or: [{ role: 'Admin' }, { id: { $in: tool.authors } }] } }, + // Perform lookup to check opt in/out flag in tools schema + { $lookup: { from: 'tools', localField: 'id', foreignField: 'id', as: 'tool' } }, + // Filter out any user who has opted out of email notifications + { $match: { 'tool.emailNotifications': true } }, + // Reduce response payload size to required fields + { $project: {_id: 1, firstname: 1, lastname: 1, email: 1, role: 1, 'tool.emailNotifications': 1 } } + ]); + + // 4. Use the returned array of email recipients to generate and send emails with SendGrid + q.exec((err, emailRecipients) => { + if (err) { + return new Error({ success: false, error: err }); + } + emailGenerator.sendEmail( + emailRecipients, + `${hdrukEmail}`, + subject, + html + ); + }); + } + +async function sendEmailNotificationToAuthors(tool, toolOwner) { + // 1. Generate tool URL for linking user from email + const toolLink = process.env.homeURL + '/tool/' + tool.id + + // 2. Find all authors of the tool who have opted in to email updates + var q = UserModel.aggregate([ + // Find all authors of this tool + { $match: { id: { $in: tool.authors } } }, + // Perform lookup to check opt in/out flag in tools schema + { $lookup: { from: 'tools', localField: 'id', foreignField: 'id', as: 'tool' } }, + // Filter out any user who has opted out of email notifications + { $match: { 'tool.emailNotifications': true } }, + // Reduce response payload size to required fields + { $project: {_id: 1, firstname: 1, lastname: 1, email: 1, role: 1, 'tool.emailNotifications': 1 } } + ]); + + // 3. Use the returned array of email recipients to generate and send emails with SendGrid + q.exec((err, emailRecipients) => { + if (err) { + return new Error({ success: false, error: err }); + } + emailGenerator.sendEmail( + emailRecipients, + `${hdrukEmail}`, + `${toolOwner.name} added you as an author of the tool ${tool.name}`, + `${toolOwner.name} added you as an author of the tool ${tool.name}

${toolLink}` + ); + }); + }; + +async function storeNotificationsForAuthors(tool, toolOwner) { + //store messages to alert a user has been added as an author + const toolLink = process.env.homeURL + '/tool/' + tool.id + + //normal user + var toolCopy = JSON.parse(JSON.stringify(tool)); + + toolCopy.authors.push(0); + asyncModule.eachSeries(toolCopy.authors, async (author) => { + + let message = new MessagesModel(); + message.messageType = 'author'; + message.messageSent = Date.now(); + message.messageDescription = `${toolOwner.name} added you as an author of the ${toolCopy.type} ${toolCopy.name}` + message.isRead = false; + message.messageObjectID = toolCopy.id; + message.messageID = parseInt(Math.random().toString().replace('0.', '')); + message.messageTo = author; + + await message.save(async (err) => { + if (err) { + return new Error({ success: false, error: err }); + } + return { success: true, id: message.messageID }; + }); + }); +}; + +function getObjectResult(type, searchAll, searchQuery, startIndex, limit) { + let newSearchQuery = JSON.parse(JSON.stringify(searchQuery)); + let q = ''; + + if (searchAll) { + q = Course.aggregate([ + { $match: newSearchQuery }, + { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } }, + { $lookup: { from: "tools", localField: "id", foreignField: "authors", as: "objects" } }, + { $lookup: { from: "reviews", localField: "id", foreignField: "toolID", as: "reviews" } } + ]).sort({ updatedAt : -1}).skip(parseInt(startIndex)).limit(parseInt(limit)); + } + else{ + q = Course.aggregate([ + { $match: newSearchQuery }, + { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } }, + { $lookup: { from: "tools", localField: "id", foreignField: "authors", as: "objects" } }, + { $lookup: { from: "reviews", localField: "id", foreignField: "toolID", as: "reviews" } } + ]).sort({ score: { $meta: "textScore" } }).skip(parseInt(startIndex)).limit(parseInt(limit)); + } + return new Promise((resolve, reject) => { + q.exec((err, data) => { + if (typeof data === "undefined") resolve([]); + else resolve(data); + }) + }) +}; + +export { addCourse, editTool, deleteTool, setStatus, getTools, getToolsAdmin } \ No newline at end of file diff --git a/src/resources/course/course.route.js b/src/resources/course/course.route.js new file mode 100644 index 00000000..8e052a5c --- /dev/null +++ b/src/resources/course/course.route.js @@ -0,0 +1,233 @@ +import express from 'express'; +import { ROLES } from '../user/user.roles'; +import { Data } from '../tool/data.model'; +import { Course } from './course.model'; +import passport from 'passport'; +import { utils } from '../auth'; +import { UserModel } from '../user/user.model'; +import { MessagesModel } from '../message/message.model'; +import { + addCourse, + editTool, + deleteTool, + setStatus, + getTools, + getToolsAdmin, +} from './course.repository'; +import emailGenerator from '../utilities/emailGenerator.util'; +import inputSanitizer from '../utilities/inputSanitizer'; +const hdrukEmail = `enquiry@healthdatagateway.org`; +const router = express.Router(); + +// @router POST /api/v1/Course +// @desc Add Course as user +// @access Private +router.post( + '/', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await addCourse(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } +); + +// @router PUT /api/v1/{id} +// @desc Edit tools user +// @access Private +// router.put('/{id}', +router.put( + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await editTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, error: err.message }); + }); + } +); + +// @router GET /api/v1/get/admin +// @desc Returns List of Tool objects +// @access Private +router.get( + '/getList', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + req.params.type = 'tool'; + let role = req.user.role; + + if (role === ROLES.Admin) { + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } else if (role === ROLES.Creator) { + await getTools(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } + } +); + +// @router GET /api/v1/ +// @desc Returns List of Tool Objects No auth +// This unauthenticated route was created specifically for API-docs +// @access Public +router.get( + '/', + async (req, res) => { + req.params.type = 'tool'; + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } +); + +// @router PATCH /api/v1/status +// @desc Set tool status +// @access Private +router.patch( + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + await setStatus(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, error: err.message }); + }); + } +); + +/** + * {get} /tool/:id Tool + * + * Return the details on the tool based on the tool ID. + */ +router.get('/:id', async (req, res) => { + var query = Course.aggregate([ + { $match: { id: parseInt(req.params.id) } }, + { + $lookup: { + from: 'tools', + localField: 'creator', + foreignField: 'id', + as: 'creator', + }, + } + ]); + query.exec((err, data) => { + if (data.length > 0) { + var p = Data.aggregate([ + { + $match: { + $and: [ + { relatedObjects: { $elemMatch: { objectId: req.params.id } } }, + ], + }, + }, + ]); + p.exec((err, relatedData) => { + relatedData.forEach((dat) => { + dat.relatedObjects.forEach((x) => { + if (x.objectId === req.params.id && dat.id !== req.params.id) { + let relatedObject = { + objectId: dat.id, + reason: x.reason, + objectType: dat.type, + user: x.user, + updated: x.updated + }; + data[0].relatedObjects = [relatedObject, ...data[0].relatedObjects || []]; + } + }); + }); + + if (err) return res.json({ success: false, error: err }); + + return res.json({ + success: true, + data: data + }); + }); + } else { + return res.status(404).send(`Course not found for Id: ${req.params.id}`); + } + }); +}); + +/** + * {get} /tool/edit/:id Tool + * + * Return the details on the tool based on the tool ID for edit. + */ +router.get('/edit/:id', async (req, res) => { + var query = Data.aggregate([ + { $match: { $and: [{ id: parseInt(req.params.id) }] } }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + ]); + query.exec((err, data) => { + if (data.length > 0) { + return res.json({ success: true, data: data }); + } else { + return res.json({ + success: false, + error: `Tool not found for tool id ${req.params.id}`, + }); + } + }); +}); + + + + +//Validation required if Delete is to be implemented +// router.delete('/:id', +// passport.authenticate('jwt'), +// utils.checkIsInRole(ROLES.Admin, ROLES.Creator), +// async (req, res) => { +// await deleteTool(req, res) +// .then(response => { +// return res.json({success: true, response}); +// }) +// .catch(err => { +// res.status(204).send(err); +// }); +// } +// ); + +module.exports = router; + + + From 8180de5941df7c4d4804401179ceef82934c20e8 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 22 Oct 2020 12:17:44 +0100 Subject: [PATCH 107/144] Continued email build out --- .../datarequest/datarequest.controller.js | 57 ++++++++++--------- src/resources/team/team.controller.js | 4 +- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 950a0c6f..0cae32a6 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -15,6 +15,8 @@ const teamController = require('../team/team.controller'); const workflowController = require('../workflow/workflow.controller'); const notificationBuilder = require('../utilities/notificationBuilder'); +const hdrukEmail = `enquiry@healthdatagateway.org`; + const userTypes = { CUSTODIAN: 'custodian', APPLICANT: 'applicant', @@ -33,7 +35,12 @@ const notificationTypes = { const applicationStatuses = { SUBMITTED: 'submitted', - INPROGRESS: 'inProgress' + INPROGRESS: 'inProgress', + INREVIEW: 'inReview', + APPROVED: 'approved', + REJECTED: 'rejected', + APPROVEDWITHCONDITIONS: 'approved with conditions', + WITHDRAWN: 'withdrawn' } module.exports = { @@ -274,7 +281,7 @@ module.exports = { aboutApplication: JSON.parse(data.aboutApplication), dataset, projectId: data.projectId || helper.generateFriendlyId(data._id), - userType: 'applicant', + userType: userTypes.APPLICANT, inReviewMode: false, reviewSections: [], }, @@ -373,7 +380,7 @@ module.exports = { aboutApplication: JSON.parse(data.aboutApplication), datasets, projectId: data.projectId || helper.generateFriendlyId(data._id), - userType: 'applicant', + userType: userTypes.APPLICANT, inReviewMode: false, reviewSections: [], }, @@ -531,10 +538,10 @@ module.exports = { ({ applicationStatus, applicationStatusDesc } = req.body); const finalStatuses = [ applicationStatuses.SUBMITTED, - 'approved', - 'rejected', - 'approved with conditions', - 'withdrawn', + applicationStatuses.APPROVED, + applicationStatuses.REJECTED, + applicationStatuses.APPROVEDWITHCONDITIONS, + applicationStatuses.WITHDRAWN, ]; if (applicationStatus) { accessRecord.applicationStatus = applicationStatus; @@ -719,7 +726,7 @@ module.exports = { } // 8. Check application is in-review let { applicationStatus } = accessRecord; - if (applicationStatus !== 'inReview') { + if (applicationStatus !== applicationStatuses.INREVIEW) { return res.status(400).json({ success: false, message: @@ -767,7 +774,7 @@ module.exports = { ); let bpmContext = { businessKey: id, - dataRequestStatus: 'inReview', + dataRequestStatus: applicationStatuses.INREVIEW, dataRequestUserId: userId.toString(), dataRequestPublisher, dataRequestStepName: workflowObj.steps[0].stepName, @@ -842,7 +849,7 @@ module.exports = { }); } // 6. Update application to 'in review' - accessRecord.applicationStatus = 'inReview'; + accessRecord.applicationStatus = applicationStatuses.INREVIEW; accessRecord.dateReviewStart = new Date(); // 7. Save update to access record await accessRecord.save(async (err) => { @@ -924,7 +931,7 @@ module.exports = { } // 5. Check application is in-review let { applicationStatus } = accessRecord; - if (applicationStatus !== 'inReview') { + if (applicationStatus !== applicationStatuses.INREVIEW) { return res.status(400).json({ success: false, message: @@ -1002,22 +1009,21 @@ module.exports = { console.error(err); res.status(500).json({ status: 'error', message: err }); } else { + // 15. Create emails and notifications if(bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { - // 15. Gather context for notifications const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); - // 16. Create notifications to reviewers of the next step that has been activated + // Create notifications to reviewers of the next step that has been activated module.exports.createNotifications(notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { - // 15. Gather context for notifications const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); - // 16. Create notifications to managers that the application is awaiting final approval + // Create notifications to managers that the application is awaiting final approval module.exports.createNotifications(notificationTypes.FINALDECISIONREQUIRED, emailContext, accessRecord, req.user); } - // 17. Call Camunda controller to update workflow process + // 16. Call Camunda controller to update workflow process bpmController.postCompleteReview(bpmContext); } }); - // 18. Return aplication and successful response + // 17. Return aplication and successful response return res .status(200) .json({ status: 'success', data: accessRecord._doc }); @@ -1065,7 +1071,7 @@ module.exports = { } // 5. Check application is in review state let { applicationStatus } = accessRecord; - if (applicationStatus !== 'inReview') { + if (applicationStatus !== applicationStatuses.INREVIEW) { return res.status(400).json({ success: false, message: 'The application status must be set to in review', @@ -1237,8 +1243,6 @@ module.exports = { }, createNotifications: async (type, context, accessRecord, user) => { - // Default from mail address - const hdrukEmail = `enquiry@healthdatagateway.org`; // Project details from about application if 5 Safes let aboutApplication = JSON.parse(accessRecord.aboutApplication); let { projectName } = aboutApplication; @@ -1742,18 +1746,17 @@ module.exports = { try { let authorised = false, userType = '', - members = []; // Return default unauthorised with no user type if incorrect params passed if (!application || !userId || !_id) { return { authorised, userType }; } // Check if the user is a custodian team member and assign permissions if so - if (_.has(application.datasets[0].toObject(), 'publisher.team.members')) { - ({ members } = application.datasets[0].publisher.team.toObject()); - if (members.some((el) => el.memberid.toString() === _id.toString())) { + if (_.has(application.datasets[0].toObject(), 'publisher.team')) { + let isTeamMember = teamController.checkTeamPermissions('', application.datasets[0].publisher.team.toObject() ,_id); + if(isTeamMember) { userType = userTypes.CUSTODIAN; authorised = true; - } + }; } // If user is not authenticated as a custodian, check if they are an author or the main applicant if ( @@ -1844,7 +1847,7 @@ module.exports = { }); if ( applicationStatus === applicationStatuses.SUBMITTED || - (applicationStatus === 'inReview' && _.isEmpty(workflow)) + (applicationStatus === applicationStatuses.INREVIEW && _.isEmpty(workflow)) ) { remainingActioners = managerUsers.join(', '); } @@ -1876,7 +1879,7 @@ module.exports = { }; } else if ( _.isUndefined(activeStep) && - applicationStatus === 'inReview' + applicationStatus === applicationStatuses.INREVIEW ) { reviewStatus = 'Final decision required'; remainingActioners = managerUsers.join(', '); diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index 9513f8e7..5afcc199 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -95,8 +95,8 @@ module.exports = { ); // 4. If the user was found check they hold the minimum required role if (userMember) { - let { roles } = userMember; - if (roles.includes(role) || roles.includes(roleTypes.MANAGER)) { + let { roles = [] } = userMember; + if (roles.includes(role) || roles.includes(roleTypes.MANAGER) || role === '') { return true; } } From 3501a2458f5e58a806c0c2c60c39f9e01898ed63 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Thu, 22 Oct 2020 12:46:01 +0100 Subject: [PATCH 108/144] Changes to the configuration for openid --- src/config/configuration.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index d93230ec..82806569 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -19,9 +19,9 @@ export const clients = [ //Metadata works client_id: process.env.MDWClientID || '', client_secret: process.env.MDWClientSecret || '', - grant_types: ['authorization_code'], - //grant_types: ['authorization_code', 'implicit'], - response_types: ['code'], + //grant_types: ['authorization_code'], + grant_types: ['authorization_code', 'implicit'], + response_types: ['code id_token'], //response_types: ['code'], redirect_uris: process.env.MDWRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256', @@ -31,9 +31,9 @@ export const clients = [ //BC Platforms client_id: process.env.BCPClientID || '', client_secret: process.env.BCPClientSecret || '', - grant_types: ['authorization_code'], - //grant_types: ['authorization_code', 'implicit'], - response_types: ['code'], + //grant_types: ['authorization_code'], + grant_types: ['authorization_code', 'implicit'], + response_types: ['code id_token'], //response_types: ['code'], redirect_uris: process.env.BCPRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256' From 981d6c46c452464517ff06592a6c9f280b84ede6 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Thu, 22 Oct 2020 12:51:33 +0100 Subject: [PATCH 109/144] Moving post_logout to BCP --- src/config/configuration.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index 82806569..d0743ba9 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -25,7 +25,7 @@ export const clients = [ //response_types: ['code'], redirect_uris: process.env.MDWRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256', - post_logout_redirect_uris: ['https://atlas-test.uksouth.cloudapp.azure.com/auth/'] + post_logout_redirect_uris: [] }, { //BC Platforms @@ -36,7 +36,8 @@ export const clients = [ response_types: ['code id_token'], //response_types: ['code'], redirect_uris: process.env.BCPRedirectURI.split(",") || [''], - id_token_signed_response_alg: 'HS256' + id_token_signed_response_alg: 'HS256', + post_logout_redirect_uris: ['https://atlas-test.uksouth.cloudapp.azure.com/auth/'] } ]; From c9231a8a85e14af4192a9e361d017ca6b15daa03 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 15 Oct 2020 13:52:05 +0100 Subject: [PATCH 110/144] Added additional parameter to determine if the user can override current phase --- .../datarequest/datarequest.controller.js | 157 +++- src/resources/message/message.controller.js | 1 - src/resources/project/project.route.js | 253 ++++--- .../publisher/publisher.controller.js | 10 +- src/resources/tool/tool.route.js | 696 ++++++++++-------- src/resources/workflow/workflow.controller.js | 2 +- 6 files changed, 643 insertions(+), 476 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 100fa1dd..e835e52d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -92,7 +92,7 @@ module.exports = { path: 'datasets dataset authors', populate: { path: 'publisher', populate: { path: 'team' } }, }, - { path: 'workflow.steps.reviewers', select: 'firstname lastname' } + { path: 'workflow.steps.reviewers', select: 'firstname lastname' }, ]); // 3. If no matching application found, return 404 if (!accessRecord) { @@ -127,14 +127,26 @@ module.exports = { readOnly = false; } // 7. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections } = module.exports.getReviewStatus( - accessRecord, - req.user._id - ); + let { + inReviewMode, + reviewSections, + hasRecommended, + } = module.exports.getReviewStatus(accessRecord, req.user._id); // 8. Get the workflow/voting status let workflow = module.exports.getWorkflowStatus(accessRecord.toObject()); - - // 9. Return application form + // 9. Check if the current user can override the current step + if (_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { + let isManager = teamController.checkTeamPermissions( + teamController.roleTypes.MANAGER, + accessRecord.datasets[0].publisher.team.toObject(), + req.user._id + ); + // Set the workflow override capability if there is an active step and user is a manager + if (!_.isEmpty(workflow)) { + workflow.canOverrideStep = !workflow.isCompleted && isManager; + } + } + // 10. Return application form return res.status(200).json({ status: 'success', data: { @@ -150,6 +162,7 @@ module.exports = { helper.generateFriendlyId(accessRecord._id), inReviewMode, reviewSections, + hasRecommended, workflow, }, }); @@ -365,7 +378,7 @@ module.exports = { } = req; // 2. Destructure body and update only specific fields by building a segregated non-user specified update object let updateObj; - let { aboutApplication, questionAnswers } = req.body; + let { aboutApplication, questionAnswers, jsonSchema = '' } = req.body; if (aboutApplication) { let parsedObj = JSON.parse(aboutApplication); let updatedDatasetIds = parsedObj.selectedDatasets.map( @@ -376,6 +389,11 @@ module.exports = { if (questionAnswers) { updateObj = { ...updateObj, questionAnswers }; } + + if(!_.isEmpty(jsonSchema)) { + updateObj = {...updateObj, jsonSchema} + } + // 3. Find data request by _id and update via body let accessRequestRecord = await DataRequestModel.findByIdAndUpdate( id, @@ -475,16 +493,21 @@ module.exports = { if (userType === userTypes.CUSTODIAN) { // Only a custodian manager can set the final status of an application authorised = false; - if (_.has(accessRecord.publisherObj.toObject(), 'team')) { - let { - publisherObj: { team }, - } = accessRecord; + let team = {}; + if (_.isNull(accessRecord.publisherObj)) { + ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); + } else { + ({ team = {} } = accessRecord.publisherObj.toObject()); + } + + if (!_.isEmpty(team)) { authorised = teamController.checkTeamPermissions( teamController.roleTypes.MANAGER, - team.toObject(), + team, _id ); } + if (!authorised) { return res .status(401) @@ -1067,7 +1090,7 @@ module.exports = { bpmController.postCompleteReview(bpmContext); } }); - // 13. Return aplication and successful response + // 12. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { console.log(err.message); @@ -1083,21 +1106,32 @@ module.exports = { params: { id }, } = req; // 2. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ - path: 'datasets dataset mainApplicant authors publisherObj', - populate: { - path: 'publisher additionalInfo', + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'datasets dataset', populate: { - path: 'team', + path: 'publisher', populate: { - path: 'users', + path: 'team', populate: { - path: 'additionalInfo', + path: 'users', + populate: { + path: 'additionalInfo', + }, }, }, }, }, - }); + { + path: 'mainApplicant authors', + populate: { + path: 'additionalInfo', + }, + }, + { + path: 'publisherObj', + }, + ]); if (!accessRecord) { return res .status(404) @@ -1111,8 +1145,13 @@ module.exports = { // 4. Update application to submitted status accessRecord.applicationStatus = 'submitted'; // Check if workflow/5 Safes based application, set final status date if status will never change again - if (!accessRecord.datasets[0].publisher.workflowEnabled) { - accessRecord.dateFinalStatus = new Date(); + let workflowEnabled = false; + if (_.has(accessRecord.datasets[0].toObject(), 'publisher')) { + if (!accessRecord.datasets[0].publisher.workflowEnabled) { + accessRecord.dateFinalStatus = new Date(); + } else { + workflowEnabled = true; + } } let dateSubmitted = new Date(); accessRecord.dateSubmitted = dateSubmitted; @@ -1130,7 +1169,7 @@ module.exports = { req.user ); // Start workflow process if publisher requires it - if (accessRecord.datasets[0].publisher.workflowEnabled) { + if (workflowEnabled) { // Call Camunda controller to start workflow for submitted application let { publisherObj: { name: publisher }, @@ -1244,7 +1283,7 @@ module.exports = { emailRecipients = [ accessRecord.mainApplicant, ...custodianUsers, - ...accessRecord.authors + ...accessRecord.authors, ]; let { dateSubmitted } = accessRecord; if (!dateSubmitted) ({ updatedAt: dateSubmitted } = accessRecord); @@ -1287,7 +1326,9 @@ module.exports = { _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { // Retrieve all custodian user Ids to generate notifications - custodianManagers = teamController.getTeamManagers(accessRecord.datasets[0].publisher.team); + custodianManagers = teamController.getTeamManagers( + accessRecord.datasets[0].publisher.team + ); let custodianUserIds = custodianManagers.map((user) => user.id); await notificationBuilder.triggerNotificationMessage( custodianUserIds, @@ -1296,7 +1337,8 @@ module.exports = { accessRecord._id ); } else { - const dataCustodianEmail = process.env.DATA_CUSTODIAN_EMAIL || contactPoint; + const dataCustodianEmail = + process.env.DATA_CUSTODIAN_EMAIL || contactPoint; custodianManagers = [{ email: dataCustodianEmail }]; } // Applicant notification @@ -1333,7 +1375,7 @@ module.exports = { // Send email to main applicant and contributors if they have opted in to email notifications emailRecipients = [ accessRecord.mainApplicant, - ...accessRecord.authors + ...accessRecord.authors, ]; } // Establish email context object @@ -1448,7 +1490,10 @@ module.exports = { } } // If user is not authenticated as a custodian, check if they are an author or the main applicant - if (_.isEmpty(userType)) { + if ( + application.applicationStatus === 'inProgress' || + _.isEmpty(userType) + ) { if ( application.authorIds.includes(userId) || application.userId === userId @@ -1528,9 +1573,13 @@ module.exports = { ) ) .map((user) => { - return `${user.firstname} ${user.lastname}`; + let isCurrentUser = user._id.toString() === userId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)`:``}`; }); - if (applicationStatus === 'submitted') { + if ( + applicationStatus === 'submitted' || + (applicationStatus === 'inReview' && _.isEmpty(workflow)) + ) { remainingActioners = managerUsers.join(', '); } if (!_.isEmpty(workflow)) { @@ -1552,6 +1601,13 @@ module.exports = { isReviewer = false, reviewPanels = [], } = module.exports.getActiveStepStatus(activeStep, users, userId)); + let activeStepIndex = workflow.steps.findIndex((step) => { + return step.active === true; + }); + workflow.steps[activeStepIndex] = { + ...workflow.steps[activeStepIndex], + reviewStatus, + }; } else if ( _.isUndefined(activeStep) && applicationStatus === 'inReview' @@ -1566,6 +1622,18 @@ module.exports = { moment(dateFinalStatus).diff(dateSubmitted, 'days') ); } + // Set review section to display format + let formattedSteps = [...workflow.steps].reduce((arr, item) => { + let step = { + ...item, + sections: [...item.sections].map( + (section) => helper.darPanelMapper[section] + ), + }; + arr.push(step); + return arr; + }, []); + workflow.steps = [...formattedSteps]; } } @@ -1648,7 +1716,8 @@ module.exports = { getReviewStatus: (application, userId) => { let inReviewMode = false, reviewSections = [], - isActiveStepReviewer = false; + isActiveStepReviewer = false, + hasRecommended = false; // Get current application status let { applicationStatus } = application; // Check if the current user is a reviewer on the current step of an attached workflow @@ -1660,9 +1729,16 @@ module.exports = { }); if (activeStep) { isActiveStepReviewer = activeStep.reviewers.some( - (reviewer) => reviewer.toString() === userId.toString() + (reviewer) => reviewer._id.toString() === userId.toString() ); reviewSections = [...activeStep.sections]; + + let { recommendations = [] } = activeStep; + if (!_.isEmpty(recommendations)) { + hasRecommended = recommendations.some( + (rec) => rec.reviewer.toString() === userId.toString() + ); + } } } // Return active review mode if conditions apply @@ -1670,7 +1746,7 @@ module.exports = { inReviewMode = true; } - return { inReviewMode, reviewSections }; + return { inReviewMode, reviewSections, hasRecommended }; }, getWorkflowStatus: (application) => { @@ -1686,7 +1762,7 @@ module.exports = { if (activeStep) { let { reviewStatus, - deadlinePassed + deadlinePassed, } = module.exports.getActiveStepStatus(activeStep); //Update active step with review status steps[activeStepIndex] = { @@ -1751,21 +1827,22 @@ module.exports = { remainingActioners = reviewers.filter( (reviewer) => !recommendations.some( - (rec) => rec.reviewer.toString() === reviewer.toString() + (rec) => rec.reviewer.toString() === reviewer._id.toString() ) ); remainingActioners = users .filter((user) => remainingActioners.some( - (actioner) => actioner.toString() === user._id.toString() + (actioner) => actioner._id.toString() === user._id.toString() ) ) .map((user) => { - return `${user.firstname} ${user.lastname}`; + let isCurrentUser = user._id.toString() === userId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)`:``}`; }); let isReviewer = reviewers.some( - (reviewer) => reviewer.toString() === userId.toString() + (reviewer) => reviewer._id.toString() === userId.toString() ); let hasRecommended = recommendations.some( (rec) => rec.reviewer.toString() === userId.toString() diff --git a/src/resources/message/message.controller.js b/src/resources/message/message.controller.js index 6dcf1e39..8b24e7ae 100644 --- a/src/resources/message/message.controller.js +++ b/src/resources/message/message.controller.js @@ -56,7 +56,6 @@ module.exports = { // 5. Create new message const message = await MessagesModel.create({ messageID: parseInt(Math.random().toString().replace('0.', '')), - messageTo: 0, messageObjectID: parseInt(Math.random().toString().replace('0.', '')), messageDescription, topic, diff --git a/src/resources/project/project.route.js b/src/resources/project/project.route.js index b5c4b444..9162c9d2 100644 --- a/src/resources/project/project.route.js +++ b/src/resources/project/project.route.js @@ -1,144 +1,183 @@ -import express from 'express' -import { Data } from '../tool/data.model' -import { ROLES } from '../user/user.roles' -import passport from "passport"; -import { utils } from "../auth"; -import {addTool, editTool, deleteTool, setStatus, getTools, getToolsAdmin} from '../tool/data.repository'; +import express from 'express'; +import { Data } from '../tool/data.model'; +import { ROLES } from '../user/user.roles'; +import passport from 'passport'; +import { utils } from '../auth'; +import { + addTool, + editTool, + deleteTool, + setStatus, + getTools, + getToolsAdmin, +} from '../tool/data.repository'; const router = express.Router(); // @router POST /api/v1/ // @desc Add project user // @access Private -router.post('/', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await addTool(req) - .then(response => { - return res.json({ success: true, response}); - }) - .catch(err => { - return res.json({ success: false, err}); - }) - } +router.post( + '/', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await addTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router GET /api/v1/ // @desc Returns List of Project Objects Authenticated // @access Private router.get( - '/getList', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - req.params.type = 'project'; - let role = req.user.role; + '/getList', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + req.params.type = 'project'; + let role = req.user.role; - if (role === ROLES.Admin) { - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } else if (role === ROLES.Creator) { - await getTools(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } - } + if (role === ROLES.Admin) { + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } else if (role === ROLES.Creator) { + await getTools(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } + } ); // @router GET /api/v1/ // @desc Returns List of Project Objects No auth // This unauthenticated route was created specifically for API-docs // @access Public -router.get( - '/', - async (req, res) => { - req.params.type = 'project'; - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } -); +router.get('/', async (req, res) => { + req.params.type = 'project'; + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); +}); // @router GET /api/v1/ // @desc Returns a Project object // @access Public router.get('/:projectID', async (req, res) => { - var q = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.projectID) }, {type: 'project'}] } }, - { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } } - ]); - q.exec((err, data) => { - if (data.length > 0) { - var p = Data.aggregate([ - { $match: { $and: [{ "relatedObjects": { $elemMatch: { "objectId": req.params.projectID } } }] } }, - ]); + var q = Data.aggregate([ + { + $match: { + $and: [{ id: parseInt(req.params.projectID) }, { type: 'project' }], + }, + }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + ]); + q.exec((err, data) => { + if (data.length > 0) { + var p = Data.aggregate([ + { + $match: { + $and: [ + { + relatedObjects: { + $elemMatch: { objectId: req.params.projectID }, + }, + }, + ], + }, + }, + ]); - p.exec( async (err, relatedData) => { - relatedData.forEach((dat) => { - dat.relatedObjects.forEach((x) => { - if (x.objectId === req.params.projectID && dat.id !== req.params.projectID) { - if (typeof data[0].relatedObjects === "undefined") data[0].relatedObjects=[]; - data[0].relatedObjects.push({ objectId: dat.id, reason: x.reason, objectType: dat.type, user: x.user, updated: x.updated }) - } - }) - }); + p.exec(async (err, relatedData) => { + relatedData.forEach((dat) => { + dat.relatedObjects.forEach((x) => { + if ( + x.objectId === req.params.projectID && + dat.id !== req.params.projectID + ) { + if (typeof data[0].relatedObjects === 'undefined') + data[0].relatedObjects = []; + data[0].relatedObjects.push({ + objectId: dat.id, + reason: x.reason, + objectType: dat.type, + user: x.user, + updated: x.updated, + }); + } + }); + }); - if (err) return res.json({ success: false, error: err }); - return res.json({ success: true, data: data }); - }); - } - else{ - return res.status(404).send(`Project not found for Id: ${req.params.projectID}`); - } - }); + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true, data: data }); + }); + } else { + return res + .status(404) + .send(`Project not found for Id: ${req.params.projectID}`); + } + }); }); // @router PATCH /api/v1/status // @desc Set project status // @access Private -router.patch('/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - await setStatus(req) - .then(response => { - return res.json({success: true, response}); - }) - .catch(err => { - return res.json({success: false, err}); - }); - } +router.patch( + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + await setStatus(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router PUT /api/v1/ -// @desc Edit project +// @desc Edit project // @access Private -router.put('/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await editTool(req) - .then(response => { - return res.json({ success: true, response}); - }) - .catch(err => { - return res.json({ success: false, err}); - }) - } +router.put( + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await editTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index e5ac828b..b4745644 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -144,6 +144,10 @@ module.exports = { }, }, }, + { + path: 'workflow.steps.reviewers', + select: 'firstname lastname' + } ]); if (!isManager) { @@ -164,7 +168,7 @@ module.exports = { let elapsedSteps = [...steps].slice(0, activeStepIndex + 1); let found = elapsedSteps.some((step) => - step.reviewers.some((reviewer) => reviewer.equals(_id)) + step.reviewers.some((reviewer) => reviewer._id.equals(_id)) ); if (found) { @@ -254,7 +258,7 @@ module.exports = { let formattedSteps = [...steps].reduce((arr, item) => { let step = { ...item, - sections: [...item.sections].map(section => helper.darPanelMapper[section]) + displaySections: [...item.sections].map(section => helper.darPanelMapper[section]) } arr.push(step); return arr; @@ -291,4 +295,4 @@ module.exports = { }); } }, -}; +}; \ No newline at end of file diff --git a/src/resources/tool/tool.route.js b/src/resources/tool/tool.route.js index 2d88909d..b5e5029d 100644 --- a/src/resources/tool/tool.route.js +++ b/src/resources/tool/tool.route.js @@ -7,15 +7,16 @@ import { utils } from '../auth'; import { UserModel } from '../user/user.model'; import { MessagesModel } from '../message/message.model'; import { - addTool, - editTool, - deleteTool, - setStatus, - getTools, - getToolsAdmin, + addTool, + editTool, + deleteTool, + setStatus, + getTools, + getToolsAdmin, } from '../tool/data.repository'; import emailGenerator from '../utilities/emailGenerator.util'; import inputSanitizer from '../utilities/inputSanitizer'; +import _ from 'lodash'; const hdrukEmail = `enquiry@healthdatagateway.org`; const router = express.Router(); @@ -23,18 +24,18 @@ const router = express.Router(); // @desc Add tools user // @access Private router.post( - '/', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await addTool(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } + '/', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await addTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } ); // @router PUT /api/v1/{id} @@ -42,85 +43,82 @@ router.post( // @access Private // router.put('/{id}', router.put( - '/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - await editTool(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, error: err.message }); - }); - } + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + await editTool(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, error: err.message }); + }); + } ); // @router GET /api/v1/get/admin // @desc Returns List of Tool objects // @access Private router.get( - '/getList', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - req.params.type = 'tool'; - let role = req.user.role; + '/getList', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + req.params.type = 'tool'; + let role = req.user.role; - if (role === ROLES.Admin) { - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } else if (role === ROLES.Creator) { - await getTools(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } - } + if (role === ROLES.Admin) { + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } else if (role === ROLES.Creator) { + await getTools(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); + } + } ); // @router GET /api/v1/ // @desc Returns List of Tool Objects No auth // This unauthenticated route was created specifically for API-docs // @access Public -router.get( - '/', - async (req, res) => { - req.params.type = 'tool'; - await getToolsAdmin(req) - .then((data) => { - return res.json({ success: true, data }); - }) - .catch((err) => { - return res.json({ success: false, err }); - }); - } -); +router.get('/', async (req, res) => { + req.params.type = 'tool'; + await getToolsAdmin(req) + .then((data) => { + return res.json({ success: true, data }); + }) + .catch((err) => { + return res.json({ success: false, err }); + }); +}); // @router PATCH /api/v1/status // @desc Set tool status // @access Private router.patch( - '/:id', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - await setStatus(req) - .then((response) => { - return res.json({ success: true, response }); - }) - .catch((err) => { - return res.json({ success: false, error: err.message }); - }); - } + '/:id', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + await setStatus(req) + .then((response) => { + return res.json({ success: true, response }); + }) + .catch((err) => { + return res.json({ success: false, error: err.message }); + }); + } ); /** @@ -129,93 +127,96 @@ router.patch( * Return the details on the tool based on the tool ID. */ router.get('/:id', async (req, res) => { - var query = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.id) }, {type: 'tool'}]} }, - { - $lookup: { - from: 'tools', - localField: 'authors', - foreignField: 'id', - as: 'persons', - }, - }, - { - $lookup: { - from: 'tools', - localField: 'uploader', - foreignField: 'id', - as: 'uploaderIs', - }, - }, - ]); - query.exec((err, data) => { - if (data.length > 0) { - var p = Data.aggregate([ - { - $match: { - $and: [ - { relatedObjects: { $elemMatch: { objectId: req.params.id } } }, - ], - }, - }, - ]); - p.exec((err, relatedData) => { - relatedData.forEach((dat) => { - dat.relatedObjects.forEach((x) => { - if (x.objectId === req.params.id && dat.id !== req.params.id) { - let relatedObject = { - objectId: dat.id, - reason: x.reason, - objectType: dat.type, - user: x.user, - updated: x.updated - }; - data[0].relatedObjects = [relatedObject, ...data[0].relatedObjects || []]; - } - }); - }); + var query = Data.aggregate([ + { $match: { $and: [{ id: parseInt(req.params.id) }, { type: 'tool' }] } }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + { + $lookup: { + from: 'tools', + localField: 'uploader', + foreignField: 'id', + as: 'uploaderIs', + }, + }, + ]); + query.exec((err, data) => { + if (data.length > 0) { + var p = Data.aggregate([ + { + $match: { + $and: [ + { relatedObjects: { $elemMatch: { objectId: req.params.id } } }, + ], + }, + }, + ]); + p.exec((err, relatedData) => { + relatedData.forEach((dat) => { + dat.relatedObjects.forEach((x) => { + if (x.objectId === req.params.id && dat.id !== req.params.id) { + let relatedObject = { + objectId: dat.id, + reason: x.reason, + objectType: dat.type, + user: x.user, + updated: x.updated, + }; + data[0].relatedObjects = [ + relatedObject, + ...(data[0].relatedObjects || []), + ]; + } + }); + }); - var r = Reviews.aggregate([ - { - $match: { - $and: [ - { toolID: parseInt(req.params.id) }, - { activeflag: 'active' }, - ], - }, - }, - { $sort: { date: -1 } }, - { - $lookup: { - from: 'tools', - localField: 'reviewerID', - foreignField: 'id', - as: 'person', - }, - }, - { - $lookup: { - from: 'tools', - localField: 'replierID', - foreignField: 'id', - as: 'owner', - }, - }, - ]); - r.exec(async (err, reviewData) => { - if (err) return res.json({ success: false, error: err }); + var r = Reviews.aggregate([ + { + $match: { + $and: [ + { toolID: parseInt(req.params.id) }, + { activeflag: 'active' }, + ], + }, + }, + { $sort: { date: -1 } }, + { + $lookup: { + from: 'tools', + localField: 'reviewerID', + foreignField: 'id', + as: 'person', + }, + }, + { + $lookup: { + from: 'tools', + localField: 'replierID', + foreignField: 'id', + as: 'owner', + }, + }, + ]); + r.exec(async (err, reviewData) => { + if (err) return res.json({ success: false, error: err }); - return res.json({ - success: true, - data: data, - reviewData: reviewData - }); - }); - }); - } else { - return res.status(404).send(`Tool not found for Id: ${req.params.id}`); - } - }); + return res.json({ + success: true, + data: data, + reviewData: reviewData, + }); + }); + }); + } else { + return res.status(404).send(`Tool not found for Id: ${req.params.id}`); + } + }); }); /** @@ -224,27 +225,27 @@ router.get('/:id', async (req, res) => { * Return the details on the tool based on the tool ID for edit. */ router.get('/edit/:id', async (req, res) => { - var query = Data.aggregate([ - { $match: { $and: [{ id: parseInt(req.params.id) }] } }, - { - $lookup: { - from: 'tools', - localField: 'authors', - foreignField: 'id', - as: 'persons', - }, - }, - ]); - query.exec((err, data) => { - if (data.length > 0) { - return res.json({ success: true, data: data }); - } else { - return res.json({ - success: false, - error: `Tool not found for tool id ${req.params.id}`, - }); - } - }); + var query = Data.aggregate([ + { $match: { $and: [{ id: parseInt(req.params.id) }] } }, + { + $lookup: { + from: 'tools', + localField: 'authors', + foreignField: 'id', + as: 'persons', + }, + }, + ]); + query.exec((err, data) => { + if (data.length > 0) { + return res.json({ success: true, data: data }); + } else { + return res.json({ + success: false, + error: `Tool not found for tool id ${req.params.id}`, + }); + } + }); }); /** @@ -255,30 +256,30 @@ router.get('/edit/:id', async (req, res) => { * We will also check the review (Free word entry) for exclusion data (node module?) */ router.post( - '/review/add', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - let reviews = new Reviews(); - const { toolID, reviewerID, rating, projectName, review } = req.body; + '/review/add', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + let reviews = new Reviews(); + const { toolID, reviewerID, rating, projectName, review } = req.body; - reviews.reviewID = parseInt(Math.random().toString().replace('0.', '')); - reviews.toolID = toolID; - reviews.reviewerID = reviewerID; - reviews.rating = rating; - reviews.projectName = inputSanitizer.removeNonBreakingSpaces(projectName); - reviews.review = inputSanitizer.removeNonBreakingSpaces(review); - reviews.activeflag = 'review'; - reviews.date = Date.now(); + reviews.reviewID = parseInt(Math.random().toString().replace('0.', '')); + reviews.toolID = toolID; + reviews.reviewerID = reviewerID; + reviews.rating = rating; + reviews.projectName = inputSanitizer.removeNonBreakingSpaces(projectName); + reviews.review = inputSanitizer.removeNonBreakingSpaces(review); + reviews.activeflag = 'review'; + reviews.date = Date.now(); - reviews.save(async (err) => { - if (err) { - return res.json({ success: false, error: err }); - } else { - return res.json({ success: true, id: reviews.reviewID }); - } - }); - } + reviews.save(async (err) => { + if (err) { + return res.json({ success: false, error: err }); + } else { + return res.json({ success: true, id: reviews.reviewID }); + } + }); + } ); /** @@ -289,56 +290,56 @@ router.post( * We will also check the review (Free word entry) for exclusion data (node module?) */ router.post( - '/reply', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - const { reviewID, replierID, reply } = req.body; - Reviews.findOneAndUpdate( - { reviewID: reviewID }, - { - replierID: replierID, - reply: inputSanitizer.removeNonBreakingSpaces(reply), - replydate: Date.now(), - }, - (err) => { - if (err) return res.json({ success: false, error: err }); - return res.json({ success: true }); - } - ); - } + '/reply', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + const { reviewID, replierID, reply } = req.body; + Reviews.findOneAndUpdate( + { reviewID: reviewID }, + { + replierID: replierID, + reply: inputSanitizer.removeNonBreakingSpaces(reply), + replydate: Date.now(), + }, + (err) => { + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true }); + } + ); + } ); - + /** * {post} /tool/review/approve Approve review * * Authenticate user to see if user can approve. */ router.put( - '/review/approve', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - const { id, activeflag } = req.body; - Reviews.findOneAndUpdate( - { reviewID: id }, - { - activeflag: activeflag, - }, - (err) => { - if (err) return res.json({ success: false, error: err }); + '/review/approve', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + const { id, activeflag } = req.body; + Reviews.findOneAndUpdate( + { reviewID: id }, + { + activeflag: activeflag, + }, + (err) => { + if (err) return res.json({ success: false, error: err }); - return res.json({ success: true }); - } - ).then(async (res) => { - const review = await Reviews.findOne({ reviewID: id }); + return res.json({ success: true }); + } + ).then(async (res) => { + const review = await Reviews.findOne({ reviewID: id }); - await storeNotificationMessages(review); + await storeNotificationMessages(review); - // Send email notififcation of approval to authors and admins who have opted in - await sendEmailNotifications(review); - }); - } + // Send email notififcation of approval to authors and admins who have opted in + await sendEmailNotifications(review); + }); + } ); /** @@ -347,16 +348,16 @@ router.put( * Authenticate user to see if user can reject. */ router.delete( - '/review/reject', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin), - async (req, res) => { - const { id } = req.body; - Reviews.findOneAndDelete({ reviewID: id }, (err) => { - if (err) return res.send(err); - return res.json({ success: true }); - }); - } + '/review/reject', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin), + async (req, res) => { + const { id } = req.body; + Reviews.findOneAndDelete({ reviewID: id }, (err) => { + if (err) return res.send(err); + return res.json({ success: true }); + }); + } ); /** @@ -365,16 +366,16 @@ router.delete( * When they delete, authenticate the user and remove the review data from the DB. */ router.delete( - '/review/delete', - passport.authenticate('jwt'), - utils.checkIsInRole(ROLES.Admin, ROLES.Creator), - async (req, res) => { - const { id } = req.body; - Data.findOneAndDelete({ id: id }, (err) => { - if (err) return res.send(err); - return res.json({ success: true }); - }); - } + '/review/delete', + passport.authenticate('jwt'), + utils.checkIsInRole(ROLES.Admin, ROLES.Creator), + async (req, res) => { + const { id } = req.body; + Data.findOneAndDelete({ id: id }, (err) => { + if (err) return res.send(err); + return res.json({ success: true }); + }); + } ); //Validation required if Delete is to be implemented @@ -392,70 +393,117 @@ router.delete( // } // ); +// @router GET /api/v1/project/tag/name +// @desc Get tools by tag search +// @access Public +router.get('/:type/tag/:name', async (req, res) => { + try { + // 1. Destructure tag name parameter passed + let { type, name } = req.params; + // 2. Check if parameters are empty + if (_.isEmpty(name) || _.isEmpty(type)) { + return res + .status(400) + .json({ success: false, message: 'Entity type and tag are required' }); + } + // 3. Find matching projects in MongoDb selecting name and id + let entities = await Data.find({ + $and: [ + { type }, + { $or: [{ 'tags.topics': name }, { 'tags.features': name }] }, + ], + }).select('id name'); + // 4. Return projects + return res.status(200).json({ success: true, entities }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred searching for tools by tag', + }); + } +}); + module.exports = router; async function storeNotificationMessages(review) { - const tool = await Data.findOne({ id: review.toolID }); - //Get reviewer name - const reviewer = await UserModel.findOne({ id: review.reviewerID }); - const toolLink = - process.env.homeURL + '/tool/' + review.toolID + '/' + tool.name; - //admins - let message = new MessagesModel(); - message.messageID = parseInt(Math.random().toString().replace('0.', '')); - message.messageTo = 0; - message.messageObjectID = review.toolID; - message.messageType = 'review'; - message.messageSent = Date.now(); - message.isRead = false; - message.messageDescription = `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name} ${toolLink}`; + const tool = await Data.findOne({ id: review.toolID }); + //Get reviewer name + const reviewer = await UserModel.findOne({ id: review.reviewerID }); + const toolLink = + process.env.homeURL + '/tool/' + review.toolID + '/' + tool.name; + //admins + let message = new MessagesModel(); + message.messageID = parseInt(Math.random().toString().replace('0.', '')); + message.messageTo = 0; + message.messageObjectID = review.toolID; + message.messageType = 'review'; + message.messageSent = Date.now(); + message.isRead = false; + message.messageDescription = `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name} ${toolLink}`; - await message.save(async (err) => { - if (err) { - return new Error({ success: false, error: err }); - } - }); - //authors - const authors = tool.authors; - authors.forEach(async (author) => { - message.messageTo = author; - await message.save(async (err) => { - if (err) { - return new Error({ success: false, error: err }); - } - }); - }); - return { success: true, id: message.messageID }; + await message.save(async (err) => { + if (err) { + return new Error({ success: false, error: err }); + } + }); + //authors + const authors = tool.authors; + authors.forEach(async (author) => { + message.messageTo = author; + await message.save(async (err) => { + if (err) { + return new Error({ success: false, error: err }); + } + }); + }); + return { success: true, id: message.messageID }; } async function sendEmailNotifications(review) { - // 1. Retrieve tool for authors and reviewer user plus generate URL for linking tool - const tool = await Data.findOne({ id: review.toolID }); - const reviewer = await UserModel.findOne({ id: review.reviewerID }); - const toolLink = process.env.homeURL + '/tool/' + tool.id + '/' + tool.name; + // 1. Retrieve tool for authors and reviewer user plus generate URL for linking tool + const tool = await Data.findOne({ id: review.toolID }); + const reviewer = await UserModel.findOne({ id: review.reviewerID }); + const toolLink = process.env.homeURL + '/tool/' + tool.id + '/' + tool.name; - // 2. Query Db for all admins or authors of the tool who have opted in to email updates - var q = UserModel.aggregate([ - // Find all users who are admins or authors of this tool - { $match: { $or: [{ role: 'Admin' }, { id: { $in: tool.authors } }] } }, - // Perform lookup to check opt in/out flag in tools schema - { $lookup: { from: 'tools', localField: 'id', foreignField: 'id', as: 'tool' } }, - // Filter out any user who has opted out of email notifications - { $match: { 'tool.emailNotifications': true } }, - // Reduce response payload size to required fields - { $project: { _id: 1, firstname: 1, lastname: 1, email: 1, role: 1, 'tool.emailNotifications': 1 } } - ]); + // 2. Query Db for all admins or authors of the tool who have opted in to email updates + var q = UserModel.aggregate([ + // Find all users who are admins or authors of this tool + { $match: { $or: [{ role: 'Admin' }, { id: { $in: tool.authors } }] } }, + // Perform lookup to check opt in/out flag in tools schema + { + $lookup: { + from: 'tools', + localField: 'id', + foreignField: 'id', + as: 'tool', + }, + }, + // Filter out any user who has opted out of email notifications + { $match: { 'tool.emailNotifications': true } }, + // Reduce response payload size to required fields + { + $project: { + _id: 1, + firstname: 1, + lastname: 1, + email: 1, + role: 1, + 'tool.emailNotifications': 1, + }, + }, + ]); - // 3. Use the returned array of email recipients to generate and send emails with SendGrid - q.exec((err, emailRecipients) => { - if (err) { - return new Error({ success: false, error: err }); - } - emailGenerator.sendEmail( - emailRecipients, - `${hdrukEmail}`, - `Someone reviewed your tool`, - `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name}

${toolLink}` - ); - }); + // 3. Use the returned array of email recipients to generate and send emails with SendGrid + q.exec((err, emailRecipients) => { + if (err) { + return new Error({ success: false, error: err }); + } + emailGenerator.sendEmail( + emailRecipients, + `${hdrukEmail}`, + `Someone reviewed your tool`, + `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to your tool ${tool.name}

${toolLink}` + ); + }); } diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 8acf75b9..4b80059a 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -324,7 +324,7 @@ module.exports = { // Extract deadline and reminder offset in days from step definition let { deadline, reminderOffset } = step; // Subtract SLA reminder offset - let reminderPeriod = deadline - reminderOffset; + let reminderPeriod = +deadline - +reminderOffset; return `P${reminderPeriod}D`; }, From 5092e1c47d49fc3d24f753368768bf3e7746a110 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 22 Oct 2020 14:59:35 +0100 Subject: [PATCH 111/144] Continued email build out --- .../datarequest/datarequest.controller.js | 241 +++++++++++------- .../utilities/emailGenerator.util.js | 10 +- src/resources/workflow/workflow.controller.js | 104 ++++---- src/resources/workflow/workflow.route.js | 3 +- 4 files changed, 216 insertions(+), 142 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index a8d59dcb..5646ec47 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -3,6 +3,7 @@ import { DataRequestModel } from './datarequest.model'; import { WorkflowModel } from '../workflow/workflow.model'; import { Data as ToolModel } from '../tool/data.model'; import { DataRequestSchemaModel } from './datarequest.schemas.model'; +import workflowController from '../workflow/workflow.controller'; import helper from '../utilities/helper.util'; import _ from 'lodash'; import { UserModel } from '../user/user.model'; @@ -12,7 +13,6 @@ import mongoose from 'mongoose'; const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); const teamController = require('../team/team.controller'); -const workflowController = require('../workflow/workflow.controller'); const notificationBuilder = require('../utilities/notificationBuilder'); const hdrukEmail = `enquiry@healthdatagateway.org`; @@ -30,8 +30,8 @@ const notificationTypes = { REVIEWSTEPSTART: 'ReviewStepStart', FINALDECISIONREQUIRED: 'FinalDecisionRequired', DEADLINEWARNING: 'DeadlineWarning', - DEADLINEPASSED: 'DeadlinePassed' -} + DEADLINEPASSED: 'DeadlinePassed', +}; const applicationStatuses = { SUBMITTED: 'submitted', @@ -40,8 +40,8 @@ const applicationStatuses = { APPROVED: 'approved', REJECTED: 'rejected', APPROVEDWITHCONDITIONS: 'approved with conditions', - WITHDRAWN: 'withdrawn' -} + WITHDRAWN: 'withdrawn', +}; module.exports = { //GET api/v1/data-access-request @@ -150,12 +150,15 @@ module.exports = { readOnly = false; } // 7. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections, hasRecommended } = workflowController.getReviewStatus( - accessRecord, - req.user._id - ); + let { + inReviewMode, + reviewSections, + hasRecommended, + } = workflowController.getReviewStatus(accessRecord, req.user._id); // 8. Get the workflow/voting status - let workflow = workflowController.getWorkflowStatus(accessRecord.toObject()); + let workflow = workflowController.getWorkflowStatus( + accessRecord.toObject() + ); // 9. Check if the current user can override the current step if (_.has(accessRecord.datasets[0].toObject(), 'publisher.team')) { let isManager = teamController.checkTeamPermissions( @@ -412,8 +415,8 @@ module.exports = { updateObj = { ...updateObj, questionAnswers }; } - if(!_.isEmpty(jsonSchema)) { - updateObj = {...updateObj, jsonSchema} + if (!_.isEmpty(jsonSchema)) { + updateObj = { ...updateObj, jsonSchema }; } // 3. Find data request by _id and update via body @@ -478,9 +481,9 @@ module.exports = { }, }, { - path: 'workflow.steps.reviewers', - select: 'id email' - } + path: 'workflow.steps.reviewers', + select: 'id email', + }, ]); if (!accessRecord) { return res @@ -790,9 +793,17 @@ module.exports = { }; bpmController.postStartStepReview(bpmContext); // 14. Gather context for notifications - const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); + const emailContext = workflowController.getWorkflowEmailContext( + workflowObj, + 0 + ); // 15. Create notifications to reviewers of the step that has been completed - module.exports.createNotifications(notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); + module.exports.createNotifications( + notificationTypes.REVIEWSTEPSTART, + emailContext, + accessRecord, + req.user + ); // 16. Return workflow payload return res.status(200).json({ success: true, @@ -1015,14 +1026,30 @@ module.exports = { res.status(500).json({ status: 'error', message: err }); } else { // 15. Create emails and notifications - if(bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { - const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); + if (bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { + const emailContext = workflowController.getWorkflowEmailContext( + workflowObj, + 0 + ); // Create notifications to reviewers of the next step that has been activated - module.exports.createNotifications(notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); + module.exports.createNotifications( + notificationTypes.REVIEWSTEPSTART, + emailContext, + accessRecord, + req.user + ); } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { - const emailContext = workflowController.getWorkflowEmailContext(workflowObj, 0); + const emailContext = workflowController.getWorkflowEmailContext( + workflowObj, + 0 + ); // Create notifications to managers that the application is awaiting final approval - module.exports.createNotifications(notificationTypes.FINALDECISIONREQUIRED, emailContext, accessRecord, req.user); + module.exports.createNotifications( + notificationTypes.FINALDECISIONREQUIRED, + emailContext, + accessRecord, + req.user + ); } // 16. Call Camunda controller to update workflow process bpmController.postCompleteReview(bpmContext); @@ -1047,12 +1074,27 @@ module.exports = { } = req; let { _id: userId } = req.user; // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ - path: 'publisherObj', - populate: { - path: 'team', + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users' + } + }, }, - }); + { + path: 'workflow.steps.reviewers', + select: 'firstname lastname', + }, + { + path: 'datasets dataset' + }, + { + path: 'mainApplicant' + }, + ]); if (!accessRecord) { return res .status(404) @@ -1125,15 +1167,37 @@ module.exports = { console.error(err); res.status(500).json({ status: 'error', message: err }); } else { - // 12. Gather context for notifications - const emailContext = workflowController.getWorkflowEmailContext(workflow, activeStepIndex); + // 12. Gather context for notifications (active step) + let emailContext = workflowController.getWorkflowEmailContext( + workflow, + activeStepIndex + ); // 13. Create notifications to reviewers of the step that has been completed - module.exports.createNotifications(notificationTypes.STEPOVERRIDE, emailContext, accessRecord, req.user); - // 14. Call Camunda controller to start manager review process + module.exports.createNotifications( + notificationTypes.STEPOVERRIDE, + emailContext, + accessRecord, + req.user + ); + // 14. Gather context for notifications (next step) + if (!bpmContext.finalPhaseApproved) { + emailContext = workflowController.getWorkflowEmailContext( + workflow, + activeStepIndex + 1 + ); + } + // 15. Create notifications to reviewers of the next step if it was not the final step + module.exports.createNotifications( + notificationTypes.REVIEWSTEPSTART, + emailContext, + accessRecord, + req.user + ); + // 16. Call Camunda controller to start manager review process bpmController.postCompleteReview(bpmContext); } }); - // 15. Return aplication and successful response + // 17. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { console.log(err.message); @@ -1251,7 +1315,7 @@ module.exports = { // Project details from about application if 5 Safes let aboutApplication = JSON.parse(accessRecord.aboutApplication); let { projectName } = aboutApplication; - let { projectId, _id, workflow = {} } = accessRecord; + let { projectId, _id, workflow = {}, dateSubmitted = '' } = accessRecord; if (_.isEmpty(projectId)) { projectId = _id; } @@ -1292,6 +1356,15 @@ module.exports = { return { firstname, lastname, email, id }; }); } + // Deconstruct workflow context if passed + let { + workflowName = '', + stepName = '', + reviewerNames = '', + reviewSections = '', + nextStepName = '', + } = context; + switch (type) { // DAR application status has been updated case notificationTypes.STATUSCHANGE: @@ -1301,10 +1374,17 @@ module.exports = { _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { // Retrieve all custodian manager user Ids and active step reviewers - custodianManagers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, teamController.roleTypes.MANAGER); - stepReviewers = workflowController.getActiveStepReviewers(workflow); + custodianManagers = teamController.getTeamMembersByRole( + accessRecord.datasets[0].publisher.team, + teamController.roleTypes.MANAGER + ); + let activeStep = workflowController.getActiveWorkflowStep(workflow); + stepReviewers = workflowController.getStepReviewers(activeStep); // Create custodian notification - let statusChangeUserIds = [...custodianManagers, ...stepReviewers].map((user) => user.id); + let statusChangeUserIds = [ + ...custodianManagers, + ...stepReviewers, + ].map((user) => user.id); await notificationBuilder.triggerNotificationMessage( statusChangeUserIds, `${appFirstName} ${appLastName}'s Data Access Request for ${datasetTitles} was ${context.applicationStatus} by ${firstname} ${lastname}`, @@ -1336,9 +1416,8 @@ module.exports = { accessRecord.mainApplicant, ...custodianManagers, ...stepReviewers, - ...accessRecord.authors + ...accessRecord.authors, ]; - let { dateSubmitted } = accessRecord; if (!dateSubmitted) ({ updatedAt: dateSubmitted } = accessRecord); // Create object to pass through email data options = { @@ -1379,7 +1458,10 @@ module.exports = { _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { // Retrieve all custodian user Ids to generate notifications - custodianManagers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, roleTypes.MANAGER); + custodianManagers = teamController.getTeamMembersByRole( + accessRecord.datasets[0].publisher.team, + roleTypes.MANAGER + ); let custodianUserIds = custodianManagers.map((user) => user.id); await notificationBuilder.triggerNotificationMessage( custodianUserIds, @@ -1521,16 +1603,9 @@ module.exports = { } break; case notificationTypes.STEPOVERRIDE: - let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( - _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') + _.has(accessRecord.toObject(), 'publisherObj.team.users') ) { - if(!_.isEmpty(workflow)) { - // Retrieve all user Ids for active step reviewers - stepReviewers = workflowController.getActiveStepReviewers(workflow); - reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); - } - // 1. Create reviewer notifications let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); await notificationBuilder.triggerNotificationMessage( @@ -1553,7 +1628,8 @@ module.exports = { stepName, reviewSections, reviewerNames, - nextStepName + nextStepName, + dateSubmitted }; html = await emailGenerator.generateStepOverrideEmail(options); await emailGenerator.sendEmail( @@ -1566,16 +1642,9 @@ module.exports = { } break; case notificationTypes.REVIEWSTEPSTART: - let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { - if(!_.isEmpty(workflow)) { - // Retrieve all user Ids for active step reviewers - stepReviewers = workflowController.getActiveStepReviewers(workflow); - reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); - } - // 1. Create reviewer notifications let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); await notificationBuilder.triggerNotificationMessage( @@ -1598,7 +1667,7 @@ module.exports = { stepName, reviewSections, reviewerNames, - nextStepName + nextStepName, }; html = await emailGenerator.generateNewReviewPhaseEmail(options); await emailGenerator.sendEmail( @@ -1611,16 +1680,9 @@ module.exports = { } break; case notificationTypes.FINALDECISIONREQUIRED: - let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { - if(!_.isEmpty(workflow)) { - // Retrieve all user Ids for active step reviewers - stepReviewers = workflowController.getActiveStepReviewers(workflow); - reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); - } - // 1. Create reviewer notifications let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); await notificationBuilder.triggerNotificationMessage( @@ -1643,9 +1705,11 @@ module.exports = { stepName, reviewSections, reviewerNames, - nextStepName + nextStepName, }; - html = await emailGenerator.generateFinalDecisionRequiredEmail(options); + html = await emailGenerator.generateFinalDecisionRequiredEmail( + options + ); await emailGenerator.sendEmail( stepReviewers, hdrukEmail, @@ -1656,16 +1720,9 @@ module.exports = { } break; case notificationTypes.DEADLINEWARNING: - let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { - if(!_.isEmpty(workflow)) { - // Retrieve all user Ids for active step reviewers - stepReviewers = workflowController.getActiveStepReviewers(workflow); - reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); - } - // 1. Create reviewer notifications let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); await notificationBuilder.triggerNotificationMessage( @@ -1688,7 +1745,7 @@ module.exports = { stepName, reviewSections, reviewerNames, - nextStepName + nextStepName, }; html = await emailGenerator.generateReviewDeadlineWarning(options); await emailGenerator.sendEmail( @@ -1701,16 +1758,9 @@ module.exports = { } break; case notificationTypes.DEADLINEPASSED: - let { workflowName, stepName, reviewerNames, reviewSections, nextStepName } = context; if ( _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') ) { - if(!_.isEmpty(workflow)) { - // Retrieve all user Ids for active step reviewers - stepReviewers = workflowController.getActiveStepReviewers(workflow); - reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); - } - // 1. Create reviewer notifications let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); await notificationBuilder.triggerNotificationMessage( @@ -1733,7 +1783,7 @@ module.exports = { stepName, reviewSections, reviewerNames, - nextStepName + nextStepName, }; html = await emailGenerator.generateReviewDeadlinePassed(options); await emailGenerator.sendEmail( @@ -1744,24 +1794,28 @@ module.exports = { false ); } - } + } }, getUserPermissionsForApplication: (application, userId, _id) => { try { let authorised = false, - userType = '', + userType = ''; // Return default unauthorised with no user type if incorrect params passed if (!application || !userId || !_id) { return { authorised, userType }; } // Check if the user is a custodian team member and assign permissions if so if (_.has(application.datasets[0].toObject(), 'publisher.team')) { - let isTeamMember = teamController.checkTeamPermissions('', application.datasets[0].publisher.team.toObject() ,_id); - if(isTeamMember) { + let isTeamMember = teamController.checkTeamPermissions( + '', + application.datasets[0].publisher.team.toObject(), + _id + ); + if (isTeamMember) { userType = userTypes.CUSTODIAN; authorised = true; - }; + } } // If user is not authenticated as a custodian, check if they are an author or the main applicant if ( @@ -1848,11 +1902,14 @@ module.exports = { ) .map((user) => { let isCurrentUser = user._id.toString() === userId.toString(); - return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)`:``}`; + return `${user.firstname} ${user.lastname}${ + isCurrentUser ? ` (you)` : `` + }`; }); if ( applicationStatus === applicationStatuses.SUBMITTED || - (applicationStatus === applicationStatuses.INREVIEW && _.isEmpty(workflow)) + (applicationStatus === applicationStatuses.INREVIEW && + _.isEmpty(workflow)) ) { remainingActioners = managerUsers.join(', '); } @@ -1874,7 +1931,11 @@ module.exports = { decisionDate, isReviewer = false, reviewPanels = [], - } = workflowController.getActiveStepStatus(activeStep, users, userId)); + } = workflowController.getActiveStepStatus( + activeStep, + users, + userId + )); let activeStepIndex = workflow.steps.findIndex((step) => { return step.active === true; }); @@ -1985,5 +2046,5 @@ module.exports = { return parseInt(totalDecisionTime / decidedApplications.length / 86400); } return 0; - } + }, }; diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 41c7298d..9cda69ff 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -556,7 +556,7 @@ const _generateContributorEmail = (options) => { }; const _generateStepOverrideEmail = (options) => { - let { id, projectName, projectId, datasetTitles, actioner, applicants, workflowName, stepName, nextStepName, reviewSections, reviewerNames } = options; + let { id, projectName, projectId, datasetTitles, actioner, applicants, workflowName, stepName, nextStepName, reviewSections, reviewerNames, dateSubmitted } = options; let body = `
{ }; const _generateNewReviewPhaseEmail = (options) => { - let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames } = options; + let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames, dateSubmitted } = options; let body = `
{ }; const _generateReviewDeadlineWarning = (options) => { - let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames } = options; + let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames, dateSubmitted } = options; let body = `
{ }; const _generateReviewDeadlinePassed = (options) => { - let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames } = options; + let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames, dateSubmitted } = options; let body = `
{ }; const _generateFinalDecisionRequiredEmail = (options) => { - let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames } = options; + let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames, dateSubmitted } = options; let body = `
{ + const getWorkflowById = async (req, res) => { try { // 1. Get the workflow from the database including the team members to check authorisation and the number of in-flight applications const workflow = await WorkflowModel.findOne({ @@ -91,10 +91,10 @@ module.exports = { message: 'An error occurred searching for the specified workflow', }); } - }, + }; // POST api/v1/workflows - createWorkflow: async (req, res) => { + const createWorkflow = async (req, res) => { try { const { _id: userId } = req.user; // 1. Look at the payload for the publisher passed @@ -166,10 +166,10 @@ module.exports = { message: 'An error occurred creating the workflow', }); } - }, + }; // PUT api/v1/workflows/:id - updateWorkflow: async (req, res) => { + const updateWorkflow = async (req, res) => { try { const { _id: userId } = req.user; const { id: workflowId } = req.params; @@ -251,10 +251,10 @@ module.exports = { message: 'An error occurred editing the workflow', }); } - }, + }; // DELETE api/v1/workflows/:id - deleteWorkflow: async (req, res) => { + const deleteWorkflow = async (req, res) => { try { const { _id: userId } = req.user; const { id: workflowId } = req.params; @@ -318,17 +318,17 @@ module.exports = { message: 'An error occurred deleting the workflow', }); } - }, + }; - calculateStepDeadlineReminderDate: (step) => { + const calculateStepDeadlineReminderDate = (step) => { // Extract deadline and reminder offset in days from step definition let { deadline, reminderOffset } = step; // Subtract SLA reminder offset let reminderPeriod = +deadline - +reminderOffset; return `P${reminderPeriod}D`; - }, + }; - workflowStepContainsManager: (reviewers, team) => { + const workflowStepContainsManager = (reviewers, team) => { let managerExists = false; // 1. Extract team members let { members } = team; @@ -347,9 +347,9 @@ module.exports = { } }) return managerExists; - }, + }; - buildNextStep: (userId, application, activeStepIndex, override) => { + const buildNextStep = (userId, application, activeStepIndex, override) => { // Check the current position of the application within its assigned workflow const finalStep = activeStepIndex === application.workflow.steps.length -1; const requiredReviews = application.workflow.steps[activeStepIndex].reviewers.length; @@ -382,25 +382,25 @@ module.exports = { ...bpmContext, dataRequestPublisher, dataRequestStepName, - notifyReviewerSLA: module.exports.calculateStepDeadlineReminderDate( + notifyReviewerSLA: calculateStepDeadlineReminderDate( nextStep ), reviewerList }; } return bpmContext; - }, + }; - getWorkflowCompleted: (workflow = {}) => { + const getWorkflowCompleted = (workflow = {}) => { let workflowCompleted = false; if (!_.isEmpty(workflow)) { let { steps } = workflow; workflowCompleted = steps.every((step) => step.completed); } return workflowCompleted; - }, + }; - getActiveWorkflowStep: (workflow = {}) => { + const getActiveWorkflowStep = (workflow = {}) => { let activeStep = {}; if (!_.isEmpty(workflow)) { let { steps } = workflow; @@ -409,23 +409,21 @@ module.exports = { }); } return activeStep; - }, + }; - getActiveStepReviewers: (workflow = {}) => { + const getStepReviewers = (step = {}) => { let stepReviewers = []; // Attempt to get step reviewers if workflow passed - if (!_.isEmpty(workflow)) { - // Get active step - let activeStep = module.exports.getActiveWorkflowStep(workflow); - // If active step, return the reviewers - if(activeStep) { - ({ reviewers: stepReviewers }) = activeStep; + if (!_.isEmpty(step)) { + // Get active reviewers + if(step) { + ({ reviewers: stepReviewers } = step); } } return stepReviewers; - }, + }; - getActiveStepStatus: (activeStep, users = [], userId = '') => { + const getActiveStepStatus = (activeStep, users = [], userId = '') => { let reviewStatus = '', deadlinePassed = false, remainingActioners = [], @@ -514,15 +512,15 @@ module.exports = { decisionComments, reviewPanels, }; - }, + }; - getWorkflowStatus: (application) => { + const getWorkflowStatus = (application) => { let workflowStatus = {}; let { workflow = {} } = application; if (!_.isEmpty(workflow)) { let { workflowName, steps } = workflow; // Find the active step in steps - let activeStep = module.exports.getActiveWorkflowStep(workflow); + let activeStep = getActiveWorkflowStep(workflow); let activeStepIndex = steps.findIndex((step) => { return step.active === true; }); @@ -530,7 +528,7 @@ module.exports = { let { reviewStatus, deadlinePassed, - } = module.exports.getActiveStepStatus(activeStep); + } = getActiveStepStatus(activeStep); //Update active step with review status steps[activeStepIndex] = { ...steps[activeStepIndex], @@ -553,13 +551,13 @@ module.exports = { workflowStatus = { workflowName, steps: formattedSteps, - isCompleted: module.exports.getWorkflowCompleted(workflow), + isCompleted: getWorkflowCompleted(workflow), }; } return workflowStatus; - }, + }; - getReviewStatus: (application, userId) => { + const getReviewStatus = (application, userId) => { let inReviewMode = false, reviewSections = [], isActiveStepReviewer = false, @@ -593,21 +591,37 @@ module.exports = { } return { inReviewMode, reviewSections, hasRecommended }; - }, + }; - getWorkflowEmailContext: (workflow, activeStepIndex) => { + const getWorkflowEmailContext = (workflow, relatedStepIndex) => { const { workflowName, steps } = workflow; - const { stepName } = steps[activeStepIndex]; - const stepReviewers = workflowController.getActiveStepReviewers(workflow); - const reviewerNames = stepReviewers.map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); - const reviewSections = [...steps[activeStepIndex].sections].map((section) => helper.darPanelMapper[section]); + const { stepName } = steps[relatedStepIndex]; + const stepReviewers = getStepReviewers(steps[relatedStepIndex]); + const reviewerNames = [...stepReviewers].map((reviewer) => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + const reviewSections = [...steps[relatedStepIndex].sections].map((section) => helper.darPanelMapper[section]).join(', '); let nextStepName = ''; //Find name of next step if this is not the final step - if(activeStepIndex + 1 > steps.length) { + if(relatedStepIndex + 1 > steps.length) { nextStepName = 'No next step'; } else { - ({ stepName: nextStepName } = steps[activeStepIndex + 1]); + ({ stepName: nextStepName } = steps[relatedStepIndex + 1]); } return { workflowName, stepName, reviewerNames, reviewSections, nextStepName }; - }, + }; + +export default { + getWorkflowById: getWorkflowById, + createWorkflow: createWorkflow, + updateWorkflow: updateWorkflow, + deleteWorkflow: deleteWorkflow, + calculateStepDeadlineReminderDate: calculateStepDeadlineReminderDate, + workflowStepContainsManager: workflowStepContainsManager, + buildNextStep: buildNextStep, + getWorkflowCompleted: getWorkflowCompleted, + getActiveWorkflowStep: getActiveWorkflowStep, + getStepReviewers: getStepReviewers, + getActiveStepStatus: getActiveStepStatus, + getWorkflowStatus: getWorkflowStatus, + getReviewStatus: getReviewStatus, + getWorkflowEmailContext: getWorkflowEmailContext }; \ No newline at end of file diff --git a/src/resources/workflow/workflow.route.js b/src/resources/workflow/workflow.route.js index 6720a79c..8af3d8a0 100644 --- a/src/resources/workflow/workflow.route.js +++ b/src/resources/workflow/workflow.route.js @@ -1,7 +1,6 @@ import express from 'express'; import passport from 'passport'; - -const workflowController = require('./workflow.controller'); +import workflowController from './workflow.controller'; const router = express.Router(); From f25e68843e613d1bef1cbf70cc3d6f0c887d02b5 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 22 Oct 2020 15:39:35 +0100 Subject: [PATCH 112/144] Updating profile with new privacy settings --- src/resources/person/person.service.js | 18 ++++++++++++++---- src/resources/user/user.register.route.js | 11 +++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/resources/person/person.service.js b/src/resources/person/person.service.js index eb7b3675..d1d20dbd 100644 --- a/src/resources/person/person.service.js +++ b/src/resources/person/person.service.js @@ -13,7 +13,13 @@ export async function createPerson({ organisation, showMyOrganisation, tags, -}) { + showSector, + showOrganisation, + showBio, + showLink, + showOrcid, + showDomain +}){ var type = "person"; var activeflag = "active"; return new Promise(async (resolve, reject) => { @@ -32,9 +38,13 @@ export async function createPerson({ sector, organisation, showMyOrganisation, - tags: { - features: [String], - }, + tags, + showSector, + showOrganisation, + showBio, + showLink, + showOrcid, + showDomain }) ) }) diff --git a/src/resources/user/user.register.route.js b/src/resources/user/user.register.route.js index 5a7d4dba..332b0b8f 100644 --- a/src/resources/user/user.register.route.js +++ b/src/resources/user/user.register.route.js @@ -1,11 +1,8 @@ import express from 'express' import { to } from 'await-to-js' -import { hashPassword } from '../auth/utils' import { login } from '../auth/strategies/jwt' -import { getRedirectUrl } from '../auth/utils' import { updateUser } from '../user/user.service' import { createPerson } from '../person/person.service' -import { ROLES } from '../user/user.roles' import { getUserByUserId } from '../user/user.repository' import { registerDiscourseUser } from '../discourse/discourse.service' const urlValidator = require('../utilities/urlValidator'); @@ -28,7 +25,7 @@ router.get('/:personID', // @access Public router.post('/', async (req, res) => { - const { id, firstname, lastname, email, bio, showBio, showLink, showOrcid, redirectURL, sector, showSector, organisation, emailNotifications, terms, tags, showDomain } = req.body + const { id, firstname, lastname, email, bio, showBio, showLink, showOrcid, redirectURL, sector, showSector, organisation, emailNotifications, terms, tags, showDomain, showOrganisation } = req.body let link = urlValidator.validateURL(req.body.link); let orcid = urlValidator.validateOrcidURL(req.body.orcid); let username = `${firstname.toLowerCase()}.${lastname.toLowerCase()}`; @@ -69,6 +66,12 @@ router.post('/', organisation, tags, showDomain, + showSector, + showOrganisation, + showBio, + showLink, + showOrcid, + showDomain }) ) From e4195d6c91650432786b1abdd6c451b72edf0591 Mon Sep 17 00:00:00 2001 From: Ciara Date: Thu, 22 Oct 2020 15:40:04 +0100 Subject: [PATCH 113/144] IG-834 added course counter api --- src/config/server.js | 4 +++- src/resources/course/coursecounter.route.js | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 src/resources/course/coursecounter.route.js diff --git a/src/config/server.js b/src/config/server.js index 20648cc6..236c77fc 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -197,13 +197,15 @@ app.use('/api/v1/linkchecker', require('../resources/linkchecker/linkchecker.rou app.use('/api/v1/stats', require('../resources/stats/stats.router')); app.use('/api/v1/kpis', require('../resources/stats/kpis.router')); -app.use('/api/v1/course', require('../resources/course/course.route')); +app.use('/api/v1/course', require('../resources/course/course.route')); app.use('/api/v1/person', require('../resources/person/person.route')); app.use('/api/v1/projects', require('../resources/project/project.route')); app.use('/api/v1/papers', require('../resources/paper/paper.route')); app.use('/api/v1/counter', require('../resources/tool/counter.route')); +app.use('/api/v1/coursecounter', require('../resources/course/coursecounter.route')); + app.use('/api/v1/discourse', require('../resources/discourse/discourse.route')); app.use('/api/v1/datasets', require('../resources/dataset/dataset.route')); diff --git a/src/resources/course/coursecounter.route.js b/src/resources/course/coursecounter.route.js new file mode 100644 index 00000000..8a9d6f25 --- /dev/null +++ b/src/resources/course/coursecounter.route.js @@ -0,0 +1,14 @@ +import express from "express"; +import { Course } from "./course.model"; + +const router = express.Router(); + +router.post("/update", async (req, res) => { + const { id, counter } = req.body; + Course.findOneAndUpdate({ id: id }, { counter: counter }, err => { + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true }); + }); +}); + +module.exports = router; \ No newline at end of file From 03311982dfc7bf5817d553a052baed57800e6090 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Thu, 22 Oct 2020 16:38:49 +0100 Subject: [PATCH 114/144] Update of config for openid --- src/config/configuration.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index d0743ba9..e3c3396b 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -19,25 +19,25 @@ export const clients = [ //Metadata works client_id: process.env.MDWClientID || '', client_secret: process.env.MDWClientSecret || '', - //grant_types: ['authorization_code'], - grant_types: ['authorization_code', 'implicit'], - response_types: ['code id_token'], - //response_types: ['code'], + grant_types: ['authorization_code'], + response_types: ['code'], + //grant_types: ['authorization_code', 'implicit'], + //response_types: ['code id_token'], redirect_uris: process.env.MDWRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256', - post_logout_redirect_uris: [] + post_logout_redirect_uris: ['https://web.uatbeta.healthdatagateway.org/search?search=&logout=true'] }, { //BC Platforms client_id: process.env.BCPClientID || '', client_secret: process.env.BCPClientSecret || '', //grant_types: ['authorization_code'], + //response_types: ['code'], grant_types: ['authorization_code', 'implicit'], response_types: ['code id_token'], - //response_types: ['code'], redirect_uris: process.env.BCPRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256', - post_logout_redirect_uris: ['https://atlas-test.uksouth.cloudapp.azure.com/auth/'] + post_logout_redirect_uris: ['https://web.uatbeta.healthdatagateway.org/search?search=&logout=true'] } ]; From 9ecbc5f25b4b039bf5b88d338ad15bed98ca2db0 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 22 Oct 2020 16:46:18 +0100 Subject: [PATCH 115/144] Updating new privacy settings for profile --- src/resources/user/user.register.route.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/resources/user/user.register.route.js b/src/resources/user/user.register.route.js index 332b0b8f..dabd7ea8 100644 --- a/src/resources/user/user.register.route.js +++ b/src/resources/user/user.register.route.js @@ -66,12 +66,7 @@ router.post('/', organisation, tags, showDomain, - showSector, showOrganisation, - showBio, - showLink, - showOrcid, - showDomain }) ) From 2747dfaf3dddbf3b4bbe20469ebe920e3689a226 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 22 Oct 2020 22:27:37 +0100 Subject: [PATCH 116/144] Continued email build --- .../datarequest/datarequest.controller.js | 443 +++--- .../utilities/emailGenerator.util.js | 1280 ++++++++++------- src/resources/workflow/workflow.controller.js | 34 +- 3 files changed, 1018 insertions(+), 739 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 5646ec47..badf5c71 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -14,14 +14,11 @@ import mongoose from 'mongoose'; const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); const teamController = require('../team/team.controller'); const notificationBuilder = require('../utilities/notificationBuilder'); - const hdrukEmail = `enquiry@healthdatagateway.org`; - const userTypes = { CUSTODIAN: 'custodian', APPLICANT: 'applicant', }; - const notificationTypes = { STATUSCHANGE: 'StatusChange', SUBMITTED: 'Submitted', @@ -32,7 +29,6 @@ const notificationTypes = { DEADLINEWARNING: 'DeadlineWarning', DEADLINEPASSED: 'DeadlinePassed', }; - const applicationStatuses = { SUBMITTED: 'submitted', INPROGRESS: 'inProgress', @@ -794,6 +790,7 @@ module.exports = { bpmController.postStartStepReview(bpmContext); // 14. Gather context for notifications const emailContext = workflowController.getWorkflowEmailContext( + accessRecord, workflowObj, 0 ); @@ -918,12 +915,27 @@ module.exports = { }); } // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate({ - path: 'publisherObj', - populate: { - path: 'team', + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users', + }, + }, }, - }); + { + path: 'workflow.steps.reviewers', + select: 'firstname lastname id email', + }, + { + path: 'datasets dataset', + }, + { + path: 'mainApplicant', + }, + ]); if (!accessRecord) { return res .status(404) @@ -968,7 +980,11 @@ module.exports = { let activeStepIndex = steps.findIndex((step) => { return step.active === true; }); - if (!steps[activeStepIndex].reviewers.includes(userId)) { + if ( + !steps[activeStepIndex].reviewers + .map((reviewer) => reviewer._id.toString()) + .includes(userId.toString()) + ) { return res.status(400).json({ success: false, message: 'You have not been assigned to vote on this review phase', @@ -1026,26 +1042,26 @@ module.exports = { res.status(500).json({ status: 'error', message: err }); } else { // 15. Create emails and notifications + let relevantStepIndex = 0, + relevantNotificationType = ''; if (bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { - const emailContext = workflowController.getWorkflowEmailContext( - workflowObj, - 0 - ); // Create notifications to reviewers of the next step that has been activated - module.exports.createNotifications( - notificationTypes.REVIEWSTEPSTART, - emailContext, - accessRecord, - req.user - ); + relevantStepIndex = activeStepIndex + 1; + relevantNotificationType = notificationTypes.REVIEWSTEPSTART; } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { + // Create notifications to managers that the application is awaiting final approval + relevantStepIndex = activeStepIndex; + relevantNotificationType = notificationTypes.FINALDECISIONREQUIRED; + } + // Continue only if notification required + if (!_.isEmpty(relevantNotificationType)) { const emailContext = workflowController.getWorkflowEmailContext( - workflowObj, - 0 + accessRecord, + workflow, + relevantStepIndex ); - // Create notifications to managers that the application is awaiting final approval module.exports.createNotifications( - notificationTypes.FINALDECISIONREQUIRED, + relevantNotificationType, emailContext, accessRecord, req.user @@ -1080,19 +1096,19 @@ module.exports = { populate: { path: 'team', populate: { - path: 'users' - } + path: 'users', + }, }, }, { path: 'workflow.steps.reviewers', - select: 'firstname lastname', + select: 'firstname lastname id email', }, { - path: 'datasets dataset' + path: 'datasets dataset', }, { - path: 'mainApplicant' + path: 'mainApplicant', }, ]); if (!accessRecord) { @@ -1169,6 +1185,7 @@ module.exports = { } else { // 12. Gather context for notifications (active step) let emailContext = workflowController.getWorkflowEmailContext( + accessRecord, workflow, activeStepIndex ); @@ -1179,25 +1196,37 @@ module.exports = { accessRecord, req.user ); - // 14. Gather context for notifications (next step) - if (!bpmContext.finalPhaseApproved) { + // 14. Create emails and notifications + let relevantStepIndex = 0, + relevantNotificationType = ''; + if (bpmContext.finalPhaseApproved) { + // Create notifications to managers that the application is awaiting final approval + relevantStepIndex = activeStepIndex; + relevantNotificationType = notificationTypes.FINALDECISIONREQUIRED; + } else { + // Create notifications to reviewers of the next step that has been activated + relevantStepIndex = activeStepIndex + 1; + relevantNotificationType = notificationTypes.REVIEWSTEPSTART; + } + // Get the email context only if required + if(relevantStepIndex !== activeStepIndex) { emailContext = workflowController.getWorkflowEmailContext( + accessRecord, workflow, - activeStepIndex + 1 + relevantStepIndex ); } - // 15. Create notifications to reviewers of the next step if it was not the final step module.exports.createNotifications( - notificationTypes.REVIEWSTEPSTART, + relevantNotificationType, emailContext, accessRecord, req.user ); - // 16. Call Camunda controller to start manager review process + // 15. Call Camunda controller to start manager review process bpmController.postCompleteReview(bpmContext); } }); - // 17. Return aplication and successful response + // 16. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { console.log(err.message); @@ -1337,7 +1366,6 @@ module.exports = { // Instantiate default params let custodianManagers = [], emailRecipients = [], - stepReviewers = [], options = {}, html = '', authors = []; @@ -1363,6 +1391,9 @@ module.exports = { reviewerNames = '', reviewSections = '', nextStepName = '', + stepReviewers = [], + stepReviewerUserIds = [], + currentDeadline = '', } = context; switch (type) { @@ -1460,7 +1491,7 @@ module.exports = { // Retrieve all custodian user Ids to generate notifications custodianManagers = teamController.getTeamMembersByRole( accessRecord.datasets[0].publisher.team, - roleTypes.MANAGER + teamController.roleTypes.MANAGER ); let custodianUserIds = custodianManagers.map((user) => user.id); await notificationBuilder.triggerNotificationMessage( @@ -1603,197 +1634,169 @@ module.exports = { } break; case notificationTypes.STEPOVERRIDE: - if ( - _.has(accessRecord.toObject(), 'publisherObj.team.users') - ) { - // 1. Create reviewer notifications - let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); - await notificationBuilder.triggerNotificationMessage( - stepReviewerUserIds, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - 'data access request', - accessRecord._id - ); - - // 2. Create reviewer emails - options = { - id: accessRecord._id, - projectName, - projectId, - datasetTitles, - userName: `${appFirstName} ${appLastName}`, - actioner: `${firstname} ${lastname}`, - applicants, - workflowName, - stepName, - reviewSections, - reviewerNames, - nextStepName, - dateSubmitted - }; - html = await emailGenerator.generateStepOverrideEmail(options); - await emailGenerator.sendEmail( - stepReviewers, - hdrukEmail, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - html, - false - ); - } + // 1. Create reviewer notifications + notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `${firstname} ${lastname} has approved a Data Access Request application phase that you were assigned to review`, + 'data access request', + accessRecord._id + ); + // 2. Create reviewer emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + dateSubmitted, + ...context, + }; + html = emailGenerator.generateStepOverrideEmail(options); + emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `${firstname} ${lastname} has approved a Data Access Request application phase that you were assigned to review`, + html, + false + ); break; case notificationTypes.REVIEWSTEPSTART: - if ( - _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') - ) { - // 1. Create reviewer notifications - let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); - await notificationBuilder.triggerNotificationMessage( - stepReviewerUserIds, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - 'data access request', - accessRecord._id - ); - - // 2. Create reviewer emails - options = { - id: accessRecord._id, - projectName, - projectId, - datasetTitles, - userName: `${appFirstName} ${appLastName}`, - actioner: `${firstname} ${lastname}`, - applicants, - workflowName, - stepName, - reviewSections, - reviewerNames, - nextStepName, - }; - html = await emailGenerator.generateNewReviewPhaseEmail(options); - await emailGenerator.sendEmail( - stepReviewers, - hdrukEmail, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - html, - false - ); - } + // 1. Create reviewer notifications + notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `You are required to review a new Data Access Request application for ${publisher} by ${moment( + currentDeadline + ).format('D MMM YYYY HH:mm')}`, + 'data access request', + accessRecord._id + ); + // 2. Create reviewer emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + dateSubmitted, + ...context, + }; + html = emailGenerator.generateNewReviewPhaseEmail(options); + emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `You are required to review a new Data Access Request application for ${publisher} by ${moment( + currentDeadline + ).format('D MMM YYYY HH:mm')}`, + html, + false + ); break; case notificationTypes.FINALDECISIONREQUIRED: - if ( - _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') - ) { - // 1. Create reviewer notifications - let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); - await notificationBuilder.triggerNotificationMessage( - stepReviewerUserIds, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - 'data access request', - accessRecord._id - ); + // 1. Get managers for publisher + custodianManagers = teamController.getTeamMembersByRole( + accessRecord.publisherObj.team, + teamController.roleTypes.MANAGER + ); + let managerUserIds = custodianManagers.map((user) => user.id); - // 2. Create reviewer emails - options = { - id: accessRecord._id, - projectName, - projectId, - datasetTitles, - userName: `${appFirstName} ${appLastName}`, - actioner: `${firstname} ${lastname}`, - applicants, - workflowName, - stepName, - reviewSections, - reviewerNames, - nextStepName, - }; - html = await emailGenerator.generateFinalDecisionRequiredEmail( - options - ); - await emailGenerator.sendEmail( - stepReviewers, - hdrukEmail, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - html, - false - ); - } + // 1. Create manager notifications + notificationBuilder.triggerNotificationMessage( + managerUserIds, + `Action is required as a Data Access Request application for ${publisher} is now awaiting a final decision`, + 'data access request', + accessRecord._id + ); + // 2. Create manager emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + dateSubmitted, + ...context, + }; + html = emailGenerator.generateFinalDecisionRequiredEmail(options); + emailGenerator.sendEmail( + custodianManagers, + hdrukEmail, + `Action is required as a Data Access Request application for ${publisher} is now awaiting a final decision`, + html, + false + ); break; case notificationTypes.DEADLINEWARNING: - if ( - _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') - ) { - // 1. Create reviewer notifications - let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); - await notificationBuilder.triggerNotificationMessage( - stepReviewerUserIds, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - 'data access request', - accessRecord._id - ); - - // 2. Create reviewer emails - options = { - id: accessRecord._id, - projectName, - projectId, - datasetTitles, - userName: `${appFirstName} ${appLastName}`, - actioner: `${firstname} ${lastname}`, - applicants, - workflowName, - stepName, - reviewSections, - reviewerNames, - nextStepName, - }; - html = await emailGenerator.generateReviewDeadlineWarning(options); - await emailGenerator.sendEmail( - stepReviewers, - hdrukEmail, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - html, - false - ); - } + // 1. Get all reviewers who have not yet voted on active phase + // 2. Create reviewer notifications + await notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + 'data access request', + accessRecord._id + ); + // 3. Create reviewer emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + nextStepName, + }; + html = await emailGenerator.generateReviewDeadlineWarning(options); + await emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + html, + false + ); break; case notificationTypes.DEADLINEPASSED: - if ( - _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') - ) { - // 1. Create reviewer notifications - let stepReviewerUserIds = [...stepReviewers].map((user) => user.id); - await notificationBuilder.triggerNotificationMessage( - stepReviewerUserIds, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - 'data access request', - accessRecord._id - ); - - // 2. Create reviewer emails - options = { - id: accessRecord._id, - projectName, - projectId, - datasetTitles, - userName: `${appFirstName} ${appLastName}`, - actioner: `${firstname} ${lastname}`, - applicants, - workflowName, - stepName, - reviewSections, - reviewerNames, - nextStepName, - }; - html = await emailGenerator.generateReviewDeadlinePassed(options); - await emailGenerator.sendEmail( - stepReviewers, - hdrukEmail, - `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, - html, - false - ); - } + // 1. Get all reviewers who have not yet voted on active phase + // 2. Get all managers + // 3. Create notifications + await notificationBuilder.triggerNotificationMessage( + stepReviewerUserIds, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + 'data access request', + accessRecord._id + ); + // 4. Create emails + options = { + id: accessRecord._id, + projectName, + projectId, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + actioner: `${firstname} ${lastname}`, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + nextStepName, + }; + html = await emailGenerator.generateReviewDeadlinePassed(options); + await emailGenerator.sendEmail( + stepReviewers, + hdrukEmail, + `${firstname} ${lastname} has approved a Data Access Request phase you are reviewing`, + html, + false + ); } }, diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 9cda69ff..1da5c602 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -7,7 +7,7 @@ const sgMail = require('@sendgrid/mail'); let parent, qsId; let questionList = []; let excludedQuestionSetIds = ['addApplicant', 'removeApplicant']; -let autoCompleteLookups = {"fullname": ['email']}; +let autoCompleteLookups = { fullname: ['email'] }; /** * [_unNestQuestionPanels] @@ -17,30 +17,52 @@ let autoCompleteLookups = {"fullname": ['email']}; * @return {Array} [{panel}, {}] */ const _unNestQuestionPanels = (panels) => { - return [...panels].reduce((arr, panel) => { - // deconstruct questionPanel:[{panel}] - let {panelId, pageId, questionSets, questionPanelHeaderText, navHeader} = panel; - if(typeof questionSets !== 'undefined') { - if (questionSets.length > 1) { - // filters excluded questionSetIds - let filtered = [...questionSets].filter(item => { - let [questionId, uniqueId] = item.questionSetId.split('_'); - return !excludedQuestionSetIds.includes(questionId); - }); - // builds new array of [{panelId, pageId, etc}] - let newPanels = filtered.map((set) => { return { panelId, pageId, questionPanelHeaderText, navHeader, questionSetId: set.questionSetId }}); - // update the arr reducer result - arr = [...arr, ...newPanels]; - } else { - // deconstruct - let [{questionSetId}] = questionSets; - // update the arr reducer result - arr = [...arr, { panelId, pageId, questionSetId, questionPanelHeaderText, navHeader }]; - } - } - return arr; - }, []); - + return [...panels].reduce((arr, panel) => { + // deconstruct questionPanel:[{panel}] + let { + panelId, + pageId, + questionSets, + questionPanelHeaderText, + navHeader, + } = panel; + if (typeof questionSets !== 'undefined') { + if (questionSets.length > 1) { + // filters excluded questionSetIds + let filtered = [...questionSets].filter((item) => { + let [questionId, uniqueId] = item.questionSetId.split('_'); + return !excludedQuestionSetIds.includes(questionId); + }); + // builds new array of [{panelId, pageId, etc}] + let newPanels = filtered.map((set) => { + return { + panelId, + pageId, + questionPanelHeaderText, + navHeader, + questionSetId: set.questionSetId, + }; + }); + // update the arr reducer result + arr = [...arr, ...newPanels]; + } else { + // deconstruct + let [{ questionSetId }] = questionSets; + // update the arr reducer result + arr = [ + ...arr, + { + panelId, + pageId, + questionSetId, + questionPanelHeaderText, + navHeader, + }, + ]; + } + } + return arr; + }, []); }; /** @@ -51,51 +73,53 @@ const _unNestQuestionPanels = (panels) => { * @return {Array} [{question}, {}] */ const _initalQuestionSpread = (questions, pages, questionPanels) => { - let flatQuestionList = []; - if (!questions) return; - for (let questionSet of questions) { - - let { questionSetId, questionSetHeader } = questionSet; - - let [qSId, uniqueQsId] = questionSetId.split('_'); - - // question set full Id ie: applicant_hUad8 - let qsFullId = typeof uniqueQsId !== 'undefined' ? `${qSId}_${uniqueQsId}` : qSId; - // remove out unwanted buttons or elements - if (!excludedQuestionSetIds.includes(qSId) && questionSet.hasOwnProperty('questions')) { - - for (let question of questionSet.questions) { - //deconstruct quesitonId from question - let {questionId} = question; - - // split questionId - let [qId, uniqueQId] = questionId.split('_'); - - // pass in questionPanels - let questionPanel = [...questionPanels].find( - (i) => i.panelId === qSId - ); - // find page it belongs too - let page = [...pages].find((i) => i.pageId === questionPanel.pageId); - - // if page not found skip and the questionId isnt excluded - if (typeof page !== 'undefined' && !excludedQuestionSetIds.includes(qId)) { - - // if it is a generated field ie ui driven add back on uniqueId - let obj = { - page: page.title, - section: questionPanel.navHeader, - questionSetId: qsFullId, - questionSetHeader, - ...question, - }; - // update flatQuestionList array, spread previous add new object - flatQuestionList = [...flatQuestionList, obj]; - } - } - } - } - return flatQuestionList; + let flatQuestionList = []; + if (!questions) return; + for (let questionSet of questions) { + let { questionSetId, questionSetHeader } = questionSet; + + let [qSId, uniqueQsId] = questionSetId.split('_'); + + // question set full Id ie: applicant_hUad8 + let qsFullId = + typeof uniqueQsId !== 'undefined' ? `${qSId}_${uniqueQsId}` : qSId; + // remove out unwanted buttons or elements + if ( + !excludedQuestionSetIds.includes(qSId) && + questionSet.hasOwnProperty('questions') + ) { + for (let question of questionSet.questions) { + //deconstruct quesitonId from question + let { questionId } = question; + + // split questionId + let [qId, uniqueQId] = questionId.split('_'); + + // pass in questionPanels + let questionPanel = [...questionPanels].find((i) => i.panelId === qSId); + // find page it belongs too + let page = [...pages].find((i) => i.pageId === questionPanel.pageId); + + // if page not found skip and the questionId isnt excluded + if ( + typeof page !== 'undefined' && + !excludedQuestionSetIds.includes(qId) + ) { + // if it is a generated field ie ui driven add back on uniqueId + let obj = { + page: page.title, + section: questionPanel.navHeader, + questionSetId: qsFullId, + questionSetHeader, + ...question, + }; + // update flatQuestionList array, spread previous add new object + flatQuestionList = [...flatQuestionList, obj]; + } + } + } + } + return flatQuestionList; }; /** @@ -104,68 +128,75 @@ const _initalQuestionSpread = (questions, pages, questionPanels) => { * @return {Array} [{questionId, question}] */ const _getAllQuestionsFlattened = (allQuestions) => { - let child; - if (!allQuestions) return; - - for (let questionObj of allQuestions) { - if (questionObj.hasOwnProperty('questionId')) { - if ( - questionObj.hasOwnProperty('page') && - questionObj.hasOwnProperty('section') - ) { - let { page, section, questionSetId, questionSetHeader } = questionObj; - if(typeof questionSetId !== 'undefined') - qsId = questionSetId - // set the parent page and parent section as nested wont have reference to its parent - parent = { page, section, questionSetId: qsId, questionSetHeader }; - } - let { questionId, question} = questionObj; - // split up questionId - let [qId, uniqueId] = questionId.split('_'); - // actual quesitonId - let questionTitle = typeof uniqueId !== 'undefined' ? `${qId}_${uniqueId}` : qId; - // if not in exclude list - if(!excludedQuestionSetIds.includes(questionTitle)) { - questionList = [ - ...questionList, - { questionId: questionTitle, question, questionSetHeader: parent.questionSetHeader, questionSetId: qsId, page: parent.page, section: parent.section }, - ]; - } - } - - if ( - typeof questionObj.input === 'object' && - typeof questionObj.input.options !== 'undefined' - ) { - questionObj.input.options - .filter((option) => { - return ( - typeof option.conditionalQuestions !== 'undefined' && - option.conditionalQuestions.length > 0 - ); - }) - .forEach((option) => { - child = _getAllQuestionsFlattened(option.conditionalQuestions); - }); - } - - if (child) { - return child; - } - } + let child; + if (!allQuestions) return; + + for (let questionObj of allQuestions) { + if (questionObj.hasOwnProperty('questionId')) { + if ( + questionObj.hasOwnProperty('page') && + questionObj.hasOwnProperty('section') + ) { + let { page, section, questionSetId, questionSetHeader } = questionObj; + if (typeof questionSetId !== 'undefined') qsId = questionSetId; + // set the parent page and parent section as nested wont have reference to its parent + parent = { page, section, questionSetId: qsId, questionSetHeader }; + } + let { questionId, question } = questionObj; + // split up questionId + let [qId, uniqueId] = questionId.split('_'); + // actual quesitonId + let questionTitle = + typeof uniqueId !== 'undefined' ? `${qId}_${uniqueId}` : qId; + // if not in exclude list + if (!excludedQuestionSetIds.includes(questionTitle)) { + questionList = [ + ...questionList, + { + questionId: questionTitle, + question, + questionSetHeader: parent.questionSetHeader, + questionSetId: qsId, + page: parent.page, + section: parent.section, + }, + ]; + } + } + + if ( + typeof questionObj.input === 'object' && + typeof questionObj.input.options !== 'undefined' + ) { + questionObj.input.options + .filter((option) => { + return ( + typeof option.conditionalQuestions !== 'undefined' && + option.conditionalQuestions.length > 0 + ); + }) + .forEach((option) => { + child = _getAllQuestionsFlattened(option.conditionalQuestions); + }); + } + + if (child) { + return child; + } + } }; const _formatSectionTitle = (value) => { - let [questionId] = value.split('_'); - return _.capitalize(questionId); -} + let [questionId] = value.split('_'); + return _.capitalize(questionId); +}; const _buildSubjectTitle = (user, title) => { - if (user.toUpperCase() === 'DATACUSTODIAN') { - return `Someone has submitted an application to access ${title} dataset. Please let the applicant know as soon as there is progress in the review of their submission.`; - } else { - return `You have requested access to ${title}. The custodian will be in contact about the application.`; - } + if (user.toUpperCase() === 'DATACUSTODIAN') { + return `Someone has submitted an application to access ${title} dataset. Please let the applicant know as soon as there is progress in the review of their submission.`; + } else { + return `You have requested access to ${title}. The custodian will be in contact about the application.`; + } }; /** @@ -178,13 +209,13 @@ const _buildSubjectTitle = (user, title) => { * @return {String} Questions Answered */ const _buildEmail = (fullQuestions, questionAnswers, options) => { - let parent; - let { userType, userName, userEmail, datasetTitles } = options; - let subject = _buildSubjectTitle(userType, datasetTitles); - let questionTree = { ...fullQuestions }; - let answers = { ...questionAnswers }; - let pages = Object.keys(questionTree); - let table = `
+ let parent; + let { userType, userName, userEmail, datasetTitles } = options; + let subject = _buildSubjectTitle(userType, datasetTitles); + let questionTree = { ...fullQuestions }; + let answers = { ...questionAnswers }; + let pages = Object.keys(questionTree); + let table = `
{ + 'D MMM YYYY HH:mm' + )} - +
Date of submission ${moment().format( - 'D MMM YYYY HH:mm' - )}
Applicant${userName}, ${_displayCorrectEmailAddress(userEmail, userType)}${userName}, ${_displayCorrectEmailAddress( + userEmail, + userType + )}
`; - let pageCount = 0; - // render page [Safe People, SafeProject] - for (let page of pages) { - // page count for styling - pageCount++; - // {SafePeople: { Applicant:[], ...}} - parent = questionTree[page]; - table += ` + let pageCount = 0; + // render page [Safe People, SafeProject] + for (let page of pages) { + // page count for styling + pageCount++; + // {SafePeople: { Applicant:[], ...}} + parent = questionTree[page]; + table += ` @@ -242,204 +276,228 @@ const _buildEmail = (fullQuestions, questionAnswers, options) => {

${page}

`; - - - // Safe People = [Applicant, Principle Investigator, ...] - // Safe People to order array for applicant - let sectionKeys; - if(page.toUpperCase() === 'SAFE PEOPLE') - sectionKeys = Object.keys({...parent}).sort(); - else - sectionKeys = Object.keys({...parent}); - - // styling for last child - let sectionCount = 0; - // render section - for (let section of sectionKeys) { - let questionsArr = questionTree[page][section]; - let [questionObj] = questionsArr; - let sectionTitle = _formatSectionTitle(questionObj.questionSetHeader); - sectionCount++; - table += ` + + // Safe People = [Applicant, Principle Investigator, ...] + // Safe People to order array for applicant + let sectionKeys; + if (page.toUpperCase() === 'SAFE PEOPLE') + sectionKeys = Object.keys({ ...parent }).sort(); + else sectionKeys = Object.keys({ ...parent }); + + // styling for last child + let sectionCount = 0; + // render section + for (let section of sectionKeys) { + let questionsArr = questionTree[page][section]; + let [questionObj] = questionsArr; + let sectionTitle = _formatSectionTitle(questionObj.questionSetHeader); + sectionCount++; + table += ` + sectionCount !== 1 ? '25px 0 0 0;' : '10px 0 0 0;' + }">${sectionTitle}`; - // render question - for (let question of questionsArr) { - let answer = answers[question.questionId] || `-`; - table += ` + // render question + for (let question of questionsArr) { + let answer = answers[question.questionId] || `-`; + table += ``; - } - } - table += `

${sectionTitle}

${question.question} ${answer}
`; - } - table += `
`; + } + } + table += ``; + } + table += ` `; - return table; + return table; }; /** * [_groupByPageSection] - * + * * @desc This function will group all the questions into the correct format for emailBuilder * @return {Object} {Safe People: {Applicant: [], Applicant_U8ad: []}, Safe Project: {}} */ const _groupByPageSection = (allQuestions) => { - // group by page [Safe People, Safe Project] - let groupedByPage = _.groupBy(allQuestions, (item) => { - return item.page; - }); - - // within grouped [Safe People: {Applicant, Applicant1, Something}] - let grouped = _.forEach(groupedByPage, (value, key) => { - groupedByPage[key] = _.groupBy(groupedByPage[key], (item) => { - return item.questionSetId; - }); - }); - - return grouped; + // group by page [Safe People, Safe Project] + let groupedByPage = _.groupBy(allQuestions, (item) => { + return item.page; + }); + + // within grouped [Safe People: {Applicant, Applicant1, Something}] + let grouped = _.forEach(groupedByPage, (value, key) => { + groupedByPage[key] = _.groupBy(groupedByPage[key], (item) => { + return item.questionSetId; + }); + }); + + return grouped; }; /** * [_actualQuestionAnswers] - * + * * @desc This function will repopulate any fiels populated by autoFill answers - * @param {Object} questionAnswers {fullname: '', ...} - * @param {Object} options {userType, ...} + * @param {Object} questionAnswers {fullname: '', ...} + * @param {Object} options {userType, ...} * @return {Object} {fullname: 'James Swallow', email: 'james@gmail.com'} */ const _actualQuestionAnswers = async (questionAnswers, options) => { - let obj = {}; - // test for user type custodian || user - let { userType } = options; - // spread questionAnswers to new var - let qa = {...questionAnswers}; - // get object keys of questionAnswers - let keys = Object.keys(qa); - // loop questionAnswer keys - for (const key of keys) { - // get value of key - let value = qa[key]; - // split the key up for unique purposes - let [qId, uniqueId] = key.split('_'); - // check if key in lookup - let lookup = autoCompleteLookups[`${qId}`]; - // if key exists and it has an object do relevant data setting - if(typeof lookup !== 'undefined' && typeof value === 'object') { - switch(qId) { - case 'fullname': - // get user by :id {fullname, email} - const response = await _getUserDetails(value); - // deconstruct response - let {fullname, email} = response; - // set fullname: 'James Swallow' - obj[key] = fullname; - // show full email for custodian or redacted for non custodians - let validEmail = _displayCorrectEmailAddress(email, userType); - // check if uniqueId and set email field - typeof uniqueId !== 'undefined' ? obj[`email_${uniqueId}`] = validEmail : obj[`email`] = validEmail; - break; - default: - obj[key] = value; - } - } - } - // return out the update values write over questionAnswers; - return {...qa, ...obj}; -} + let obj = {}; + // test for user type custodian || user + let { userType } = options; + // spread questionAnswers to new var + let qa = { ...questionAnswers }; + // get object keys of questionAnswers + let keys = Object.keys(qa); + // loop questionAnswer keys + for (const key of keys) { + // get value of key + let value = qa[key]; + // split the key up for unique purposes + let [qId, uniqueId] = key.split('_'); + // check if key in lookup + let lookup = autoCompleteLookups[`${qId}`]; + // if key exists and it has an object do relevant data setting + if (typeof lookup !== 'undefined' && typeof value === 'object') { + switch (qId) { + case 'fullname': + // get user by :id {fullname, email} + const response = await _getUserDetails(value); + // deconstruct response + let { fullname, email } = response; + // set fullname: 'James Swallow' + obj[key] = fullname; + // show full email for custodian or redacted for non custodians + let validEmail = _displayCorrectEmailAddress(email, userType); + // check if uniqueId and set email field + typeof uniqueId !== 'undefined' + ? (obj[`email_${uniqueId}`] = validEmail) + : (obj[`email`] = validEmail); + break; + default: + obj[key] = value; + } + } + } + // return out the update values write over questionAnswers; + return { ...qa, ...obj }; +}; /** * [_displayCorrectEmailAddress] - * + * * @desc This function will return a obfuscated email based on user role * @param {String} 'your@gmail.com' * @param {String} 'dataCustodian' * @return {String} 'r********@**********m' */ -const _displayCorrectEmailAddress = (email, userType) => { - return userType.toUpperCase() === 'DATACUSTODIAN' ? email : helper.censorEmail(email); -} +const _displayCorrectEmailAddress = (email, userType) => { + return userType.toUpperCase() === 'DATACUSTODIAN' + ? email + : helper.censorEmail(email); +}; /** * [_getUserDetails] - * + * * @desc This function will return the user infromation from mongodb * @param {Int} 98767876 * @return {Object} {fullname: 'James Swallow', email: 'james@gmail.com'} */ const _getUserDetails = async (userObj) => { - return new Promise(async (resolve, reject) => { - try { - let {id} = userObj; - const doc = await UserModel.findOne({id}).exec(); - let { firstname = '', lastname = '', email = '' } = doc; - resolve({fullname: `${firstname} ${lastname}`, email}); - } - catch (err) { - reject({fullname: '', email: ''}); - } - }); + return new Promise(async (resolve, reject) => { + try { + let { id } = userObj; + const doc = await UserModel.findOne({ id }).exec(); + let { firstname = '', lastname = '', email = '' } = doc; + resolve({ fullname: `${firstname} ${lastname}`, email }); + } catch (err) { + reject({ fullname: '', email: '' }); + } + }); }; const _generateEmail = async ( - questions, - pages, - questionPanels, - questionAnswers, - options + questions, + pages, + questionPanels, + questionAnswers, + options ) => { - // reset questionList arr - questionList = []; - // set questionAnswers - let flatQuestionAnswers = await _actualQuestionAnswers(questionAnswers, options) - // unnest each questionPanel if questionSets - let flatQuestionPanels = _unNestQuestionPanels(questionPanels); - // unnest question flat - let unNestedQuestions = _initalQuestionSpread(questions, pages, flatQuestionPanels); - // assigns to questionList - let fullQuestionSet = _getAllQuestionsFlattened(unNestedQuestions); - // fullQuestions [SafePeople: {Applicant: {}, Applicant_aca: {}}, SafeProject:{}] - let fullQuestions = _groupByPageSection([...questionList]); - // build up email with values - let email = _buildEmail(fullQuestions, flatQuestionAnswers, options); - // return email - return email; + // reset questionList arr + questionList = []; + // set questionAnswers + let flatQuestionAnswers = await _actualQuestionAnswers( + questionAnswers, + options + ); + // unnest each questionPanel if questionSets + let flatQuestionPanels = _unNestQuestionPanels(questionPanels); + // unnest question flat + let unNestedQuestions = _initalQuestionSpread( + questions, + pages, + flatQuestionPanels + ); + // assigns to questionList + let fullQuestionSet = _getAllQuestionsFlattened(unNestedQuestions); + // fullQuestions [SafePeople: {Applicant: {}, Applicant_aca: {}}, SafeProject:{}] + let fullQuestions = _groupByPageSection([...questionList]); + // build up email with values + let email = _buildEmail(fullQuestions, flatQuestionAnswers, options); + // return email + return email; }; -const _displayConditionalStatusDesc = (applicationStatus, applicationStatusDesc) => { - if(applicationStatusDesc && applicationStatus === 'approved with conditions' || applicationStatus === 'rejected') { - let conditionalTitle = ''; - switch(applicationStatus) { - case 'approved with conditions': - conditionalTitle = 'Approved with conditions:'; - break; - case 'rejected': - conditionalTitle = 'Reason for rejection:'; - break; - } - return ` +const _displayConditionalStatusDesc = ( + applicationStatus, + applicationStatusDesc +) => { + if ( + (applicationStatusDesc && + applicationStatus === 'approved with conditions') || + applicationStatus === 'rejected' + ) { + let conditionalTitle = ''; + switch (applicationStatus) { + case 'approved with conditions': + conditionalTitle = 'Approved with conditions:'; + break; + case 'rejected': + conditionalTitle = 'Reason for rejection:'; + break; + } + return `

${conditionalTitle}

${applicationStatusDesc}

- ` - } - return ''; + `; + } + return ''; }; const _displayDARLink = (accessId) => { - if(!accessId) - return ''; + if (!accessId) return ''; - let darLink = `${process.env.homeURL}/data-access-request/${accessId}`; - return `View application`; + let darLink = `${process.env.homeURL}/data-access-request/${accessId}`; + return `View application`; }; const _generateDARStatusChangedEmail = (options) => { - let { id, applicationStatus, applicationStatusDesc, projectId, projectName, publisher, datasetTitles, dateSubmitted, applicants } = options; - let body = `
+ let { + id, + applicationStatus, + applicationStatusDesc, + projectId, + projectName, + publisher, + datasetTitles, + dateSubmitted, + applicants, + } = options; + let body = `
{
- + - + @@ -481,7 +543,9 @@ const _generateDARStatusChangedEmail = (options) => { - +
Project${projectName || 'No project name set'}${ + projectName || 'No project name set' + }
Project ID${projectId || id}${ + projectId || id + }
Dataset(s)
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}${moment( + dateSubmitted + ).format('D MMM YYYY HH:mm')}
@@ -489,19 +553,38 @@ const _generateDARStatusChangedEmail = (options) => {
- ${_displayConditionalStatusDesc(applicationStatus, applicationStatusDesc)} + ${_displayConditionalStatusDesc( + applicationStatus, + applicationStatusDesc + )} ${_displayDARLink(id)}
`; - return body; + return body; }; const _generateContributorEmail = (options) => { - let { id, datasetTitles, projectName, projectId, change, actioner, applicants } = options; - let header = `You've been ${change === 'added' ? 'added to' : 'removed from'} a data access request application`; - let subheader = `${actioner} ${change} you as a contributor ${change === 'added' ? 'to' : 'from'} a data access request application. ${change == 'added' ? 'Contributors can exchange private notes, make edits, invite others and submit the application.' : ''}`; + let { + id, + datasetTitles, + projectName, + projectId, + change, + actioner, + applicants, + } = options; + let header = `You've been ${ + change === 'added' ? 'added to' : 'removed from' + } a data access request application`; + let subheader = `${actioner} ${change} you as a contributor ${ + change === 'added' ? 'to' : 'from' + } a data access request application. ${ + change == 'added' + ? 'Contributors can exchange private notes, make edits, invite others and submit the application.' + : '' + }`; - let body = `
+ let body = `
{
- + - + @@ -546,92 +633,161 @@ const _generateContributorEmail = (options) => {
Project${projectName || 'No project name set'}${ + projectName || 'No project name set' + }
Project ID${projectId || id}${ + projectId || id + }
Dataset(s)
- ${change === 'added' ? ` + ${ + change === 'added' + ? `
${_displayDARLink(id)} -
` : ''} +
` + : '' + }
`; - return body; + return body; }; const _generateStepOverrideEmail = (options) => { - let { id, projectName, projectId, datasetTitles, actioner, applicants, workflowName, stepName, nextStepName, reviewSections, reviewerNames, dateSubmitted } = options; - let body = `
- - - - - - - - - - - - - + let { + id, + projectName, + projectId, + datasetTitles, + actioner, + applicants, + workflowName, + stepName, + nextStepName, + nextReviewSections, + nextReviewerNames, + nextDeadline, + reviewSections, + reviewerNames, + dateSubmitted, + startDateTime, + endDateTime, + duration + } = options; + let body = `
+
- Data access request application review phase completed -
- ${actioner} has manually completed the review phase '${stepName}' for the following data access request application. -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Review phase completed${workflowName} - ${stepName}
Review sections${reviewSections}
Reviewers${reviewerNames}
Next review phase${workflowName} - ${nextStepName}
-
+ + + + + + + + + + +
Data access request application review phase completed
${actioner} has manually completed the review phase '${stepName}' for the following data access request application.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
Application details
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Workflow${workflowName}
  
Completed review phase
Phase name${stepName}
Phase commenced${moment(startDateTime).format('D MMM YYYY HH:mm')}
Phase completed${moment(endDateTime).format('D MMM YYYY HH:mm')}
Phase duration${duration}
Review sections${reviewSections}
Reviewers${reviewerNames}
 
Next review phase
Phase name${nextStepName}
Review sections${nextReviewSections}
Reviewers ${nextReviewerNames}
Deadline${moment(nextDeadline).format('D MMM YYYY')}
-
- ${_displayDARLink(id)} -
- `; - return body; +
+ + + + +
${_displayDARLink(id)}
+
`; + return body; }; const _generateNewReviewPhaseEmail = (options) => { - let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames, dateSubmitted } = options; - let body = `
+ let { + id, + projectName, + projectId, + datasetTitles, + applicants, + workflowName, + stepName, + currentDeadline, + reviewSections, + reviewerNames, + dateSubmitted + } = options; + let body = `
{ @@ -700,12 +875,23 @@ const _generateNewReviewPhaseEmail = (options) => { ${_displayDARLink(id)} `; - return body; + return body; }; const _generateReviewDeadlineWarning = (options) => { - let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames, dateSubmitted } = options; - let body = `
+ let { + id, + projectName, + projectId, + datasetTitles, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + dateSubmitted, + } = options; + let body = `
+ + + - + - + @@ -673,23 +836,35 @@ const _generateNewReviewPhaseEmail = (options) => { - + - - + + - - + - - + - - + + + + + + + + + + + + + +
Application details
Project${projectName || 'No project name set'}${ + projectName || 'No project name set' + }
Project ID${projectId || id}${ + projectId || id + }
Dataset(s)
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}${moment( + dateSubmitted + ).format('D MMM YYYY HH:mm')}
Review phase${workflowName} - ${stepName}Workflow${workflowName}
Review sections${reviewSections} 
Reviewers${reviewerNames}Current review phase
Deadline${moment(dateDeadline).format('D MMM YYYY HH:mm')}Phase name${stepName}
Review sections${reviewSections}
Reviewers ${reviewerNames}
Deadline${moment(currentDeadline).format('D MMM YYYY')}
{
- + - + @@ -747,7 +937,9 @@ const _generateReviewDeadlineWarning = (options) => { - + @@ -763,7 +955,9 @@ const _generateReviewDeadlineWarning = (options) => { - +
Project${projectName || 'No project name set'}${ + projectName || 'No project name set' + }
Project ID${projectId || id}${ + projectId || id + }
Dataset(s)
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}${moment( + dateSubmitted + ).format('D MMM YYYY HH:mm')}
Review phase
Deadline${moment(dateDeadline).format('D MMM YYYY HH:mm')}${moment( + dateDeadline + ).format('D MMM YYYY HH:mm')}
@@ -774,12 +968,23 @@ const _generateReviewDeadlineWarning = (options) => { ${_displayDARLink(id)}
`; - return body; + return body; }; const _generateReviewDeadlinePassed = (options) => { - let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames, dateSubmitted } = options; - let body = `
+ let { + id, + projectName, + projectId, + datasetTitles, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + dateSubmitted, + } = options; + let body = `
{
- + - + @@ -821,7 +1030,9 @@ const _generateReviewDeadlinePassed = (options) => { - + @@ -837,7 +1048,9 @@ const _generateReviewDeadlinePassed = (options) => { - +
Project${projectName || 'No project name set'}${ + projectName || 'No project name set' + }
Project ID${projectId || id}${ + projectId || id + }
Dataset(s)
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}${moment( + dateSubmitted + ).format('D MMM YYYY HH:mm')}
Review phase
Deadline${moment(dateDeadline).format('D MMM YYYY HH:mm')}${moment( + dateDeadline + ).format('D MMM YYYY HH:mm')}
@@ -848,82 +1061,121 @@ const _generateReviewDeadlinePassed = (options) => { ${_displayDARLink(id)}
`; - return body; + return body; }; const _generateFinalDecisionRequiredEmail = (options) => { - let { id, projectName, projectId, datasetTitles, applicants, workflowName, stepName, reviewSections, reviewerNames, dateSubmitted } = options; - let body = `
- - - - - - - - - - - - - + let { + id, + projectName, + projectId, + datasetTitles, + actioner, + applicants, + workflowName, + stepName, + reviewSections, + reviewerNames, + dateSubmitted, + startDateTime, + endDateTime, + duration, + totalDuration + } = options; + let body = `
+
- Data access request application is now awaiting final approval -
- The final phase ${stepName} of workflow ${workflowName} has now been completed for the following data access request application. -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Review phase completed${workflowName} - ${stepName}
Review sections${reviewSections}
Reviewers${reviewerNames}
Deadline${moment(dateDeadline).format('D MMM YYYY HH:mm')}
-
+ + + + + + + + + + +
Data access request application is now awaiting final approval
The final phase ${stepName} of workflow ${workflowName} has now been completed for the following data access request application.
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + -
Application details
Project${projectName || 'No project name set'}
Project ID${projectId || id}
Dataset(s)${datasetTitles}
Applicants${applicants}
Submitted${moment(dateSubmitted).format('D MMM YYYY HH:mm')}
Workflow${workflowName}
  
Completed review phase
Phase name${stepName}
Phase commenced${moment(startDateTime).format('D MMM YYYY HH:mm')}
Phase completed${moment(endDateTime).format('D MMM YYYY HH:mm')}
Phase duration${duration}
Review sections${reviewSections}
Reviewers${reviewerNames}
  
Workflow details
Duration${totalDuration}
-
- ${_displayDARLink(id)} -
- `; - return body; -} +
+ + + + +
${_displayDARLink(id)}
+
`; + return body; +}; /** * [_sendEmail] @@ -932,47 +1184,49 @@ const _generateFinalDecisionRequiredEmail = (options) => { * @param {Object} context */ const _sendEmail = async (to, from, subject, html, allowUnsubscribe = true) => { - // 1. Apply SendGrid API key from environment variable - sgMail.setApiKey(process.env.SENDGRID_API_KEY); - - // 2. Ensure any duplicates recieve only a single email - const recipients = [...new Map(to.map(item => [item['email'], item])).values()] - - // 3. Build each email object for SendGrid extracting email addresses from user object with unique unsubscribe link (to) - for (let recipient of recipients) { - let body = html + _generateEmailFooter(recipient, allowUnsubscribe); - let msg = { - to: recipient.email, - from: from, - subject: subject, - html: body, - }; - - // 4. Send email using SendGrid - await sgMail.send(msg); - } + // 1. Apply SendGrid API key from environment variable + sgMail.setApiKey(process.env.SENDGRID_API_KEY); + + // 2. Ensure any duplicates recieve only a single email + const recipients = [ + ...new Map(to.map((item) => [item['email'], item])).values(), + ]; + + // 3. Build each email object for SendGrid extracting email addresses from user object with unique unsubscribe link (to) + for (let recipient of recipients) { + let body = html + _generateEmailFooter(recipient, allowUnsubscribe); + let msg = { + to: recipient.email, + from: from, + subject: subject, + html: body, + }; + + // 4. Send email using SendGrid + await sgMail.send(msg); + } }; -const _generateEmailFooter = (recipient, allowUnsubscribe) => { - // 1. Generate HTML for unsubscribe link if allowed depending on context +const _generateEmailFooter = (recipient, allowUnsubscribe) => { + // 1. Generate HTML for unsubscribe link if allowed depending on context - let unsubscribeHTML = ''; + let unsubscribeHTML = ''; - if (allowUnsubscribe) { - const baseURL = process.env.homeURL; - const unsubscribeRoute = '/account/unsubscribe/'; - let userObjectId = recipient._id; - let unsubscribeLink = baseURL + unsubscribeRoute + userObjectId; - unsubscribeHTML = ` + if (allowUnsubscribe) { + const baseURL = process.env.homeURL; + const unsubscribeRoute = '/account/unsubscribe/'; + let userObjectId = recipient._id; + let unsubscribeLink = baseURL + unsubscribeRoute + userObjectId; + unsubscribeHTML = `

You're receiving this message because you have an account in the Innovation Gateway.

Unsubscribe if you want to stop receiving these.

`; - } + } - // 2. Generate generic HTML email footer - return `
+ // 2. Generate generic HTML email footer + return `
{ }; export default { - generateEmail: _generateEmail, - generateDARStatusChangedEmail: _generateDARStatusChangedEmail, - generateContributorEmail: _generateContributorEmail, - generateStepOverrideEmail: _generateStepOverrideEmail, - generateNewReviewPhaseEmail: _generateNewReviewPhaseEmail, - generateReviewDeadlineWarning: _generateReviewDeadlineWarning, - generateReviewDeadlinePassed: _generateReviewDeadlinePassed, - generateFinalDecisionRequiredEmail: _generateFinalDecisionRequiredEmail, - sendEmail: _sendEmail, - generateEmailFooter: _generateEmailFooter + generateEmail: _generateEmail, + generateDARStatusChangedEmail: _generateDARStatusChangedEmail, + generateContributorEmail: _generateContributorEmail, + generateStepOverrideEmail: _generateStepOverrideEmail, + generateNewReviewPhaseEmail: _generateNewReviewPhaseEmail, + generateReviewDeadlineWarning: _generateReviewDeadlineWarning, + generateReviewDeadlinePassed: _generateReviewDeadlinePassed, + generateFinalDecisionRequiredEmail: _generateFinalDecisionRequiredEmail, + sendEmail: _sendEmail, + generateEmailFooter: _generateEmailFooter, }; diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index d2026466..0bade2d0 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -593,20 +593,42 @@ const teamController = require('../team/team.controller'); return { inReviewMode, reviewSections, hasRecommended }; }; - const getWorkflowEmailContext = (workflow, relatedStepIndex) => { + const getWorkflowEmailContext = (accessRecord, workflow, relatedStepIndex) => { + const { dateReviewStart = '' } = accessRecord; const { workflowName, steps } = workflow; - const { stepName } = steps[relatedStepIndex]; + const { stepName, startDateTime = '', endDateTime = '' } = steps[relatedStepIndex]; const stepReviewers = getStepReviewers(steps[relatedStepIndex]); const reviewerNames = [...stepReviewers].map((reviewer) => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); const reviewSections = [...steps[relatedStepIndex].sections].map((section) => helper.darPanelMapper[section]).join(', '); - let nextStepName = ''; - //Find name of next step if this is not the final step - if(relatedStepIndex + 1 > steps.length) { + const stepReviewerUserIds = [...stepReviewers].map((user) => user.id); + const { deadline: stepDeadline = 0 } = steps[relatedStepIndex]; + const currentDeadline = stepDeadline === 0 ? 'No deadline specified' : moment().add(stepDeadline, 'days'); + let nextStepName = '', nextReviewerNames = '', nextReviewSections = '', duration = '', totalDuration = '', nextDeadline = ''; + + // Calculate duration for step if it is completed + if(!_.isEmpty(startDateTime.toString()) && !_.isEmpty(endDateTime.toString())) { + duration = moment(endDateTime).diff(moment(startDateTime), 'days'); + duration = duration === 0 ? `Same day` : duration === 1 ? `1 day` : `${duration} days`; + } + + if(relatedStepIndex + 1 === steps.length) { + // If workflow completed nextStepName = 'No next step'; + // Calculate total duration for workflow + if(steps[relatedStepIndex].completed && !_.isEmpty(dateReviewStart.toString())){ + totalDuration = moment().diff(moment(dateReviewStart), 'days'); + totalDuration = totalDuration === 0 ? `Same day` : duration === 1 ? `1 day` : `${duration} days`; + } } else { + // Get details of next step if this is not the final step ({ stepName: nextStepName } = steps[relatedStepIndex + 1]); + let nextStepReviewers = getStepReviewers(steps[relatedStepIndex + 1]); + nextReviewerNames = [...nextStepReviewers].map((reviewer) => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + nextReviewSections = [...steps[relatedStepIndex + 1].sections].map((section) => helper.darPanelMapper[section]).join(', '); + let { deadline = 0 } = steps[relatedStepIndex + 1]; + nextDeadline = deadline === 0 ? 'No deadline specified' : moment().add(deadline, 'days'); } - return { workflowName, stepName, reviewerNames, reviewSections, nextStepName }; + return { workflowName, stepName, startDateTime, endDateTime, stepReviewers, duration, totalDuration, reviewerNames, stepReviewerUserIds, reviewSections, currentDeadline, nextStepName, nextReviewerNames, nextReviewSections, nextDeadline }; }; export default { From e7bf8f49f4a04f82e54d1a9cb183a903d043d563 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 22 Oct 2020 22:42:57 +0100 Subject: [PATCH 117/144] Continued email build --- .../datarequest/datarequest.controller.js | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index badf5c71..dca7d3bb 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1332,11 +1332,49 @@ module.exports = { //POST api/v1/data-access-request/:id/notify notifyAccessRequestById: async (req, res) => { - // 1. Get workflow etc. - // 12. Gather context for notifications - //const emailContext = workflowController.getWorkflowEmailContext(workflow, activeStepIndex); - // 13. Create notifications to reviewers of the step that has been completed - //module.exports.createNotifications(notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user); + // 1. Get the required request params + const { + params: { id }, + } = req; + // 2. Retrieve DAR from database + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users', + }, + }, + }, + { + path: 'workflow.steps.reviewers', + select: 'firstname lastname id email', + }, + { + path: 'datasets dataset', + }, + { + path: 'mainApplicant', + }, + ]); + if (!accessRecord) { + return res + .status(404) + .json({ status: 'error', message: 'Application not found.' }); + } + let { workflow } = accessRecord; + let activeStepIndex = workflow.steps.findIndex((step) => { + return step.active === true; + }); + // 3. Determine email context if deadline has elapsed or is approaching + const emailContext = workflowController.getWorkflowEmailContext(accessRecord, workflow, activeStepIndex); + // 4. Send emails based on deadline elapsed or approaching + if(emailContext.deadlineElapsed) { + module.exports.createNotifications(notificationTypes.DEADLINEPASSED, emailContext, accessRecord, req.user); + } else { + module.exports.createNotifications(notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user); + } return res.status(200).json({ status: 'success' }); }, @@ -1397,7 +1435,6 @@ module.exports = { } = context; switch (type) { - // DAR application status has been updated case notificationTypes.STATUSCHANGE: // 1. Create notifications // Custodian manager and current step reviewer notifications From eb798c137b956a7cdc4f2eb5a0f7e3be5f47de0c Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Fri, 23 Oct 2020 09:25:51 +0100 Subject: [PATCH 118/144] Updates to course and search APIs --- src/resources/course/course.repository.js | 17 +++++------- src/resources/course/course.route.js | 33 +++++++++++------------ src/resources/search/search.repository.js | 21 ++++++++++----- src/resources/search/search.router.js | 19 ++++++++++--- 4 files changed, 51 insertions(+), 39 deletions(-) diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index 0893a6f8..ad559175 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -130,7 +130,7 @@ const addCourse = async (req, res) => { -const editTool = async (req, res) => { +const editCourse = async (req, res) => { return new Promise(async(resolve, reject) => { const toolCreator = req.body.toolCreator; @@ -195,7 +195,7 @@ const editTool = async (req, res) => { }) }; - const deleteTool = async(req, res) => { + const deleteCourse = async(req, res) => { return new Promise(async(resolve, reject) => { const { id } = req.params.id; Course.findOneAndDelete({ id: req.params.id }, (err) => { @@ -213,7 +213,7 @@ const editTool = async (req, res) => { ) })}; - const getToolsAdmin = async (req, res) => { + const getCourseAdmin = async (req, res) => { return new Promise(async (resolve, reject) => { let startIndex = 0; @@ -227,14 +227,11 @@ const editTool = async (req, res) => { if (req.query.limit) { limit = req.query.limit; } - if (req.params.type) { - typeString = req.params.type; - } if (req.query.q) { searchString = req.query.q || "";; } - let searchQuery = { $and: [{ type: typeString }] }; + let searchQuery = { $and: [{ type: 'course' }] }; let searchAll = false; if (searchString.length > 0) { @@ -251,7 +248,7 @@ const editTool = async (req, res) => { }); } - const getTools = async (req, res) => { + const getCourse = async (req, res) => { return new Promise(async (resolve, reject) => { let startIndex = 0; let limit = 1000; @@ -273,7 +270,7 @@ const editTool = async (req, res) => { let query = Course.aggregate([ { $match: { $and: [{ type: typeString }, { authors: parseInt(idString) }] } }, - { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } }, + { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "creator" } }, { $sort: { updatedAt : -1}} ])//.skip(parseInt(startIndex)).limit(parseInt(maxResults)); query.exec((err, data) => { @@ -470,4 +467,4 @@ function getObjectResult(type, searchAll, searchQuery, startIndex, limit) { }) }; -export { addCourse, editTool, deleteTool, setStatus, getTools, getToolsAdmin } \ No newline at end of file +export { addCourse, editCourse, deleteCourse, setStatus, getCourse, getCourseAdmin } \ No newline at end of file diff --git a/src/resources/course/course.route.js b/src/resources/course/course.route.js index 8e052a5c..1818d7aa 100644 --- a/src/resources/course/course.route.js +++ b/src/resources/course/course.route.js @@ -8,18 +8,18 @@ import { UserModel } from '../user/user.model'; import { MessagesModel } from '../message/message.model'; import { addCourse, - editTool, - deleteTool, + editCourse, + deleteCourse, setStatus, - getTools, - getToolsAdmin, + getCourse, + getCourseAdmin, } from './course.repository'; import emailGenerator from '../utilities/emailGenerator.util'; import inputSanitizer from '../utilities/inputSanitizer'; const hdrukEmail = `enquiry@healthdatagateway.org`; const router = express.Router(); -// @router POST /api/v1/Course +// @router POST /api/v1/course // @desc Add Course as user // @access Private router.post( @@ -37,16 +37,15 @@ router.post( } ); -// @router PUT /api/v1/{id} -// @desc Edit tools user +// @router PUT /api/v1/course/{id} +// @desc Edit Course as user // @access Private -// router.put('/{id}', router.put( '/:id', passport.authenticate('jwt'), utils.checkIsInRole(ROLES.Admin, ROLES.Creator), async (req, res) => { - await editTool(req) + await editCourse(req) .then((response) => { return res.json({ success: true, response }); }) @@ -64,11 +63,10 @@ router.get( passport.authenticate('jwt'), utils.checkIsInRole(ROLES.Admin, ROLES.Creator), async (req, res) => { - req.params.type = 'tool'; let role = req.user.role; if (role === ROLES.Admin) { - await getToolsAdmin(req) + await getCourseAdmin(req) .then((data) => { return res.json({ success: true, data }); }) @@ -76,7 +74,7 @@ router.get( return res.json({ success: false, err }); }); } else if (role === ROLES.Creator) { - await getTools(req) + await getCourseTools(req) .then((data) => { return res.json({ success: true, data }); }) @@ -94,8 +92,7 @@ router.get( router.get( '/', async (req, res) => { - req.params.type = 'tool'; - await getToolsAdmin(req) + await getCourseAdmin(req) .then((data) => { return res.json({ success: true, data }); }) @@ -106,7 +103,7 @@ router.get( ); // @router PATCH /api/v1/status -// @desc Set tool status +// @desc Set course status // @access Private router.patch( '/:id', @@ -186,14 +183,14 @@ router.get('/:id', async (req, res) => { * Return the details on the tool based on the tool ID for edit. */ router.get('/edit/:id', async (req, res) => { - var query = Data.aggregate([ + var query = Course.aggregate([ { $match: { $and: [{ id: parseInt(req.params.id) }] } }, { $lookup: { from: 'tools', localField: 'authors', foreignField: 'id', - as: 'persons', + as: 'creator', }, }, ]); @@ -203,7 +200,7 @@ router.get('/edit/:id', async (req, res) => { } else { return res.json({ success: false, - error: `Tool not found for tool id ${req.params.id}`, + error: `Course not found for course id ${req.params.id}`, }); } }); diff --git a/src/resources/search/search.repository.js b/src/resources/search/search.repository.js index 285bdc95..8e07cd63 100644 --- a/src/resources/search/search.repository.js +++ b/src/resources/search/search.repository.js @@ -1,7 +1,10 @@ import { Data } from '../tool/data.model'; +import { Course } from '../course/course.model'; import _ from 'lodash'; export function getObjectResult(type, searchAll, searchQuery, startIndex, maxResults, sort) { + let collection = Data; + if (type === 'course') collection = Course; var newSearchQuery = JSON.parse(JSON.stringify(searchQuery)); newSearchQuery["$and"].push({ type: type }) @@ -58,7 +61,7 @@ export function getObjectResult(type, searchAll, searchQuery, startIndex, maxRes else queryObject.push({ "$sort": { "datasetfields.metadataquality.quality_score": -1, score: { $meta: "textScore" }}}); } - var q = Data.aggregate(queryObject).skip(parseInt(startIndex)).limit(parseInt(maxResults)); + var q = collection.aggregate(queryObject).skip(parseInt(startIndex)).limit(parseInt(maxResults)); return new Promise((resolve, reject) => { q.exec((err, data) => { if (typeof data === "undefined") resolve([]); @@ -68,12 +71,14 @@ export function getObjectResult(type, searchAll, searchQuery, startIndex, maxRes } export function getObjectCount(type, searchAll, searchQuery) { + let collection = Data; + if (type === 'course') collection = Course; var newSearchQuery = JSON.parse(JSON.stringify(searchQuery)); newSearchQuery["$and"].push({ type: type }) var q = ''; if (searchAll) { - q = Data.aggregate([ + q = collection.aggregate([ { $match: newSearchQuery }, { "$group": { @@ -92,7 +97,7 @@ export function getObjectCount(type, searchAll, searchQuery) { ]); } else { - q = Data.aggregate([ + q = collection.aggregate([ { $match: newSearchQuery }, { "$group": { @@ -267,11 +272,13 @@ export function getObjectFilters(searchQueryStart, req, type) { export const getFilter = async (searchString, type, field, isArray, activeFiltersQuery) => { return new Promise(async (resolve, reject) => { + let collection = Data; + if (type === 'course') collection = Course; var q = '', p = ''; var combinedResults = [], activeCombinedResults = []; - if (searchString) q = Data.aggregate(filterQueryGenerator(field, searchString, type, isArray, {})); - else q = Data.aggregate(filterQueryGenerator(field, '', type, isArray, {})); + if (searchString) q = collection.aggregate(filterQueryGenerator(field, searchString, type, isArray, {})); + else q = collection.aggregate(filterQueryGenerator(field, '', type, isArray, {})); q.exec((err, data) => { if (err) return resolve({}) @@ -288,8 +295,8 @@ export const getFilter = async (searchString, type, field, isArray, activeFilter var newSearchQuery = JSON.parse(JSON.stringify(activeFiltersQuery)); newSearchQuery["$and"].push({ type: type }) - if (searchString) p = Data.aggregate(filterQueryGenerator(field, searchString, type, isArray, newSearchQuery)); - else p = Data.aggregate(filterQueryGenerator(field, '', type, isArray, newSearchQuery)); + if (searchString) p = collection.aggregate(filterQueryGenerator(field, searchString, type, isArray, newSearchQuery)); + else p = collection.aggregate(filterQueryGenerator(field, '', type, isArray, newSearchQuery)); p.exec((activeErr, activeData) => { if (activeData.length) { diff --git a/src/resources/search/search.router.js b/src/resources/search/search.router.js index 851e3be9..b7531044 100644 --- a/src/resources/search/search.router.js +++ b/src/resources/search/search.router.js @@ -47,7 +47,7 @@ router.get('/', async (req, res) => { searchAll = true; } - var allResults = [], datasetResults = [], toolResults = [], projectResults = [], paperResults = [], personResults = []; + var allResults = [], datasetResults = [], toolResults = [], projectResults = [], paperResults = [], personResults = [], courseResults = []; if (tab === '') { allResults = await Promise.all([ @@ -55,7 +55,8 @@ router.get('/', async (req, res) => { getObjectResult('tool', searchAll, getObjectFilters(searchQuery, req, 'tool'), req.query.toolIndex || 0, req.query.maxResults || 40, req.query.toolSort || ''), getObjectResult('project', searchAll, getObjectFilters(searchQuery, req, 'project'), req.query.projectIndex || 0, req.query.maxResults || 40, req.query.projectSort || ''), getObjectResult('paper', searchAll, getObjectFilters(searchQuery, req, 'paper'), req.query.paperIndex || 0, req.query.maxResults || 40, req.query.paperSort || ''), - getObjectResult('person', searchAll, searchQuery, req.query.personIndex || 0, req.query.maxResults || 40, req.query.personSort) + getObjectResult('person', searchAll, searchQuery, req.query.personIndex || 0, req.query.maxResults || 40, req.query.personSort), + getObjectResult('course', searchAll, getObjectFilters(searchQuery, req, 'course'), req.query.courseIndex || 0, req.query.maxResults || 40, req.query.courseSort || '') ]); } else if (tab === 'Datasets') { @@ -82,6 +83,11 @@ router.get('/', async (req, res) => { personResults = await Promise.all([ getObjectResult('person', searchAll, searchQuery, req.query.personIndex || 0, req.query.maxResults || 40, req.query.personSort || '') ]); + } + else if (tab === 'Courses') { + courseResults = await Promise.all([ + getObjectResult('course', searchAll, getObjectFilters(searchQuery, req, 'course'), req.query.courseIndex || 0, req.query.maxResults || 40, req.query.courseSort || '') + ]); } var summaryCounts = await Promise.all([ @@ -89,7 +95,8 @@ router.get('/', async (req, res) => { getObjectCount('tool', searchAll, getObjectFilters(searchQuery, req, 'tool')), getObjectCount('project', searchAll, getObjectFilters(searchQuery, req, 'project')), getObjectCount('paper', searchAll, getObjectFilters(searchQuery, req, 'paper')), - getObjectCount('person', searchAll, searchQuery) + getObjectCount('person', searchAll, searchQuery), + getObjectCount('course', searchAll, getObjectFilters(searchQuery, req, 'course')) ]); var summary = { @@ -97,7 +104,8 @@ router.get('/', async (req, res) => { tools: summaryCounts[1][0] !== undefined ? summaryCounts[1][0].count : 0, projects: summaryCounts[2][0] !== undefined ? summaryCounts[2][0].count : 0, papers: summaryCounts[3][0] !== undefined ? summaryCounts[3][0].count : 0, - persons: summaryCounts[4][0] !== undefined ? summaryCounts[4][0].count : 0 + persons: summaryCounts[4][0] !== undefined ? summaryCounts[4][0].count : 0, + courses: summaryCounts[5][0] !== undefined ? summaryCounts[5][0].count : 0 } let recordSearchData = new RecordSearchData(); @@ -107,6 +115,7 @@ router.get('/', async (req, res) => { recordSearchData.returned.project = summaryCounts[2][0] !== undefined ? summaryCounts[2][0].count : 0; recordSearchData.returned.paper = summaryCounts[3][0] !== undefined ? summaryCounts[3][0].count : 0; recordSearchData.returned.person = summaryCounts[4][0] !== undefined ? summaryCounts[4][0].count : 0; + recordSearchData.returned.course = summaryCounts[5][0] !== undefined ? summaryCounts[5][0].count : 0; recordSearchData.datesearched = Date.now(); recordSearchData.save((err) => { }); @@ -118,6 +127,7 @@ router.get('/', async (req, res) => { projectResults: allResults[2], paperResults: allResults[3], personResults: allResults[4], + courseResults: allResults[5], summary: summary }); } @@ -128,6 +138,7 @@ router.get('/', async (req, res) => { projectResults: projectResults[0], paperResults: paperResults[0], personResults: personResults[0], + courseResults: courseResults[0], summary: summary }); }); From 5dabc6a8a087f9db3b3a9842143588e7b6e3912f Mon Sep 17 00:00:00 2001 From: Ciara Date: Fri, 23 Oct 2020 11:23:16 +0100 Subject: [PATCH 119/144] IG-834 added api for related resource being a course --- .../relatedobjects/relatedobjects.route.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/resources/relatedobjects/relatedobjects.route.js b/src/resources/relatedobjects/relatedobjects.route.js index 89ca6572..3ab2db30 100644 --- a/src/resources/relatedobjects/relatedobjects.route.js +++ b/src/resources/relatedobjects/relatedobjects.route.js @@ -1,5 +1,7 @@ import express from 'express' import { Data } from '../tool/data.model' +import { Course } from "../course/course.model"; + import axios from 'axios'; const router = express.Router(); @@ -9,7 +11,8 @@ const router = express.Router(); * * Return the details on the relatedobject based on the ID. */ -router.get('/:id', async (req, res) => { +router.get('/:id', async (req, res) => { + console.log(`in relatedobjects.route`) var id = req.params.id; if (!isNaN(id)) { var q = Data.aggregate([ @@ -32,4 +35,18 @@ router.get('/:id', async (req, res) => { } }); +router.get('/course/:id', async (req, res) => { + var id = req.params.id; + + var q = Course.aggregate([ + { $match: { $and: [{ id: parseInt(id) }] } }, + // { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } } + ]); + q.exec((err, data) => { + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true, data: data }); + }); + + }); + module.exports = router; \ No newline at end of file From e6c8b946ef25654c852d97dea065522015ffc28c Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 23 Oct 2020 11:27:12 +0100 Subject: [PATCH 120/144] Continued email build --- src/resources/workflow/workflow.controller.js | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 0bade2d0..fc769a62 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -594,23 +594,41 @@ const teamController = require('../team/team.controller'); }; const getWorkflowEmailContext = (accessRecord, workflow, relatedStepIndex) => { + // Extract workflow email variables const { dateReviewStart = '' } = accessRecord; const { workflowName, steps } = workflow; - const { stepName, startDateTime = '', endDateTime = '' } = steps[relatedStepIndex]; + const { stepName, startDateTime = '', endDateTime = '', completed = false, deadline: stepDeadline = 0, reminderOffset = 0 } = steps[relatedStepIndex]; const stepReviewers = getStepReviewers(steps[relatedStepIndex]); const reviewerNames = [...stepReviewers].map((reviewer) => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); const reviewSections = [...steps[relatedStepIndex].sections].map((section) => helper.darPanelMapper[section]).join(', '); const stepReviewerUserIds = [...stepReviewers].map((user) => user.id); - const { deadline: stepDeadline = 0 } = steps[relatedStepIndex]; const currentDeadline = stepDeadline === 0 ? 'No deadline specified' : moment().add(stepDeadline, 'days'); - let nextStepName = '', nextReviewerNames = '', nextReviewSections = '', duration = '', totalDuration = '', nextDeadline = ''; + let nextStepName = '', nextReviewerNames = '', nextReviewSections = '', duration = '', totalDuration = '', nextDeadline = '', deadlineElapsed = false, deadlineApproaching = false, daysToDeadline = 0; // Calculate duration for step if it is completed + if(completed) if(!_.isEmpty(startDateTime.toString()) && !_.isEmpty(endDateTime.toString())) { duration = moment(endDateTime).diff(moment(startDateTime), 'days'); duration = duration === 0 ? `Same day` : duration === 1 ? `1 day` : `${duration} days`; + } else { + //If related step is not completed, check if deadline has elapsed or is approaching + if(!_.isEmpty(startDateTime.toString()) && stepDeadline != 0) { + let deadline = moment(startDateTime).add(stepDeadline, 'days'); + deadlineElapsed = moment().isAfter(deadline, 'second'); + + // If deadline is not elapsed, check if it is within SLA period + if(!deadlineElapsed && reminderOffset !== 0) { + let deadlineReminderDate = deadline.subtract(reminderOffset, 'days'); + deadlineApproaching = moment().isAfter(deadlineReminderDate, 'second'); + } + + // Get number of days remaining/passed deadline + let daysDiff = deadline.diff(moment(), 'days'); + daysToDeadline = daysDiff < 0 ? `${Math.abs(daysDiff)} days passed the deadline` : `${daysDiff} days until the deadline`; + } } + // Check if there is another step after the current related step if(relatedStepIndex + 1 === steps.length) { // If workflow completed nextStepName = 'No next step'; @@ -628,7 +646,26 @@ const teamController = require('../team/team.controller'); let { deadline = 0 } = steps[relatedStepIndex + 1]; nextDeadline = deadline === 0 ? 'No deadline specified' : moment().add(deadline, 'days'); } - return { workflowName, stepName, startDateTime, endDateTime, stepReviewers, duration, totalDuration, reviewerNames, stepReviewerUserIds, reviewSections, currentDeadline, nextStepName, nextReviewerNames, nextReviewSections, nextDeadline }; + return { + workflowName, + stepName, + startDateTime, + endDateTime, + stepReviewers, + duration, + totalDuration, + reviewerNames, + stepReviewerUserIds, + reviewSections, + currentDeadline, + nextStepName, + nextReviewerNames, + nextReviewSections, + nextDeadline, + deadlineElapsed, + deadlineApproaching, + daysToDeadline + }; }; export default { From 150eba53456c3773672a97ddec0e5039884eaf3f Mon Sep 17 00:00:00 2001 From: Ciara Date: Fri, 23 Oct 2020 11:31:51 +0100 Subject: [PATCH 121/144] IG-834 added for related resource course --- .../relatedobjects/relatedobjects.route.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/resources/relatedobjects/relatedobjects.route.js b/src/resources/relatedobjects/relatedobjects.route.js index 89ca6572..64805a78 100644 --- a/src/resources/relatedobjects/relatedobjects.route.js +++ b/src/resources/relatedobjects/relatedobjects.route.js @@ -1,5 +1,6 @@ import express from 'express' import { Data } from '../tool/data.model' +import { Course } from "../course/course.model"; import axios from 'axios'; const router = express.Router(); @@ -32,4 +33,18 @@ router.get('/:id', async (req, res) => { } }); +router.get('/course/:id', async (req, res) => { + var id = req.params.id; + + var q = Course.aggregate([ + { $match: { $and: [{ id: parseInt(id) }] } }, + // { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } } + ]); + q.exec((err, data) => { + if (err) return res.json({ success: false, error: err }); + return res.json({ success: true, data: data }); + }); + + }); + module.exports = router; \ No newline at end of file From 78d9131e9bb4c2a8c0f1352f12a97ed0725b0a94 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 23 Oct 2020 12:21:02 +0100 Subject: [PATCH 122/144] Update profile with new privacy settings --- src/resources/person/person.route.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/person/person.route.js b/src/resources/person/person.route.js index 70ed5c46..0aa16849 100644 --- a/src/resources/person/person.route.js +++ b/src/resources/person/person.route.js @@ -17,7 +17,7 @@ router.post('/', async (req, res) => { const { firstname, lastname, bio, emailNotifications, terms, sector, organisation, showOrganisation, tags} = req.body; let link = urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(req.body.link)); - let orcid = urlValidator.validateOrcidURL(inputSanitizer.removeNonBreakingSpaces(req.body.orcid)); + let orcid = req.body.orcid !== '' ? urlValidator.validateOrcidURL(inputSanitizer.removeNonBreakingSpaces(req.body.orcid)) : ''; let data = Data(); console.log(req.body) data.id = parseInt(Math.random().toString().replace('0.', '')); @@ -47,7 +47,7 @@ router.put('/', let { id, firstname, lastname, email, bio, showBio, showLink, showOrcid, emailNotifications, terms, sector, showSector, organisation, showOrganisation, tags, showDomain } = req.body; const type = 'person'; let link = urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(req.body.link)); - let orcid = urlValidator.validateOrcidURL(inputSanitizer.removeNonBreakingSpaces(req.body.orcid)); + let orcid = req.body.orcid !== '' ? urlValidator.validateOrcidURL(inputSanitizer.removeNonBreakingSpaces(req.body.orcid)) : ''; firstname = inputSanitizer.removeNonBreakingSpaces(firstname), lastname = inputSanitizer.removeNonBreakingSpaces(lastname), bio = inputSanitizer.removeNonBreakingSpaces(bio); From 7328cd1371fafbb3b0a4a2ccb74bb5073abd2102 Mon Sep 17 00:00:00 2001 From: Ciara Date: Fri, 23 Oct 2020 14:21:38 +0100 Subject: [PATCH 123/144] IG-834 added additional fields required to display course card in search results --- src/resources/search/search.repository.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/resources/search/search.repository.js b/src/resources/search/search.repository.js index 8e07cd63..49e071d3 100644 --- a/src/resources/search/search.repository.js +++ b/src/resources/search/search.repository.js @@ -41,6 +41,9 @@ export function getObjectResult(type, searchAll, searchQuery, startIndex, maxRes "persons.firstname": 1, "persons.lastname": 1, + "title": 1, + "courseOptions": 1, + "activeflag": 1, "counter": 1, "datasetfields.metadataquality.quality_score": 1 From a0bee67a1343b2ee692bfccfbb1e0ab8b4782d83 Mon Sep 17 00:00:00 2001 From: Ciara Date: Fri, 23 Oct 2020 14:36:49 +0100 Subject: [PATCH 124/144] IG-834 added additional fields needed to display on course card in search results --- src/resources/search/search.repository.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/resources/search/search.repository.js b/src/resources/search/search.repository.js index 49e071d3..ad9e55b5 100644 --- a/src/resources/search/search.repository.js +++ b/src/resources/search/search.repository.js @@ -43,6 +43,9 @@ export function getObjectResult(type, searchAll, searchQuery, startIndex, maxRes "title": 1, "courseOptions": 1, + "provider":1, + "keywords":1, + "domains":1, "activeflag": 1, "counter": 1, From 885558ba892d82d6ccedb73496349779e3de4076 Mon Sep 17 00:00:00 2001 From: Ciara Date: Fri, 23 Oct 2020 15:40:49 +0100 Subject: [PATCH 125/144] IG-832 updated to return creator as persons --- src/resources/course/course.repository.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index ad559175..6ca826a9 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -246,7 +246,7 @@ const editCourse = async (req, res) => { resolve(values[0]); }); }); - } + } const getCourse = async (req, res) => { return new Promise(async (resolve, reject) => { @@ -280,7 +280,7 @@ const editCourse = async (req, res) => { }); } - const setStatus = async (req, res) => { + const setStatus = async (req, res) => { return new Promise(async (resolve, reject) => { try { const { activeflag, rejectionReason } = req.body; @@ -440,13 +440,13 @@ async function storeNotificationsForAuthors(tool, toolOwner) { }; function getObjectResult(type, searchAll, searchQuery, startIndex, limit) { - let newSearchQuery = JSON.parse(JSON.stringify(searchQuery)); + let newSearchQuery = JSON.parse(JSON.stringify(searchQuery)); let q = ''; if (searchAll) { q = Course.aggregate([ { $match: newSearchQuery }, - { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } }, + { $lookup: { from: "tools", localField: "creator", foreignField: "id", as: "persons" } }, { $lookup: { from: "tools", localField: "id", foreignField: "authors", as: "objects" } }, { $lookup: { from: "reviews", localField: "id", foreignField: "toolID", as: "reviews" } } ]).sort({ updatedAt : -1}).skip(parseInt(startIndex)).limit(parseInt(limit)); @@ -454,7 +454,7 @@ function getObjectResult(type, searchAll, searchQuery, startIndex, limit) { else{ q = Course.aggregate([ { $match: newSearchQuery }, - { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } }, + { $lookup: { from: "tools", localField: "creator", foreignField: "id", as: "persons" } }, { $lookup: { from: "tools", localField: "id", foreignField: "authors", as: "objects" } }, { $lookup: { from: "reviews", localField: "id", foreignField: "toolID", as: "reviews" } } ]).sort({ score: { $meta: "textScore" } }).skip(parseInt(startIndex)).limit(parseInt(limit)); From 38f516b130f70b3e7c97f84127651761be5d5421 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 23 Oct 2020 16:55:45 +0100 Subject: [PATCH 126/144] Added param for removing submitted applications --- src/resources/datarequest/datarequest.controller.js | 2 +- src/resources/publisher/publisher.controller.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index dca7d3bb..57cdf4a7 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -85,7 +85,7 @@ module.exports = { // 6. Return payload return res .status(200) - .json({ success: true, data: modifiedApplications, avgDecisionTime }); + .json({ success: true, data: modifiedApplications, avgDecisionTime, canViewSubmitted: true }); } catch (error) { console.error(error); return res.status(500).json({ diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index b4745644..f9427d1b 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -193,7 +193,7 @@ module.exports = { // 7. Return all applications return res .status(200) - .json({ success: true, data: modifiedApplications, avgDecisionTime }); + .json({ success: true, data: modifiedApplications, avgDecisionTime, canViewSubmitted: isManager }); } catch (err) { console.error(err); return res.status(500).json({ From c23677fdbcdf6962f972b3e20371e270894f4a3a Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Tue, 27 Oct 2020 00:30:20 +0000 Subject: [PATCH 127/144] Updates to apis --- src/resources/course/course.model.js | 5 +- src/resources/course/course.repository.js | 95 +++++----- src/resources/dataset/dataset.service.js | 5 +- src/resources/search/filter.route.js | 49 ++++++ src/resources/search/search.repository.js | 200 +++++++++++++++++----- src/resources/search/search.router.js | 4 +- 6 files changed, 260 insertions(+), 98 deletions(-) diff --git a/src/resources/course/course.model.js b/src/resources/course/course.model.js index fafe1173..aae09489 100644 --- a/src/resources/course/course.model.js +++ b/src/resources/course/course.model.js @@ -26,14 +26,15 @@ const CourseSchema = new Schema( keywords: [String], domains: [String], courseOptions: [{ - flexibleDates: String, + flexibleDates: {type: Boolean, default: false }, startDate: Date, studyMode: String, studyDurationNumber: Number, studyDurationMeasure: String, fees: [{ feeDescription: String, - feeAmount: Number + feeAmount: Number, + feePer: String }], }], entries: [ diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index 6ca826a9..b73b8c99 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -33,18 +33,16 @@ const addCourse = async (req, res) => { if (req.body.courseOptions) { req.body.courseOptions.forEach((x) => { - x.flexibleDates = inputSanitizer.removeNonBreakingSpaces(x.flexibleDates); x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); x.studyDurationNumber = x.studyDurationNumber; x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); - if (req.body.fees) { - req.body.fees.forEach((y) => { + if (x.fees) { + x.fees.forEach((y) => { y.feeDescription = inputSanitizer.removeNonBreakingSpaces(y.feeDescription); - x.feeAmount = inputSanitizer.removeNonBreakingSpaces(x.feeAmount); + y.feePer = inputSanitizer.removeNonBreakingSpaces(y.feePer); }); } - course.fees = req.body.fees; }); } course.courseOptions = req.body.courseOptions; @@ -75,7 +73,7 @@ const addCourse = async (req, res) => { if(!newCourse) reject(new Error(`Can't persist data object to DB.`)); - let message = new MessagesModel(); + /* let message = new MessagesModel(); message.messageID = parseInt(Math.random().toString().replace('0.', '')); message.messageTo = 0; message.messageObjectID = course.id; @@ -116,10 +114,10 @@ const addCourse = async (req, res) => { }); if (course.type === 'course') { - await sendEmailNotificationToAuthors(course, course.creator); + //await sendEmailNotificationToAuthors(course, course.creator); } - await storeNotificationsForAuthors(course, course.creator); - + //await storeNotificationsForAuthors(course, course.creator); + */ resolve(newCourse); }) }; @@ -132,51 +130,54 @@ const addCourse = async (req, res) => { const editCourse = async (req, res) => { return new Promise(async(resolve, reject) => { - - const toolCreator = req.body.toolCreator; - let { type, name, link, description, resultsInsights, categories, license, authors, tags, journal, journalYear, relatedObjects, isPreprint } = req.body; let id = req.params.id; - let programmingLanguage = req.body.programmingLanguage; - if (!categories || typeof categories === undefined) categories = {'category':'', 'programmingLanguage':[], 'programmingLanguageVersion':''} - - if(programmingLanguage){ - programmingLanguage.forEach((p) => - { - p.programmingLanguage = inputSanitizer.removeNonBreakingSpaces(p.programmingLanguage); - p.version = (inputSanitizer.removeNonBreakingSpaces(p.version)); + if(req.body.entries){ + req.body.entries.forEach((e) => { + e.level = inputSanitizer.removeNonBreakingSpaces(e.level); + e.subject = (inputSanitizer.removeNonBreakingSpaces(e.subject)); }); } - let data = { + if (req.body.courseOptions) { + req.body.courseOptions.forEach((x) => { + if (x.flexibleDates) x.startDate = null; + else x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); + x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); + x.studyDurationNumber = x.studyDurationNumber; + x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); + if (x.fees) { + x.fees.forEach((y) => { + y.feeDescription = inputSanitizer.removeNonBreakingSpaces(y.feeDescription); + y.feePer = inputSanitizer.removeNonBreakingSpaces(y.feePer); + }); + } + }); + } + + /* let data = { id: id, - name: name, + name: req.body.title, authors: authors, - }; + }; */ Course.findOneAndUpdate({ id: id }, { - type: inputSanitizer.removeNonBreakingSpaces(type), - name: inputSanitizer.removeNonBreakingSpaces(name), - link: urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(link)), - description: inputSanitizer.removeNonBreakingSpaces(description), - resultsInsights: inputSanitizer.removeNonBreakingSpaces(resultsInsights), - journal: inputSanitizer.removeNonBreakingSpaces(journal), - journalYear: inputSanitizer.removeNonBreakingSpaces(journalYear), - categories: { - category: inputSanitizer.removeNonBreakingSpaces(categories.category), - programmingLanguage: categories.programmingLanguage, - programmingLanguageVersion: categories.programmingLanguageVersion - }, - license: inputSanitizer.removeNonBreakingSpaces(license), - authors: authors, - programmingLanguage: programmingLanguage, - tags: { - features: inputSanitizer.removeNonBreakingSpaces(tags.features), - topics: inputSanitizer.removeNonBreakingSpaces(tags.topics) - }, - relatedObjects: relatedObjects, - isPreprint: isPreprint + title: inputSanitizer.removeNonBreakingSpaces(req.body.title), + link: urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(req.body.link)), + provider: inputSanitizer.removeNonBreakingSpaces(req.body.provider), + description: inputSanitizer.removeNonBreakingSpaces(req.body.description), + courseDelivery: inputSanitizer.removeNonBreakingSpaces(req.body.courseDelivery), + location: inputSanitizer.removeNonBreakingSpaces(req.body.location), + keywords: inputSanitizer.removeNonBreakingSpaces(req.body.keywords), + domains: inputSanitizer.removeNonBreakingSpaces(req.body.domains), + relatedObjects: req.body.relatedObjects, + courseOptions: req.body.courseOptions, + entries:req.body.entries, + restrictions: inputSanitizer.removeNonBreakingSpaces(req.body.restrictions), + award: inputSanitizer.removeNonBreakingSpaces(req.body.award), + competencyFramework: inputSanitizer.removeNonBreakingSpaces(req.body.competencyFramework), + nationalPriority: inputSanitizer.removeNonBreakingSpaces(req.body.nationalPriority), }, (err) => { if (err) { reject(new Error(`Failed to update.`)); @@ -185,10 +186,10 @@ const editCourse = async (req, res) => { if(tool == null){ reject(new Error(`No record found with id of ${id}.`)); } - else if (type === 'tool') { + else if (req.body.type === 'tool') { // Send email notification of update to all authors who have opted in to updates - sendEmailNotificationToAuthors(data, toolCreator); - storeNotificationsForAuthors(data, toolCreator); + //sendEmailNotificationToAuthors(data, toolCreator); + //storeNotificationsForAuthors(data, toolCreator); } resolve(tool); }); diff --git a/src/resources/dataset/dataset.service.js b/src/resources/dataset/dataset.service.js index 85317a3b..eb166ce4 100644 --- a/src/resources/dataset/dataset.service.js +++ b/src/resources/dataset/dataset.service.js @@ -12,7 +12,8 @@ export async function loadDataset(datasetID) { const dataClassCall = axios.get(metadataCatalogueLink + '/api/dataModels/'+datasetID+'/dataClasses', { timeout:5000 }).catch(err => { console.log('Unable to get dataclass '+err.message) }); const versionLinksCall = axios.get(metadataCatalogueLink + '/api/catalogueItems/'+datasetID+'/semanticLinks', { timeout:5000 }).catch(err => { console.log('Unable to get version links '+err.message) }); const phenotypesCall = await axios.get('https://raw.githubusercontent.com/spiros/hdr-caliber-phenome-portal/master/_data/dataset2phenotypes.json', { timeout:5000 }).catch(err => { console.log('Unable to get phenotypes '+err.message) }); - const [dataset, metadataQualityList, metadataSchema, dataClass, versionLinks, phenotypesList] = await axios.all([datasetCall, metadataQualityCall, metadataSchemaCall, dataClassCall, versionLinksCall, phenotypesCall]); + const dataUtilityCall = await axios.get('https://raw.githubusercontent.com/HDRUK/datasets/master/reports/data_utility.json', { timeout:5000 }).catch(err => { console.log('Unable to get data utility '+err.message) }); + const [dataset, metadataQualityList, metadataSchema, dataClass, versionLinks, phenotypesList, dataUtilityList] = await axios.all([datasetCall, metadataQualityCall, metadataSchemaCall, dataClassCall, versionLinksCall, phenotypesCall,dataUtilityCall]); var technicaldetails = []; @@ -69,6 +70,7 @@ export async function loadDataset(datasetID) { const metadataQuality = metadataQualityList.data.find(x => x.id === datasetID); const phenotypes = phenotypesList.data[datasetID] || []; + const dataUtility = dataUtilityList.data.find(x => x.id === datasetID); var data = new Data(); data.id = uniqueID; @@ -101,6 +103,7 @@ export async function loadDataset(datasetID) { data.datasetfields.technicaldetails = technicaldetails; data.datasetfields.versionLinks = versionLinks && versionLinks.data && versionLinks.data.items ? versionLinks.data.items : []; data.datasetfields.phenotypes = phenotypes; + data.datasetfields.datautility = dataUtility ? dataUtility : {}; return await data.save(); } diff --git a/src/resources/search/filter.route.js b/src/resources/search/filter.route.js index d6d91f4a..3b90c977 100644 --- a/src/resources/search/filter.route.js +++ b/src/resources/search/filter.route.js @@ -194,8 +194,57 @@ router.get('/', async (req, res) => { }); }); } + else if (tab === 'Courses') { + let searchQuery = { $and: [{ activeflag: 'active' }] }; + if (searchString.length > 0) searchQuery["$and"].push({ $text: { $search: searchString } }); + var activeFiltersQuery = getObjectFilters(searchQuery, req, 'course') + + await Promise.all([ + getFilter(searchString, 'course', 'courseOptions.startDate', true, activeFiltersQuery), + getFilter(searchString, 'course', 'provider', true, activeFiltersQuery), + getFilter(searchString, 'course', 'location', true, activeFiltersQuery), + getFilter(searchString, 'course', 'courseOptions.studyMode', true, activeFiltersQuery), + getFilter(searchString, 'course', 'award', true, activeFiltersQuery), + getFilter(searchString, 'course', 'entries.level', true, activeFiltersQuery), + getFilter(searchString, 'course', 'domains', true, activeFiltersQuery), + getFilter(searchString, 'course', 'keywords', true, activeFiltersQuery), + getFilter(searchString, 'course', 'competencyFramework', true, activeFiltersQuery), + getFilter(searchString, 'course', 'nationalPriority', true, activeFiltersQuery) + ]).then((values) => { + return res.json({ + success: true, + allFilters: { + courseStartDatesFilter: values[0][0], + courseProviderFilter: values[1][0], + courseLocationFilter: values[2][0], + courseStudyModeFilter: values[3][0], + courseAwardFilter: values[4][0], + courseEntryLevelFilter: values[5][0], + courseDomainsFilter: values[6][0], + courseKeywordsFilter: values[7][0], + courseFrameworkFilter: values[8][0], + coursePriorityFilter: values[9][0] + }, + filterOptions: { + courseStartDatesFilterOptions: values[0][1], + courseProviderFilterOptions: values[1][0], + courseLocationFilterOptions: values[2][0], + courseStudyModeFilterOptions: values[3][0], + courseAwardFilterOptions: values[4][0], + courseEntryLevelFilterOptions: values[5][0], + courseDomainsFilterOptions: values[6][0], + courseKeywordsFilterOptions: values[7][0], + courseFrameworkFilterOptions: values[8][0], + coursePriorityFilterOptions: values[9][0] + } + }); + }); + } }); + + + // @route GET api/v1/search/filter/topic/:type // @desc GET Get list of topics by entity type // @access Public diff --git a/src/resources/search/search.repository.js b/src/resources/search/search.repository.js index ad9e55b5..f97d011c 100644 --- a/src/resources/search/search.repository.js +++ b/src/resources/search/search.repository.js @@ -1,58 +1,77 @@ import { Data } from '../tool/data.model'; import { Course } from '../course/course.model'; import _ from 'lodash'; +import moment from 'moment'; export function getObjectResult(type, searchAll, searchQuery, startIndex, maxResults, sort) { let collection = Data; if (type === 'course') collection = Course; var newSearchQuery = JSON.parse(JSON.stringify(searchQuery)); newSearchQuery["$and"].push({ type: type }) - - var queryObject = [ - { $match: newSearchQuery }, - { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } }, - { - $project: { - "_id": 0, - "id": 1, - "name": 1, - "type": 1, - "description": 1, - "bio": 1, - "categories.category": 1, - "categories.programmingLanguage": 1, - "programmingLanguage.programmingLanguage": 1, - "programmingLanguage.version": 1, - "license": 1, - "tags.features": 1, - "tags.topics": 1, - "firstname": 1, - "lastname": 1, - "datasetid": 1, - - "datasetfields.publisher": 1, - "datasetfields.geographicCoverage": 1, - "datasetfields.physicalSampleAvailability": 1, - "datasetfields.abstract": 1, - "datasetfields.ageBand": 1, - "datasetfields.phenotypes": 1, - - "persons.id": 1, - "persons.firstname": 1, - "persons.lastname": 1, - - "title": 1, - "courseOptions": 1, - "provider":1, - "keywords":1, - "domains":1, - - "activeflag": 1, - "counter": 1, - "datasetfields.metadataquality.quality_score": 1 + if (type === 'course') newSearchQuery["$and"].push({$or:[{"courseOptions.startDate": { $gt: new Date(Date.now())}}, { 'courseOptions.flexibleDates':true}]}); + + var queryObject; + if (type === 'course') { + queryObject = [ + { $match: newSearchQuery }, + { + $project: { + "_id": 0, + "id": 1, + "title": 1, + "provider": 1, + "type": 1, + "description": 1, + "courseOptions.flexibleDates": 1, + "courseOptions.startDate": 1, + "courseOptions.studyMode": 1, + "domains": 1, + "award": 1 + } } - } - ]; + ]; + } + else { + queryObject = [ + { $match: newSearchQuery }, + { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "persons" } }, + { + $project: { + "_id": 0, + "id": 1, + "name": 1, + "type": 1, + "description": 1, + "bio": 1, + "categories.category": 1, + "categories.programmingLanguage": 1, + "programmingLanguage.programmingLanguage": 1, + "programmingLanguage.version": 1, + "license": 1, + "tags.features": 1, + "tags.topics": 1, + "firstname": 1, + "lastname": 1, + "datasetid": 1, + + "datasetfields.publisher": 1, + "datasetfields.geographicCoverage": 1, + "datasetfields.physicalSampleAvailability": 1, + "datasetfields.abstract": 1, + "datasetfields.ageBand": 1, + "datasetfields.phenotypes": 1, + + "persons.id": 1, + "persons.firstname": 1, + "persons.lastname": 1, + + "activeflag": 1, + "counter": 1, + "datasetfields.metadataquality.quality_score": 1 + } + } + ]; + } if (sort === '' || sort ==='relevance') { if (searchAll) queryObject.push({ "$sort": { "name": 1 }}); @@ -66,6 +85,10 @@ export function getObjectResult(type, searchAll, searchQuery, startIndex, maxRes if (searchAll) queryObject.push({ "$sort": { "datasetfields.metadataquality.quality_score": -1, "name": 1 }}); else queryObject.push({ "$sort": { "datasetfields.metadataquality.quality_score": -1, score: { $meta: "textScore" }}}); } + else if (sort === 'startdate') { + if (searchAll) queryObject.push({ "$sort": { "courseOptions.startDate": 1 }}); + else queryObject.push({ "$sort": { "courseOptions.startDate": 1, score: { $meta: "textScore" }}}); + } var q = collection.aggregate(queryObject).skip(parseInt(startIndex)).limit(parseInt(maxResults)); return new Promise((resolve, reject) => { @@ -137,7 +160,8 @@ export function getObjectFilters(searchQueryStart, req, type) { license = '', sampleavailability = '', keywords = '', publisher = '', ageband = '', geographiccover = '', phenotypes = '', programmingLanguage = '', toolcategories = '', features = '', tooltopics = '', projectcategories = '', projectfeatures = '', projecttopics = '', - paperfeatures = '', papertopics = '' + paperfeatures = '', papertopics = '', + coursestartdates = '', coursedomains = '', coursekeywords = '', courseprovider = '', courselocation = '', coursestudymode = '', courseaward = '', courseentrylevel = '', courseframework = '', coursepriority = '' } = req.query; if (type === "dataset") { @@ -273,6 +297,88 @@ export function getObjectFilters(searchQueryStart, req, type) { searchQuery["$and"].push({ "$or": filterTermArray }); } } + else if (type === "course") { + if (coursestartdates.length > 0) { + var filterTermArray = []; + coursestartdates.split('::').forEach((filterTerm) => { + const d = new Date(filterTerm); + filterTermArray.push({ "courseOptions.startDate": new Date(d) }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (courseprovider.length > 0) { + var filterTermArray = []; + courseprovider.split('::').forEach((filterTerm) => { + filterTermArray.push({ "provider": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (courselocation.length > 0) { + var filterTermArray = []; + courselocation.split('::').forEach((filterTerm) => { + filterTermArray.push({ "location": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (coursestudymode.length > 0) { + var filterTermArray = []; + coursestudymode.split('::').forEach((filterTerm) => { + filterTermArray.push({ "courseOptions.studyMode": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (courseaward.length > 0) { + var filterTermArray = []; + courseaward.split('::').forEach((filterTerm) => { + filterTermArray.push({ "award": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (courseentrylevel.length > 0) { + var filterTermArray = []; + courseentrylevel.split('::').forEach((filterTerm) => { + filterTermArray.push({ "entries.level": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (coursedomains.length > 0) { + var filterTermArray = []; + coursedomains.split('::').forEach((filterTerm) => { + filterTermArray.push({ "domains": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (coursekeywords.length > 0) { + var filterTermArray = []; + coursekeywords.split('::').forEach((filterTerm) => { + filterTermArray.push({ "keywords": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (courseframework.length > 0) { + var filterTermArray = []; + courseframework.split('::').forEach((filterTerm) => { + filterTermArray.push({ "competencyFramework": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + + if (coursepriority.length > 0) { + var filterTermArray = []; + coursepriority.split('::').forEach((filterTerm) => { + filterTermArray.push({ "nationalPriority": filterTerm }) + }); + searchQuery["$and"].push({ "$or": filterTermArray }); + } + } return searchQuery; } @@ -293,6 +399,7 @@ export const getFilter = async (searchString, type, field, isArray, activeFilter data.forEach((dat) => { if (dat.result && dat.result !== '') { if (field === 'datasetfields.phenotypes') combinedResults.push(dat.result.name.trim()); + else if (field === 'courseOptions.startDate') combinedResults.push(moment(dat.result).format("DD MMM YYYY")); else combinedResults.push(dat.result.trim()); } }) @@ -309,6 +416,7 @@ export const getFilter = async (searchString, type, field, isArray, activeFilter activeData.forEach((dat) => { if (dat.result && dat.result !== '') { if (field === 'datasetfields.phenotypes') activeCombinedResults.push(dat.result.name.trim()); + else if (field === 'courseOptions.startDate') activeCombinedResults.push(moment(dat.result).format("DD MMM YYYY")); else activeCombinedResults.push(dat.result.trim()); } }) diff --git a/src/resources/search/search.router.js b/src/resources/search/search.router.js index b7531044..c3714af0 100644 --- a/src/resources/search/search.router.js +++ b/src/resources/search/search.router.js @@ -56,7 +56,7 @@ router.get('/', async (req, res) => { getObjectResult('project', searchAll, getObjectFilters(searchQuery, req, 'project'), req.query.projectIndex || 0, req.query.maxResults || 40, req.query.projectSort || ''), getObjectResult('paper', searchAll, getObjectFilters(searchQuery, req, 'paper'), req.query.paperIndex || 0, req.query.maxResults || 40, req.query.paperSort || ''), getObjectResult('person', searchAll, searchQuery, req.query.personIndex || 0, req.query.maxResults || 40, req.query.personSort), - getObjectResult('course', searchAll, getObjectFilters(searchQuery, req, 'course'), req.query.courseIndex || 0, req.query.maxResults || 40, req.query.courseSort || '') + getObjectResult('course', searchAll, getObjectFilters(searchQuery, req, 'course'), req.query.courseIndex || 0, req.query.maxResults || 40, 'startdate') ]); } else if (tab === 'Datasets') { @@ -86,7 +86,7 @@ router.get('/', async (req, res) => { } else if (tab === 'Courses') { courseResults = await Promise.all([ - getObjectResult('course', searchAll, getObjectFilters(searchQuery, req, 'course'), req.query.courseIndex || 0, req.query.maxResults || 40, req.query.courseSort || '') + getObjectResult('course', searchAll, getObjectFilters(searchQuery, req, 'course'), req.query.courseIndex || 0, req.query.maxResults || 40, 'startdate') ]); } From 76b7a483c148978ee7f22bdb05583c9d6bdc875f Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 28 Oct 2020 09:33:52 +0000 Subject: [PATCH 128/144] Changed aboutApplication from json string to object in MongoDb --- .../datarequest/datarequest.controller.js | 31 +++++++++++++------ .../datarequest/datarequest.model.js | 4 +-- .../publisher/publisher.controller.js | 8 +++-- src/resources/workflow/workflow.controller.js | 8 +++-- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 57cdf4a7..1b5e3c1b 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -174,7 +174,7 @@ module.exports = { ...accessRecord.toObject(), jsonSchema: JSON.parse(accessRecord.jsonSchema), questionAnswers: JSON.parse(accessRecord.questionAnswers), - aboutApplication: JSON.parse(accessRecord.aboutApplication), + aboutApplication: typeof accessRecord.aboutApplication === 'string' ? JSON.parse(accessRecord.aboutApplication) : accessRecord.aboutApplication, datasets: accessRecord.datasets, readOnly, userType, @@ -252,7 +252,7 @@ module.exports = { jsonSchema, publisher, questionAnswers: '{}', - aboutApplication: '{}', + aboutApplication: {}, applicationStatus: applicationStatuses.INPROGRESS, }); // 4. save record @@ -271,13 +271,17 @@ module.exports = { data = { ...accessRecord.toObject() }; } + if (typeof a_string === 'string') { + // this is a string + } + return res.status(200).json({ status: 'success', data: { ...data, jsonSchema: JSON.parse(data.jsonSchema), questionAnswers: JSON.parse(data.questionAnswers), - aboutApplication: JSON.parse(data.aboutApplication), + aboutApplication: typeof data.aboutApplication === 'string' ? JSON.parse(data.aboutApplication) : data.aboutApplication, dataset, projectId: data.projectId || helper.generateFriendlyId(data._id), userType: userTypes.APPLICANT, @@ -352,7 +356,7 @@ module.exports = { jsonSchema, publisher, questionAnswers: '{}', - aboutApplication: '{}', + aboutApplication: {}, applicationStatus: applicationStatuses.INPROGRESS, }); // 4. save record @@ -376,7 +380,7 @@ module.exports = { ...data, jsonSchema: JSON.parse(data.jsonSchema), questionAnswers: JSON.parse(data.questionAnswers), - aboutApplication: JSON.parse(data.aboutApplication), + aboutApplication: typeof data.aboutApplication === 'string' ? JSON.parse(data.aboutApplication) : data.aboutApplication, datasets, projectId: data.projectId || helper.generateFriendlyId(data._id), userType: userTypes.APPLICANT, @@ -401,8 +405,10 @@ module.exports = { let updateObj; let { aboutApplication, questionAnswers, jsonSchema = '' } = req.body; if (aboutApplication) { - let parsedObj = JSON.parse(aboutApplication); - let updatedDatasetIds = parsedObj.selectedDatasets.map( + if(typeof aboutApplication === 'string') { + aboutApplication = JSON.parse(aboutApplication) + } + let updatedDatasetIds = aboutApplication.selectedDatasets.map( (dataset) => dataset.datasetId ); updateObj = { aboutApplication, datasetIds: updatedDatasetIds }; @@ -1380,7 +1386,10 @@ module.exports = { createNotifications: async (type, context, accessRecord, user) => { // Project details from about application if 5 Safes - let aboutApplication = JSON.parse(accessRecord.aboutApplication); + let { aboutApplication } = accessRecord; + if(typeof aboutApplication === 'string') { + aboutApplication = JSON.parse(accessRecord.aboutApplication); + } let { projectName } = aboutApplication; let { projectId, _id, workflow = {}, dateSubmitted = '' } = accessRecord; if (_.isEmpty(projectId)) { @@ -2024,8 +2033,10 @@ module.exports = { let { aboutApplication, questionAnswers } = app; if (aboutApplication) { - let aboutObj = JSON.parse(aboutApplication); - ({ projectName } = aboutObj); + if(typeof aboutApplication === 'string') { + aboutApplication = JSON.parse(aboutApplication); + } + ({ projectName } = aboutApplication); } if (_.isEmpty(projectName)) { projectName = `${publisher} - ${name}`; diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 5a1b1c81..19382af8 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -29,8 +29,8 @@ const DataRequestSchema = new Schema({ default: "{}" }, aboutApplication: { - type: String, - default: "{}" + type: Object, + default: {} }, dateSubmitted: { type: Date diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index f9427d1b..9513dd50 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -265,9 +265,11 @@ module.exports = { }, []); applications = applications.map((app) => { - const { aboutApplication, _id } = app; - const aboutApplicationObj = JSON.parse(aboutApplication) || {}; - let { projectName = 'No project name' } = aboutApplicationObj; + let { aboutApplication, _id } = app; + if(typeof aboutApplication === 'string') { + aboutApplication = JSON.parse(aboutApplication) || {}; + } + let { projectName = 'No project name' } = aboutApplication; return { projectName, _id }; }); let canDelete = applications.length === 0, diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index fc769a62..f2133ee2 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -60,9 +60,11 @@ const teamController = require('../team/team.controller'); applications = [], } = workflow.toObject(); applications = applications.map((app) => { - const { aboutApplication, _id } = app; - const aboutApplicationObj = JSON.parse(aboutApplication) || {}; - let { projectName = 'No project name' } = aboutApplicationObj; + let { aboutApplication, _id } = app; + if(typeof aboutApplication === 'string') { + aboutApplication = JSON.parse(aboutApplication) || {}; + } + let { projectName = 'No project name' } = aboutApplication; return { projectName, _id }; }); // Set operation permissions From c05ce3c73a26fdbe58811b3ef92a4c2cc46a9c38 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Wed, 28 Oct 2020 12:03:28 +0000 Subject: [PATCH 129/144] Updated MDW config and gateway logout --- src/config/configuration.js | 8 ++------ src/resources/auth/auth.route.js | 6 ++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index e3c3396b..57aa415e 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -19,10 +19,8 @@ export const clients = [ //Metadata works client_id: process.env.MDWClientID || '', client_secret: process.env.MDWClientSecret || '', - grant_types: ['authorization_code'], - response_types: ['code'], - //grant_types: ['authorization_code', 'implicit'], - //response_types: ['code id_token'], + grant_types: ['authorization_code', 'implicit'], + response_types: ['code id_token'], redirect_uris: process.env.MDWRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256', post_logout_redirect_uris: ['https://web.uatbeta.healthdatagateway.org/search?search=&logout=true'] @@ -31,8 +29,6 @@ export const clients = [ //BC Platforms client_id: process.env.BCPClientID || '', client_secret: process.env.BCPClientSecret || '', - //grant_types: ['authorization_code'], - //response_types: ['code'], grant_types: ['authorization_code', 'implicit'], response_types: ['code id_token'], redirect_uris: process.env.BCPRedirectURI.split(",") || [''], diff --git a/src/resources/auth/auth.route.js b/src/resources/auth/auth.route.js index bc5139cc..659fc3e2 100644 --- a/src/resources/auth/auth.route.js +++ b/src/resources/auth/auth.route.js @@ -49,8 +49,10 @@ router.post('/login', async (req, res) => { // @desc logout user // @access Private router.get('/logout', function (req, res) { - req.logout(); - res.clearCookie('jwt'); + req.logout(); + for (var prop in req.cookies) { + res.clearCookie(prop); + } return res.json({ success: true }); }); From 86490865f5f3a2d105c70021ff56558af3e5ad90 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 28 Oct 2020 14:16:15 +0000 Subject: [PATCH 130/144] Removed redundant code --- src/resources/datarequest/datarequest.controller.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 1b5e3c1b..98b3e050 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -271,10 +271,6 @@ module.exports = { data = { ...accessRecord.toObject() }; } - if (typeof a_string === 'string') { - // this is a string - } - return res.status(200).json({ status: 'success', data: { From adae90c935eaa0f3869a9c5b290f4de19092a1ba Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Wed, 28 Oct 2020 14:46:31 +0000 Subject: [PATCH 131/144] Setting MDW config response type back to code --- src/config/configuration.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index 57aa415e..c1a6bba5 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -19,8 +19,10 @@ export const clients = [ //Metadata works client_id: process.env.MDWClientID || '', client_secret: process.env.MDWClientSecret || '', - grant_types: ['authorization_code', 'implicit'], - response_types: ['code id_token'], + grant_types: ['authorization_code'], + response_types: ['code'], + //grant_types: ['authorization_code', 'implicit'], + //response_types: ['code id_token'], redirect_uris: process.env.MDWRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256', post_logout_redirect_uris: ['https://web.uatbeta.healthdatagateway.org/search?search=&logout=true'] From 44bd9dc1310d41d949653b9cd6480be4a762c9c7 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Wed, 28 Oct 2020 15:12:23 +0000 Subject: [PATCH 132/144] Updating post logout uris for MDW --- src/config/configuration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index c1a6bba5..157d39c2 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -25,7 +25,7 @@ export const clients = [ //response_types: ['code id_token'], redirect_uris: process.env.MDWRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256', - post_logout_redirect_uris: ['https://web.uatbeta.healthdatagateway.org/search?search=&logout=true'] + post_logout_redirect_uris: ['https://hdr.auth.metadata.works/logout','http://localhost:8080/logout'] }, { //BC Platforms From 0b2075da78946cd8c0243eab1c216aaa47d5fbd3 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Wed, 28 Oct 2020 17:17:27 +0000 Subject: [PATCH 133/144] Fix for DAR not submitting --- .../datarequest/datarequest.controller.js | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 38504eea..7cc4bee7 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -136,7 +136,7 @@ module.exports = { }); } catch (err) { console.error(err.message); - res.status(500).json({ status: 'error', message: err }); + res.status(500).json({ status: 'error', message: err.message }); } }, @@ -226,7 +226,7 @@ module.exports = { }); } catch (err) { console.log(err.message); - res.status(500).json({ status: 'error', message: err }); + res.status(500).json({ status: 'error', message: err.message }); } }, @@ -315,7 +315,7 @@ module.exports = { }); } catch (err) { console.log(err.message); - res.status(500).json({ status: 'error', message: err }); + res.status(500).json({ status: 'error', message: err.message }); } }, @@ -361,7 +361,7 @@ module.exports = { }); } catch (err) { console.log(err.message); - res.status(500).json({ status: 'error', message: err }); + res.status(500).json({ status: 'error', message: err.message }); } }, @@ -469,7 +469,7 @@ module.exports = { await accessRecord.save(async (err) => { if(err) { console.error(err); - res.status(500).json({ status: 'error', message: err }); + res.status(500).json({ status: 'error', message: err.message }); } else { // If save has succeeded - send notifications // Send notifications to added/removed contributors @@ -491,7 +491,7 @@ module.exports = { ); } // Update workflow process if publisher requires it - if (accessRecord.datasets[0].publisher.workflowEnabled) { + if (accessRecord.datasets[0].publisher && accessRecord.datasets[0].publisher.workflowEnabled) { // Call Camunda controller to get current workflow process for application let response = await bpmController.getProcess(id); let { data = {} } = response; @@ -559,7 +559,7 @@ module.exports = { // 4. Update application to submitted status accessRecord.applicationStatus = 'submitted'; // Check if workflow/5 Safes based application, set final status date if status will never change again - if (!accessRecord.datasets[0].publisher.workflowEnabled) { + if (accessRecord.datasets[0].publisher === null || (accessRecord.datasets[0].publisher && !accessRecord.datasets[0].publisher.workflowEnabled)) { accessRecord.dateFinalStatus = new Date(); } let dateSubmitted = new Date(); @@ -567,13 +567,13 @@ module.exports = { await accessRecord.save(async(err) => { if(err) { console.error(err); - res.status(500).json({ status: 'error', message: err }); + res.status(500).json({ status: 'error', message: err.message }); } else { // If save has succeeded - send notifications // Send notifications and emails to custodian team and main applicant await module.exports.createNotifications('Submitted', {}, accessRecord, req.user); // Start workflow process if publisher requires it - if (accessRecord.datasets[0].publisher.workflowEnabled) { + if (accessRecord.datasets[0].publisher && accessRecord.datasets[0].publisher.workflowEnabled) { // Call Camunda controller to start workflow for submitted application let { name: publisher } = accessRecord.datasets[0].publisher; let { _id: userId } = req.user; @@ -588,7 +588,7 @@ module.exports = { .json({ status: 'success', data: accessRecord._doc }); } catch (err) { console.log(err.message); - res.status(500).json({ status: 'error', message: err }); + res.status(500).json({ status: 'error', message: err.message }); } }, From d149937485df07050e21b69aaf03800ccfae833c Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Tue, 27 Oct 2020 11:44:18 +0000 Subject: [PATCH 134/144] Upload files --- .gitignore | 2 + package.json | 2 + src/config/server.js | 7 - .../datarequest/datarequest.controller.js | 136 +++++++++++++++++- .../datarequest/datarequest.model.js | 15 +- .../datarequest/datarequest.route.js | 23 ++- src/resources/utilities/cloudStorage.util.js | 48 +++++++ 7 files changed, 220 insertions(+), 13 deletions(-) create mode 100644 src/resources/utilities/cloudStorage.util.js diff --git a/.gitignore b/.gitignore index 03138fe5..dea7e3c2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ # production /build +/tmp + # misc .DS_Store npm-debug.log* diff --git a/package.json b/package.json index ac414ec1..5cb76c3d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@google-cloud/monitoring": "^2.1.0", + "@google-cloud/storage": "^5.3.0", "@sendgrid/mail": "^7.1.0", "async": "^3.2.0", "await-to-js": "^2.1.1", @@ -31,6 +32,7 @@ "moment": "^2.27.0", "mongoose": "^5.9.12", "morgan": "^1.10.0", + "multer": "^1.4.2", "oidc-provider": "^6.29.3", "passport": "^0.4.1", "passport-google-oauth": "^2.0.0", diff --git a/src/config/server.js b/src/config/server.js index 57011dff..e6c56417 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -10,7 +10,6 @@ import bodyParser from 'body-parser'; import logger from 'morgan'; import passport from 'passport'; import cookieParser from 'cookie-parser'; - import { connectToDatabase } from './db'; import { initialiseAuthentication } from '../resources/auth'; @@ -20,20 +19,14 @@ const Account = require('./account'); const configuration = require('./configuration'); - const API_PORT = process.env.PORT || 3001; const session = require('express-session'); var app = express(); - - configuration.findAccount = Account.findAccount; const oidc = new Provider(process.env.api_url || 'http://localhost:3001', configuration); oidc.proxy = true; - - - var domains = [process.env.homeURL]; var rx = /^([http|https]+:\/\/[a-z]+)\.([^/]*)/; diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 57cdf4a7..6620a0ad 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1,11 +1,12 @@ import emailGenerator from '../utilities/emailGenerator.util'; import { DataRequestModel } from './datarequest.model'; import { WorkflowModel } from '../workflow/workflow.model'; -import { Data as ToolModel } from '../tool/data.model'; +import { Data, Data as ToolModel } from '../tool/data.model'; import { DataRequestSchemaModel } from './datarequest.schemas.model'; import workflowController from '../workflow/workflow.controller'; import helper from '../utilities/helper.util'; -import _ from 'lodash'; +import {processFile, getFile, fileStatus} from '../utilities/cloudStorage.util'; +import _, { filter } from 'lodash'; import { UserModel } from '../user/user.model'; import inputSanitizer from '../utilities/inputSanitizer'; import moment from 'moment'; @@ -19,6 +20,7 @@ const userTypes = { CUSTODIAN: 'custodian', APPLICANT: 'applicant', }; + const notificationTypes = { STATUSCHANGE: 'StatusChange', SUBMITTED: 'Submitted', @@ -29,6 +31,7 @@ const notificationTypes = { DEADLINEWARNING: 'DeadlineWarning', DEADLINEPASSED: 'DeadlinePassed', }; + const applicationStatuses = { SUBMITTED: 'submitted', INPROGRESS: 'inProgress', @@ -39,6 +42,7 @@ const applicationStatuses = { WITHDRAWN: 'withdrawn', }; + module.exports = { //GET api/v1/data-access-request getAccessRequestsByUser: async (req, res) => { @@ -112,6 +116,7 @@ module.exports = { populate: { path: 'publisher', populate: { path: 'team' } }, }, { path: 'workflow.steps.reviewers', select: 'firstname lastname' }, + { path: 'files.owner', select: 'firstname lastname' }, ]); // 3. If no matching application found, return 404 if (!accessRecord) { @@ -185,6 +190,7 @@ module.exports = { reviewSections, hasRecommended, workflow, + files: accessRecord.files || [] }, }); } catch (err) { @@ -283,6 +289,7 @@ module.exports = { userType: userTypes.APPLICANT, inReviewMode: false, reviewSections: [], + files: data.files || [] }, }); } catch (err) { @@ -310,10 +317,11 @@ module.exports = { userId, applicationStatus: applicationStatuses.INPROGRESS, }) - .populate({ + .populate([{ path: 'mainApplicant', select: 'firstname lastname -id -_id', - }) + }, + { path: 'files.owner', select: 'firstname lastname' }]) .sort({ createdAt: 1 }); // 4. Get datasets datasets = await ToolModel.find({ @@ -382,6 +390,7 @@ module.exports = { userType: userTypes.APPLICANT, inReviewMode: false, reviewSections: [], + files: data.files || [] }, }); } catch (err) { @@ -899,6 +908,125 @@ module.exports = { } }, + //POST api/v1/data-access-request/:id/upload + uploadFiles: async (req, res) => { + try { + // 1. get DAR ID + const { params: { id }} = req; + // 2. get files + let files = req.files; + // 3. descriptions and uniqueIds file from FE + let { descriptions, ids } = req.body; + // 4. get access record + let accessRecord = await DataRequestModel.findOne({ _id: id }); + if(!accessRecord) { + return res + .status(404) + .json({ status: 'error', message: 'Application not found.' }); + } + // 5. Check if requesting user is custodian member or applicant/contributor + // let { authorised } = module.exports.getUserPermissionsForApplication(accessRecord, req.user.id, req.user._id); + // 6. check authorisation + // if (!authorised) { + // return res + // .status(401) + // .json({ status: 'failure', message: 'Unauthorised' }); + // } + // 7. check files + if(_.isEmpty(files)) { + return res + .status(400) + .json({status: 'error', message: 'No files to upload'}); + } + let fileArr = []; + let descriptionArray = Array.isArray(descriptions); + // 8. process the files for scanning + for (let i = 0; i < files.length; i++) { + const response = await processFile(files[i], id); + // deconstruct response + let { file, status } = response; + // get description information + let description = descriptionArray ? descriptions[i] : descriptions; + // get uniqueId + let uniqueId = ids[i]; + console.log(files[i].originalname, description, uniqueId); + // setup fileArr for mongoo + let newFile = { + status: status.trim(), + description: description.trim(), + fileId: uniqueId.trim(), + size: files[i].size, + name: files[i].originalname, + owner: req.user._id, + error: status === fileStatus.ERROR ? 'Could not upload. Unknown error. Please try again.' : '' + }; + // update local for post back to FE + fileArr.push(newFile); + // mongoo db update files array + accessRecord.files.push(newFile); + } + // 9. write back into mongo [{userId, fileName, status: enum, size}] + const savedDataSet = await accessRecord.save(); + // 10. get the latest updates with the users + let updatedRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'files.owner', + select: 'firstname lastname id', + }]); + + // 11. process access record into object + let record = updatedRecord._doc; + // 12. fet files + let mediaFiles = record.files.map((f)=> { + return f._doc + }); + // 10. return response + return res.status(200).json({ status: 'success', mediaFiles }); + + } catch (err) { + console.log(err.message); + res.status(500).json({ status: 'error', message: err }); + } + }, + + //GET api/v1/data-access-request/:id/file/:fileId + getFile: async(req, res) => { + try { + // 1. get params + const { params: { id, fileId }} = req; + // 2. get AccessRecord + let accessRecord = await DataRequestModel.findOne({ _id: id }); + + if(!accessRecord) { + return res + .status(404) + .json({ status: 'error', message: 'Application not found.' }); + } + // 3. process access record into object + let record = accessRecord._doc; + // 4. find the file in the files array from db + let mediaFile = record.files.find((f)=> { + let {fileId: dbFileId} = f._doc; + return dbFileId === fileId + }) || {}; + // 5. no file return + if(_.isEmpty(mediaFile)) { + return res + .status(400) + .json({status: 'error', message: 'No file to download, please try again later'}); + } + // 6. get the name of the file + let { name } = mediaFile._doc; + // 7. get the file + const fullFile = await getFile(name, id); + // 8. send file back to user + return res.status(200).sendFile(`${process.env.TMPDIR}${id}/${name}`); + } catch(err) { + console.log(err); + res.status(500).json({ status: 'error', message: err }); + } + }, + //PUT api/v1/data-access-request/:id/vote updateAccessRequestReviewVote: async (req, res) => { try { diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 5a1b1c81..0f7d009e 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -44,7 +44,20 @@ const DataRequestSchema = new Schema({ publisher: { type: String, default: "" - } + }, + files: [{ + name: { type: String }, + size: { type: Number }, + description: { type: String }, + status: { type: String }, + fileId: { type: String }, + error: { type: String, default: '' }, + owner: { + type: Schema.Types.ObjectId, + ref: 'User' + } + }], + }, { timestamps: true, toJSON: { virtuals: true }, diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 6b998254..121fcfb5 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -1,8 +1,19 @@ import express from 'express'; import passport from 'passport'; import _ from 'lodash'; - +import multer from 'multer'; const datarequestController = require('./datarequest.controller'); +const fs = require('fs'); +const path = './tmp'; +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + if (!fs.existsSync(path)) { + fs.mkdirSync(path); + } + cb(null, path) + } +}) +const multerMid = multer({ storage: storage }); const router = express.Router(); @@ -26,6 +37,11 @@ router.get('/dataset/:dataSetId', passport.authenticate('jwt'), datarequestContr // @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer router.get('/datasets/:datasetIds', passport.authenticate('jwt'), datarequestController.getAccessRequestByUserAndMultipleDatasets); +// @route GET api/v1/data-access-request/:id/file/:fileId +// @desc GET +// @access Private +router.get('/:id/file/:fileId', passport.authenticate('jwt'), datarequestController.getFile); + // @route PATCH api/v1/data-access-request/:id // @desc Update application passing single object to update database entry with specified key // @access Private - Applicant (Gateway User) @@ -56,6 +72,11 @@ router.put('/:id/startreview', passport.authenticate('jwt'), datarequestControll // @access Private - Custodian Manager router.put('/:id/stepoverride', passport.authenticate('jwt'), datarequestController.updateAccessRequestStepOverride); +// @route POST api/v1/data-access-request/:id/upload +// @desc POST application files to scan bucket +// @access Private - Applicant (Gateway User / Custoidan Manager) +router.post('/:id/upload', passport.authenticate('jwt'), multerMid.array('assets'), datarequestController.uploadFiles); + // @route POST api/v1/data-access-request/:id // @desc Submit request record // @access Private - Applicant (Gateway User) diff --git a/src/resources/utilities/cloudStorage.util.js b/src/resources/utilities/cloudStorage.util.js new file mode 100644 index 00000000..0a6bd548 --- /dev/null +++ b/src/resources/utilities/cloudStorage.util.js @@ -0,0 +1,48 @@ +import {Storage} from '@google-cloud/storage'; +import fs from 'fs'; +const bucketName = process.env.SCAN_BUCKET; +const dir = process.env.TMPDIR; +const sourceBucket = process.env.DESTINATION_BUCKET; + +export const fileStatus = { + UPLOADED: 'UPLOADED', + ERROR: 'ERROR', + SCANNED: 'SCANNED' +}; + +export const processFile = (file, id) => new Promise(async (resolve, reject) => { + const storage = new Storage(); + let { originalname, path } = file; + storage.bucket(bucketName).upload(path, { + gzip: true, + destination: `dar-${id}-${originalname}`, + metadata: { cacheControl: 'none-cache'} + }, (err, file) => { + if(!err) { + // remove temp dir / path = dir + fs.unlinkSync(path); + // resolve + resolve({status: fileStatus.UPLOADED, file}); + } else { + resolve({status: fileStatus.ERROR, file}); + } + }); +}); + +export const getFile = (file, id) => new Promise(async (resolve) => { + // 1. new storage obj + const storage = new Storage(); + // 2. set option for file dest + let options = { + // The path to which the file should be downloaded + destination: `${process.env.TMPDIR}${id}/${file}`, + }; + // create tmp + if (!fs.existsSync(`${process.env.TMPDIR}${id}`)) { + fs.mkdirSync(`${process.env.TMPDIR}${id}`); + } + // 3. set path + const path = `dar/${id}/${file}`; + // 4. get file from GCP + resolve(storage.bucket(sourceBucket).file(path).download(options)); +}); From f27a1b9ccb53738ae2b8047cc984f0eb411cdf8a Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Thu, 29 Oct 2020 16:40:47 +0000 Subject: [PATCH 135/144] Update to file name and ids --- .../datarequest/datarequest.controller.js | 21 ++++++++++++------- src/resources/utilities/cloudStorage.util.js | 10 ++++----- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 6620a0ad..553286a1 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -939,22 +939,27 @@ module.exports = { .json({status: 'error', message: 'No files to upload'}); } let fileArr = []; + // check and see if descriptions and ids are an array let descriptionArray = Array.isArray(descriptions); + let idArray = Array.isArray(ids); // 8. process the files for scanning for (let i = 0; i < files.length; i++) { - const response = await processFile(files[i], id); - // deconstruct response - let { file, status } = response; // get description information let description = descriptionArray ? descriptions[i] : descriptions; // get uniqueId - let uniqueId = ids[i]; + let generatedId = idArray ? ids[i] : ids; + // remove - from uuidV4 + let uniqueId = generatedId.replace(/-/gmi, ''); + // send to db + const response = await processFile(files[i], id, uniqueId); + // deconstruct response + let { file, status } = response; console.log(files[i].originalname, description, uniqueId); // setup fileArr for mongoo let newFile = { status: status.trim(), description: description.trim(), - fileId: uniqueId.trim(), + fileId: uniqueId, size: files[i].size, name: files[i].originalname, owner: req.user._id, @@ -1016,11 +1021,11 @@ module.exports = { .json({status: 'error', message: 'No file to download, please try again later'}); } // 6. get the name of the file - let { name } = mediaFile._doc; + let { name, fileId: dbFileId } = mediaFile._doc; // 7. get the file - const fullFile = await getFile(name, id); + const fullFile = await getFile(name, dbFileId, id); // 8. send file back to user - return res.status(200).sendFile(`${process.env.TMPDIR}${id}/${name}`); + return res.status(200).sendFile(`${process.env.TMPDIR}${id}/${dbFileId}_${name}`); } catch(err) { console.log(err); res.status(500).json({ status: 'error', message: err }); diff --git a/src/resources/utilities/cloudStorage.util.js b/src/resources/utilities/cloudStorage.util.js index 0a6bd548..3607ff25 100644 --- a/src/resources/utilities/cloudStorage.util.js +++ b/src/resources/utilities/cloudStorage.util.js @@ -10,12 +10,12 @@ export const fileStatus = { SCANNED: 'SCANNED' }; -export const processFile = (file, id) => new Promise(async (resolve, reject) => { +export const processFile = (file, id, uniqueId) => new Promise(async (resolve, reject) => { const storage = new Storage(); let { originalname, path } = file; storage.bucket(bucketName).upload(path, { gzip: true, - destination: `dar-${id}-${originalname}`, + destination: `dar-${id}-${uniqueId}_${originalname}`, metadata: { cacheControl: 'none-cache'} }, (err, file) => { if(!err) { @@ -29,20 +29,20 @@ export const processFile = (file, id) => new Promise(async (resolve, reject) => }); }); -export const getFile = (file, id) => new Promise(async (resolve) => { +export const getFile = (file, fileId, id) => new Promise(async (resolve) => { // 1. new storage obj const storage = new Storage(); // 2. set option for file dest let options = { // The path to which the file should be downloaded - destination: `${process.env.TMPDIR}${id}/${file}`, + destination: `${process.env.TMPDIR}${id}/${fileId}_${file}`, }; // create tmp if (!fs.existsSync(`${process.env.TMPDIR}${id}`)) { fs.mkdirSync(`${process.env.TMPDIR}${id}`); } // 3. set path - const path = `dar/${id}/${file}`; + const path = `dar/${id}/${fileId}_${file}`; // 4. get file from GCP resolve(storage.bucket(sourceBucket).file(path).download(options)); }); From 004690a36c11ef8291f491881cdc24d28cc79758 Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Thu, 29 Oct 2020 17:02:45 +0000 Subject: [PATCH 136/144] Fixed scan issues --- src/resources/datarequest/datarequest.controller.js | 11 +++++------ src/resources/utilities/cloudStorage.util.js | 1 - 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 553286a1..6a2cc06b 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1,12 +1,12 @@ import emailGenerator from '../utilities/emailGenerator.util'; import { DataRequestModel } from './datarequest.model'; import { WorkflowModel } from '../workflow/workflow.model'; -import { Data, Data as ToolModel } from '../tool/data.model'; +import { Data as ToolModel } from '../tool/data.model'; import { DataRequestSchemaModel } from './datarequest.schemas.model'; import workflowController from '../workflow/workflow.controller'; import helper from '../utilities/helper.util'; import {processFile, getFile, fileStatus} from '../utilities/cloudStorage.util'; -import _, { filter } from 'lodash'; +import _ from 'lodash'; import { UserModel } from '../user/user.model'; import inputSanitizer from '../utilities/inputSanitizer'; import moment from 'moment'; @@ -953,8 +953,7 @@ module.exports = { // send to db const response = await processFile(files[i], id, uniqueId); // deconstruct response - let { file, status } = response; - console.log(files[i].originalname, description, uniqueId); + let { status } = response; // setup fileArr for mongoo let newFile = { status: status.trim(), @@ -971,7 +970,7 @@ module.exports = { accessRecord.files.push(newFile); } // 9. write back into mongo [{userId, fileName, status: enum, size}] - const savedDataSet = await accessRecord.save(); + await accessRecord.save(); // 10. get the latest updates with the users let updatedRecord = await DataRequestModel.findOne({ _id: id }).populate([ { @@ -1023,7 +1022,7 @@ module.exports = { // 6. get the name of the file let { name, fileId: dbFileId } = mediaFile._doc; // 7. get the file - const fullFile = await getFile(name, dbFileId, id); + await getFile(name, dbFileId, id); // 8. send file back to user return res.status(200).sendFile(`${process.env.TMPDIR}${id}/${dbFileId}_${name}`); } catch(err) { diff --git a/src/resources/utilities/cloudStorage.util.js b/src/resources/utilities/cloudStorage.util.js index 3607ff25..f7a81ea3 100644 --- a/src/resources/utilities/cloudStorage.util.js +++ b/src/resources/utilities/cloudStorage.util.js @@ -1,7 +1,6 @@ import {Storage} from '@google-cloud/storage'; import fs from 'fs'; const bucketName = process.env.SCAN_BUCKET; -const dir = process.env.TMPDIR; const sourceBucket = process.env.DESTINATION_BUCKET; export const fileStatus = { From d4fca33a4a3cb5e4893bca20d657189182f58a23 Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Thu, 29 Oct 2020 17:44:17 +0000 Subject: [PATCH 137/144] Sanitise checker --- src/resources/datarequest/datarequest.controller.js | 1 - src/resources/utilities/cloudStorage.util.js | 10 +++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 6a2cc06b..8d4757f8 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1000,7 +1000,6 @@ module.exports = { const { params: { id, fileId }} = req; // 2. get AccessRecord let accessRecord = await DataRequestModel.findOne({ _id: id }); - if(!accessRecord) { return res .status(404) diff --git a/src/resources/utilities/cloudStorage.util.js b/src/resources/utilities/cloudStorage.util.js index f7a81ea3..250b6806 100644 --- a/src/resources/utilities/cloudStorage.util.js +++ b/src/resources/utilities/cloudStorage.util.js @@ -37,11 +37,15 @@ export const getFile = (file, fileId, id) => new Promise(async (resolve) => { destination: `${process.env.TMPDIR}${id}/${fileId}_${file}`, }; // create tmp - if (!fs.existsSync(`${process.env.TMPDIR}${id}`)) { - fs.mkdirSync(`${process.env.TMPDIR}${id}`); + const sanitisedId = id.replace( /[^0-9a-z]/ig,''); + + const filePath = `${process.env.TMPDIR}${sanitisedId}`; + + if (!fs.existsSync(filePath)) { + fs.mkdirSync(filePath); } // 3. set path - const path = `dar/${id}/${fileId}_${file}`; + const path = `dar/${sanitisedId}/${fileId}_${file}`; // 4. get file from GCP resolve(storage.bucket(sourceBucket).file(path).download(options)); }); From 9a0069ee810ee3d86c4d4c01b7ea11677bf3f7ff Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Thu, 29 Oct 2020 17:51:03 +0000 Subject: [PATCH 138/144] Init id on params list --- src/resources/datarequest/datarequest.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 8d4757f8..c3ebf7cd 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -997,7 +997,7 @@ module.exports = { getFile: async(req, res) => { try { // 1. get params - const { params: { id, fileId }} = req; + const { params: { id = 0, fileId = 0}} = req; // 2. get AccessRecord let accessRecord = await DataRequestModel.findOne({ _id: id }); if(!accessRecord) { From 7095dafa989e37a6c0e965a09a3188c687a04f5a Mon Sep 17 00:00:00 2001 From: Chris Marks Date: Thu, 29 Oct 2020 18:07:41 +0000 Subject: [PATCH 139/144] Sanitise middleware --- package.json | 1 + src/resources/datarequest/datarequest.controller.js | 3 ++- src/resources/datarequest/datarequest.route.js | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5cb76c3d..37e3b308 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "express": "^4.17.1", "express-rate-limit": "^5.1.3", "express-session": "^1.17.1", + "express-validator": "^6.6.1", "googleapis": "^55.0.0", "jose": "^2.0.2", "jsonwebtoken": "^8.5.1", diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index c3ebf7cd..f7076b5f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -997,7 +997,8 @@ module.exports = { getFile: async(req, res) => { try { // 1. get params - const { params: { id = 0, fileId = 0}} = req; + const { params: { id, fileId }} = req; + // 2. get AccessRecord let accessRecord = await DataRequestModel.findOne({ _id: id }); if(!accessRecord) { diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 121fcfb5..878501f2 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -2,6 +2,7 @@ import express from 'express'; import passport from 'passport'; import _ from 'lodash'; import multer from 'multer'; +import { param } from 'express-validator'; const datarequestController = require('./datarequest.controller'); const fs = require('fs'); const path = './tmp'; @@ -40,7 +41,7 @@ router.get('/datasets/:datasetIds', passport.authenticate('jwt'), datarequestCon // @route GET api/v1/data-access-request/:id/file/:fileId // @desc GET // @access Private -router.get('/:id/file/:fileId', passport.authenticate('jwt'), datarequestController.getFile); +router.get('/:id/file/:fileId', param('id').customSanitizer(value => {return value}), passport.authenticate('jwt'), datarequestController.getFile); // @route PATCH api/v1/data-access-request/:id // @desc Update application passing single object to update database entry with specified key From 27a1398cc80576218f129fc76ed11154b8826af7 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Fri, 30 Oct 2020 09:30:20 +0000 Subject: [PATCH 140/144] Updates to filters and search --- src/resources/course/course.repository.js | 5 +- src/resources/search/filter.route.js | 64 ++++++++-- src/resources/search/search.repository.js | 136 ++++++++++++++++------ 3 files changed, 161 insertions(+), 44 deletions(-) diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index b73b8c99..5b656137 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -33,7 +33,8 @@ const addCourse = async (req, res) => { if (req.body.courseOptions) { req.body.courseOptions.forEach((x) => { - x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); + if (x.flexibleDates) x.startDate = null; + //else x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); x.studyDurationNumber = x.studyDurationNumber; x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); @@ -142,7 +143,7 @@ const editCourse = async (req, res) => { if (req.body.courseOptions) { req.body.courseOptions.forEach((x) => { if (x.flexibleDates) x.startDate = null; - else x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); + //else x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); x.studyDurationNumber = x.studyDurationNumber; x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); diff --git a/src/resources/search/filter.route.js b/src/resources/search/filter.route.js index 3b90c977..a32d9642 100644 --- a/src/resources/search/filter.route.js +++ b/src/resources/search/filter.route.js @@ -227,15 +227,15 @@ router.get('/', async (req, res) => { }, filterOptions: { courseStartDatesFilterOptions: values[0][1], - courseProviderFilterOptions: values[1][0], - courseLocationFilterOptions: values[2][0], - courseStudyModeFilterOptions: values[3][0], - courseAwardFilterOptions: values[4][0], - courseEntryLevelFilterOptions: values[5][0], - courseDomainsFilterOptions: values[6][0], - courseKeywordsFilterOptions: values[7][0], - courseFrameworkFilterOptions: values[8][0], - coursePriorityFilterOptions: values[9][0] + courseProviderFilterOptions: values[1][1], + courseLocationFilterOptions: values[2][1], + courseStudyModeFilterOptions: values[3][1], + courseAwardFilterOptions: values[4][1], + courseEntryLevelFilterOptions: values[5][1], + courseDomainsFilterOptions: values[6][1], + courseKeywordsFilterOptions: values[7][1], + courseFrameworkFilterOptions: values[8][1], + coursePriorityFilterOptions: values[9][1] } }); }); @@ -334,4 +334,50 @@ router.get('/organisation/:type', }); } ); + +// @route GET api/v1/search/filter/domains/:type +// @desc GET Get list of features by entity type +// @access Public +router.get('/domains/:type', + async (req, res) => { + await getFilter('', req.params.type, 'domains', true, getObjectFilters({ $and: [{ activeflag: 'active' }] }, req, req.params.type)) + .then(data => { + return res.json({success: true, data}); + }) + .catch(err => { + return res.json({success: false, err}); + }); + } +); + +// @route GET api/v1/search/filter/keywords/:type +// @desc GET Get list of features by entity type +// @access Public +router.get('/keywords/:type', + async (req, res) => { + await getFilter('', req.params.type, 'keywords', true, getObjectFilters({ $and: [{ activeflag: 'active' }] }, req, req.params.type)) + .then(data => { + return res.json({success: true, data}); + }) + .catch(err => { + return res.json({success: false, err}); + }); + } +); + +// @route GET api/v1/search/filter/awards/:type +// @desc GET Get list of features by entity type +// @access Public +router.get('/awards/:type', + async (req, res) => { + await getFilter('', req.params.type, 'award', true, getObjectFilters({ $and: [{ activeflag: 'active' }] }, req, req.params.type)) + .then(data => { + return res.json({success: true, data}); + }) + .catch(err => { + return res.json({success: false, err}); + }); + } +); + module.exports = router; \ No newline at end of file diff --git a/src/resources/search/search.repository.js b/src/resources/search/search.repository.js index f97d011c..97194dc2 100644 --- a/src/resources/search/search.repository.js +++ b/src/resources/search/search.repository.js @@ -8,11 +8,22 @@ export function getObjectResult(type, searchAll, searchQuery, startIndex, maxRes if (type === 'course') collection = Course; var newSearchQuery = JSON.parse(JSON.stringify(searchQuery)); newSearchQuery["$and"].push({ type: type }) - if (type === 'course') newSearchQuery["$and"].push({$or:[{"courseOptions.startDate": { $gt: new Date(Date.now())}}, { 'courseOptions.flexibleDates':true}]}); + + if (type === 'course') { + newSearchQuery["$and"].forEach((x) => { + if (x.$or) { + x.$or.forEach((y) => { + if (y['courseOptions.startDate']) y['courseOptions.startDate'] = new Date (y['courseOptions.startDate']) + }) + } + }) + newSearchQuery["$and"].push({$or:[{"courseOptions.startDate": { $gte: new Date(Date.now())}}, { 'courseOptions.flexibleDates':true}]}); + } var queryObject; if (type === 'course') { queryObject = [ + { $unwind: '$courseOptions' }, { $match: newSearchQuery }, { $project: { @@ -104,45 +115,100 @@ export function getObjectCount(type, searchAll, searchQuery) { if (type === 'course') collection = Course; var newSearchQuery = JSON.parse(JSON.stringify(searchQuery)); newSearchQuery["$and"].push({ type: type }) - var q = ''; + if (type === 'course') { + newSearchQuery["$and"].forEach((x) => { + if (x.$or) { + x.$or.forEach((y) => { + if (y['courseOptions.startDate']) y['courseOptions.startDate'] = new Date (y['courseOptions.startDate']) + }) + } + }) + newSearchQuery["$and"].push({$or:[{"courseOptions.startDate": { $gte: new Date(Date.now())}}, { 'courseOptions.flexibleDates':true}]}); + } - if (searchAll) { - q = collection.aggregate([ - { $match: newSearchQuery }, - { - "$group": { - "_id": {}, - "count": { - "$sum": 1 + //if (type === 'course') newSearchQuery["$and"].push({$or:[{"courseOptions.startDate": { $gte: new Date(Date.now())}}, { 'courseOptions.flexibleDates':true}]}); + var q = ''; + if (type === 'course') { + if (searchAll) { + q = collection.aggregate([ + { $unwind: '$courseOptions' }, + { $match: newSearchQuery }, + { + "$group": { + "_id": {}, + "count": { + "$sum": 1 + } + } + }, + { + "$project": { + "count": "$count", + "_id": 0 } } - }, - { - "$project": { - "count": "$count", - "_id": 0 + ]); + } + else { + q = collection.aggregate([ + { $unwind: '$courseOptions' }, + { $match: newSearchQuery }, + { + "$group": { + "_id": {}, + "count": { + "$sum": 1 + } + } + }, + { + "$project": { + "count": "$count", + "_id": 0 + } } - } - ]); + ]).sort({ score: { $meta: "textScore" } }); + } } else { - q = collection.aggregate([ - { $match: newSearchQuery }, - { - "$group": { - "_id": {}, - "count": { - "$sum": 1 + if (searchAll) { + q = collection.aggregate([ + { $match: newSearchQuery }, + { + "$group": { + "_id": {}, + "count": { + "$sum": 1 + } + } + }, + { + "$project": { + "count": "$count", + "_id": 0 } } - }, - { - "$project": { - "count": "$count", - "_id": 0 + ]); + } + else { + q = collection.aggregate([ + { $match: newSearchQuery }, + { + "$group": { + "_id": {}, + "count": { + "$sum": 1 + } + } + }, + { + "$project": { + "count": "$count", + "_id": 0 + } } - } - ]).sort({ score: { $meta: "textScore" } }); + ]).sort({ score: { $meta: "textScore" } }); + } } return new Promise((resolve, reject) => { @@ -301,8 +367,7 @@ export function getObjectFilters(searchQueryStart, req, type) { if (coursestartdates.length > 0) { var filterTermArray = []; coursestartdates.split('::').forEach((filterTerm) => { - const d = new Date(filterTerm); - filterTermArray.push({ "courseOptions.startDate": new Date(d) }) + filterTermArray.push({ "courseOptions.startDate": filterTerm }) }); searchQuery["$and"].push({ "$or": filterTermArray }); } @@ -430,6 +495,11 @@ export const getFilter = async (searchString, type, field, isArray, activeFilter export function filterQueryGenerator(filter, searchString, type, isArray, activeFiltersQuery) { var queryArray = [] + if (type === "course") { + queryArray.push({ $unwind: '$courseOptions' }); + queryArray.push({ $match: {$or:[{"courseOptions.startDate": { $gte: new Date(Date.now())}}, { 'courseOptions.flexibleDates':true}]}}); + } + if (!_.isEmpty(activeFiltersQuery)) { queryArray.push({ $match: activeFiltersQuery}); } From 7f9b7dc20b91fd0cba07b6569f441a277e45edc7 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Fri, 30 Oct 2020 09:36:42 +0000 Subject: [PATCH 141/144] Adding in extra logout uris --- src/config/configuration.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/configuration.js b/src/config/configuration.js index 157d39c2..326081a0 100755 --- a/src/config/configuration.js +++ b/src/config/configuration.js @@ -25,7 +25,7 @@ export const clients = [ //response_types: ['code id_token'], redirect_uris: process.env.MDWRedirectURI.split(",") || [''], id_token_signed_response_alg: 'HS256', - post_logout_redirect_uris: ['https://hdr.auth.metadata.works/logout','http://localhost:8080/logout'] + post_logout_redirect_uris: ['https://hdr.auth.metadata.works/logout','https://hdr.auth.metadata.works/auth/logout','http://localhost:8080/logout','http://localhost:8080/auth/logout'] }, { //BC Platforms From 0648569bfd137ff70a172a3cd227da11470f82ba Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Fri, 30 Oct 2020 13:57:34 +0000 Subject: [PATCH 142/144] Updates to remove comments --- src/resources/course/course.repository.js | 10 +--------- src/resources/search/search.repository.js | 1 - 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index 5b656137..a3001fa9 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -34,7 +34,6 @@ const addCourse = async (req, res) => { if (req.body.courseOptions) { req.body.courseOptions.forEach((x) => { if (x.flexibleDates) x.startDate = null; - //else x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); x.studyDurationNumber = x.studyDurationNumber; x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); @@ -143,7 +142,6 @@ const editCourse = async (req, res) => { if (req.body.courseOptions) { req.body.courseOptions.forEach((x) => { if (x.flexibleDates) x.startDate = null; - //else x.startDate = inputSanitizer.removeNonBreakingSpaces(x.startDate); x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); x.studyDurationNumber = x.studyDurationNumber; x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); @@ -156,13 +154,7 @@ const editCourse = async (req, res) => { }); } - /* let data = { - id: id, - name: req.body.title, - authors: authors, - }; */ - - Course.findOneAndUpdate({ id: id }, + Course.findOneAndUpdate({ id: id }, { title: inputSanitizer.removeNonBreakingSpaces(req.body.title), link: urlValidator.validateURL(inputSanitizer.removeNonBreakingSpaces(req.body.link)), diff --git a/src/resources/search/search.repository.js b/src/resources/search/search.repository.js index 97194dc2..1c44390b 100644 --- a/src/resources/search/search.repository.js +++ b/src/resources/search/search.repository.js @@ -126,7 +126,6 @@ export function getObjectCount(type, searchAll, searchQuery) { newSearchQuery["$and"].push({$or:[{"courseOptions.startDate": { $gte: new Date(Date.now())}}, { 'courseOptions.flexibleDates':true}]}); } - //if (type === 'course') newSearchQuery["$and"].push({$or:[{"courseOptions.startDate": { $gte: new Date(Date.now())}}, { 'courseOptions.flexibleDates':true}]}); var q = ''; if (type === 'course') { if (searchAll) { From f99a68feff41d42ecb154a4e5cb408ca29f711f2 Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Fri, 30 Oct 2020 14:16:39 +0000 Subject: [PATCH 143/144] Updates for LGTM --- src/resources/course/course.repository.js | 23 +++++++++++---------- src/resources/course/course.route.js | 10 ++------- src/resources/course/coursecounter.route.js | 9 +++++++- 3 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index a3001fa9..ce95f6ab 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -35,7 +35,6 @@ const addCourse = async (req, res) => { req.body.courseOptions.forEach((x) => { if (x.flexibleDates) x.startDate = null; x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); - x.studyDurationNumber = x.studyDurationNumber; x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); if (x.fees) { x.fees.forEach((y) => { @@ -143,7 +142,6 @@ const editCourse = async (req, res) => { req.body.courseOptions.forEach((x) => { if (x.flexibleDates) x.startDate = null; x.studyMode = inputSanitizer.removeNonBreakingSpaces(x.studyMode); - x.studyDurationNumber = x.studyDurationNumber; x.studyDurationMeasure = inputSanitizer.removeNonBreakingSpaces(x.studyDurationMeasure); if (x.fees) { x.fees.forEach((y) => { @@ -154,6 +152,10 @@ const editCourse = async (req, res) => { }); } + let relatedObjects = req.body.relatedObjects; + let courseOptions = req.body.courseOptions; + let entries = req.body.entries; + Course.findOneAndUpdate({ id: id }, { title: inputSanitizer.removeNonBreakingSpaces(req.body.title), @@ -164,9 +166,9 @@ const editCourse = async (req, res) => { location: inputSanitizer.removeNonBreakingSpaces(req.body.location), keywords: inputSanitizer.removeNonBreakingSpaces(req.body.keywords), domains: inputSanitizer.removeNonBreakingSpaces(req.body.domains), - relatedObjects: req.body.relatedObjects, - courseOptions: req.body.courseOptions, - entries:req.body.entries, + relatedObjects: relatedObjects, + courseOptions: courseOptions, + entries:entries, restrictions: inputSanitizer.removeNonBreakingSpaces(req.body.restrictions), award: inputSanitizer.removeNonBreakingSpaces(req.body.award), competencyFramework: inputSanitizer.removeNonBreakingSpaces(req.body.competencyFramework), @@ -244,17 +246,17 @@ const editCourse = async (req, res) => { const getCourse = async (req, res) => { return new Promise(async (resolve, reject) => { - let startIndex = 0; - let limit = 1000; + //let startIndex = 0; + //let limit = 1000; let typeString = ""; let idString = req.user.id; - if (req.query.startIndex) { + /* if (req.query.startIndex) { startIndex = req.query.startIndex; } if (req.query.limit) { limit = req.query.limit; - } + } */ if (req.params.type) { typeString = req.params.type; } @@ -266,7 +268,7 @@ const editCourse = async (req, res) => { { $match: { $and: [{ type: typeString }, { authors: parseInt(idString) }] } }, { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "creator" } }, { $sort: { updatedAt : -1}} - ])//.skip(parseInt(startIndex)).limit(parseInt(maxResults)); + ]);//.skip(parseInt(startIndex)).limit(parseInt(maxResults)); query.exec((err, data) => { if (err) reject({ success: false, error: err }); resolve(data); @@ -407,7 +409,6 @@ async function sendEmailNotificationToAuthors(tool, toolOwner) { async function storeNotificationsForAuthors(tool, toolOwner) { //store messages to alert a user has been added as an author - const toolLink = process.env.homeURL + '/tool/' + tool.id //normal user var toolCopy = JSON.parse(JSON.stringify(tool)); diff --git a/src/resources/course/course.route.js b/src/resources/course/course.route.js index 1818d7aa..02d4dd58 100644 --- a/src/resources/course/course.route.js +++ b/src/resources/course/course.route.js @@ -4,19 +4,12 @@ import { Data } from '../tool/data.model'; import { Course } from './course.model'; import passport from 'passport'; import { utils } from '../auth'; -import { UserModel } from '../user/user.model'; -import { MessagesModel } from '../message/message.model'; import { addCourse, editCourse, - deleteCourse, setStatus, - getCourse, getCourseAdmin, } from './course.repository'; -import emailGenerator from '../utilities/emailGenerator.util'; -import inputSanitizer from '../utilities/inputSanitizer'; -const hdrukEmail = `enquiry@healthdatagateway.org`; const router = express.Router(); // @router POST /api/v1/course @@ -126,6 +119,7 @@ router.patch( * Return the details on the tool based on the tool ID. */ router.get('/:id', async (req, res) => { + let id = parseInt(req.params.id) var query = Course.aggregate([ { $match: { id: parseInt(req.params.id) } }, { @@ -172,7 +166,7 @@ router.get('/:id', async (req, res) => { }); }); } else { - return res.status(404).send(`Course not found for Id: ${req.params.id}`); + return res.status(404).send(`Course not found for Id: ${id}`); } }); }); diff --git a/src/resources/course/coursecounter.route.js b/src/resources/course/coursecounter.route.js index 8a9d6f25..5017f68d 100644 --- a/src/resources/course/coursecounter.route.js +++ b/src/resources/course/coursecounter.route.js @@ -1,9 +1,16 @@ import express from "express"; import { Course } from "./course.model"; +const rateLimit = require("express-rate-limit"); const router = express.Router(); -router.post("/update", async (req, res) => { +const datasetLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour window + max: 10, // start blocking after 10 requests + message: "Too many calls have been made to this api from this IP, please try again after an hour" +}); + +router.post("/update", datasetLimiter, async (req, res) => { const { id, counter } = req.body; Course.findOneAndUpdate({ id: id }, { counter: counter }, err => { if (err) return res.json({ success: false, error: err }); From 5d5d906fea40d385c7125843ccdc55bbf0c58f8e Mon Sep 17 00:00:00 2001 From: Paul McCafferty Date: Mon, 2 Nov 2020 15:21:44 +0000 Subject: [PATCH 144/144] Updates to notifications --- src/resources/course/course.repository.js | 129 +++++++------------ src/resources/course/course.route.js | 3 +- src/resources/discourse/discourse.service.js | 7 + src/resources/message/message.model.js | 3 +- src/resources/message/message.route.js | 6 +- 5 files changed, 64 insertions(+), 84 deletions(-) diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index ce95f6ab..5ffd3fd8 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -72,51 +72,10 @@ const addCourse = async (req, res) => { if(!newCourse) reject(new Error(`Can't persist data object to DB.`)); - /* let message = new MessagesModel(); - message.messageID = parseInt(Math.random().toString().replace('0.', '')); - message.messageTo = 0; - message.messageObjectID = course.id; - message.messageType = 'add'; - message.messageDescription = `Approval needed: new ${course.type} added ${course.title}` - message.messageSent = Date.now(); - message.isRead = false; - let newMessageObj = await message.save(); - if(!newMessageObj) - reject(new Error(`Can't persist message to DB.`)); - - // 1. Generate URL for linking tool from email - const courseLink = process.env.homeURL + '/' + course.type + '/' + course.id - - // 2. Query Db for all admins who have opted in to email updates - var q = UserModel.aggregate([ - // Find all users who are admins - { $match: { role: 'Admin' } }, - // Perform lookup to check opt in/out flag in tools schema - { $lookup: { from: 'tools', localField: 'id', foreignField: 'id', as: 'tool' } }, - // Filter out any user who has opted out of email notifications - { $match: { 'tool.emailNotifications': true } }, - // Reduce response payload size to required fields - { $project: { _id: 1, firstname: 1, lastname: 1, email: 1, role: 1, 'tool.emailNotifications': 1 } } - ]); - - // 3. Use the returned array of email recipients to generate and send emails with SendGrid - q.exec((err, emailRecipients) => { - if (err) { - return new Error({ success: false, error: err }); - } - emailGenerator.sendEmail( - emailRecipients, - `${hdrukEmail}`, - `A new ${course.type} has been added and is ready for review`, - `Approval needed: new ${course.type} ${course.name}

${courseLink}` - ); - }); - - if (course.type === 'course') { - //await sendEmailNotificationToAuthors(course, course.creator); - } - //await storeNotificationsForAuthors(course, course.creator); - */ + await createMessage(course.creator, course.id, course.title, course.type, 'add'); + await createMessage(0, course.id, course.title, course.type, 'add'); + // Send email notification of status update to admins and authors who have opted in + await sendEmailNotifications(course, 'add'); resolve(newCourse); }) }; @@ -177,16 +136,17 @@ const editCourse = async (req, res) => { if (err) { reject(new Error(`Failed to update.`)); } - }).then((tool) => { - if(tool == null){ + }).then(async (course) => { + if(course == null){ reject(new Error(`No record found with id of ${id}.`)); } - else if (req.body.type === 'tool') { - // Send email notification of update to all authors who have opted in to updates - //sendEmailNotificationToAuthors(data, toolCreator); - //storeNotificationsForAuthors(data, toolCreator); - } - resolve(tool); + + await createMessage(course.creator, id, course.title, course.type, 'edit'); + await createMessage(0, id, course.title, course.type, 'edit'); + // Send email notification of status update to admins and authors who have opted in + await sendEmailNotifications(course, 'edit'); + + resolve(course); }); }) }; @@ -198,8 +158,8 @@ const editCourse = async (req, res) => { if (err) reject(err); - }).then((tool) => { - if(tool == null){ + }).then((course) => { + if(course == null){ reject(`No Content`); } else{ @@ -248,7 +208,6 @@ const editCourse = async (req, res) => { return new Promise(async (resolve, reject) => { //let startIndex = 0; //let limit = 1000; - let typeString = ""; let idString = req.user.id; /* if (req.query.startIndex) { @@ -257,16 +216,13 @@ const editCourse = async (req, res) => { if (req.query.limit) { limit = req.query.limit; } */ - if (req.params.type) { - typeString = req.params.type; - } if (req.query.id) { idString = req.query.id; } let query = Course.aggregate([ - { $match: { $and: [{ type: typeString }, { authors: parseInt(idString) }] } }, - { $lookup: { from: "tools", localField: "authors", foreignField: "id", as: "creator" } }, + { $match: { $and: [{ type: 'course' }, { creator: parseInt(idString) }] } }, + { $lookup: { from: "tools", localField: "creator", foreignField: "id", as: "persons" } }, { $sort: { updatedAt : -1}} ]);//.skip(parseInt(startIndex)).limit(parseInt(maxResults)); query.exec((err, data) => { @@ -282,24 +238,21 @@ const editCourse = async (req, res) => { const { activeflag, rejectionReason } = req.body; const id = req.params.id; - let tool = await Course.findOneAndUpdate({ id: id }, { $set: { activeflag: activeflag } }); - if (!tool) { - reject(new Error('Tool not found')); + let course = await Course.findOneAndUpdate({ id: id }, { $set: { activeflag: activeflag } }); + if (!course) { + reject(new Error('Course not found')); } - if (tool.authors) { - tool.authors.forEach(async (authorId) => { - await createMessage(authorId, id, tool.name, tool.type, activeflag, rejectionReason); - }); - } - await createMessage(0, id, tool.name, tool.type, activeflag, rejectionReason); + + await createMessage(course.creator, id, course.title, course.type, activeflag, rejectionReason); + await createMessage(0, id, course.title, course.type, activeflag, rejectionReason); - if (!tool.discourseTopicId && tool.activeflag === 'active') { - await createDiscourseTopic(tool); + if (!course.discourseTopicId && course.activeflag === 'active') { + await createDiscourseTopic(course); } // Send email notification of status update to admins and authors who have opted in - await sendEmailNotifications(tool, activeflag, rejectionReason); + await sendEmailNotifications(course, activeflag, rejectionReason); resolve(id); @@ -313,7 +266,7 @@ const editCourse = async (req, res) => { async function createMessage(authorId, toolId, toolName, toolType, activeflag, rejectionReason) { let message = new MessagesModel(); const toolLink = process.env.homeURL + '/' + toolType + '/' + toolId; - + if (activeflag === 'active') { message.messageType = 'approved'; message.messageDescription = `Your ${toolType} ${toolName} has been approved and is now live ${toolLink}` @@ -325,6 +278,14 @@ const editCourse = async (req, res) => { message.messageDescription = `Your ${toolType} ${toolName} has been rejected ${toolLink}` message.messageDescription = (rejectionReason) ? message.messageDescription.concat(` Rejection reason: ${rejectionReason}`) : message.messageDescription } + else if (activeflag === 'add') { + message.messageType = 'add'; + message.messageDescription = `Your ${toolType} ${toolName} has been submitted for approval` + } + else if (activeflag === 'edit') { + message.messageType = 'edit'; + message.messageDescription = `Your ${toolType} ${toolName} has been updated` + } message.messageID = parseInt(Math.random().toString().replace('0.', '')); message.messageTo = authorId; message.messageObjectID = toolId; @@ -350,6 +311,14 @@ const editCourse = async (req, res) => { subject = `Your ${tool.type} ${tool.name} has been rejected` html = `Your ${tool.type} ${tool.name} has been rejected

Rejection reason: ${rejectionReason}

${toolLink}` } + else if (activeflag === 'add') { + subject = `Your ${tool.type} ${tool.name} has been submitted for approval` + html = `Your ${tool.type} ${tool.name} has been submitted for approval

${toolLink}` + } + else if (activeflag === 'edit') { + subject = `Your ${tool.type} ${tool.name} has been updated` + html = `Your ${tool.type} ${tool.name} has been updated

${toolLink}` + } // 3. Find all authors of the tool who have opted in to email updates var q = UserModel.aggregate([ @@ -379,12 +348,12 @@ const editCourse = async (req, res) => { async function sendEmailNotificationToAuthors(tool, toolOwner) { // 1. Generate tool URL for linking user from email - const toolLink = process.env.homeURL + '/tool/' + tool.id + const toolLink = process.env.homeURL + '/course/' + tool.id // 2. Find all authors of the tool who have opted in to email updates var q = UserModel.aggregate([ // Find all authors of this tool - { $match: { id: { $in: tool.authors } } }, + { $match: { id: tool.creator } }, // Perform lookup to check opt in/out flag in tools schema { $lookup: { from: 'tools', localField: 'id', foreignField: 'id', as: 'tool' } }, // Filter out any user who has opted out of email notifications @@ -412,14 +381,14 @@ async function storeNotificationsForAuthors(tool, toolOwner) { //normal user var toolCopy = JSON.parse(JSON.stringify(tool)); + var listToEmail = [toolCopy.creator]; - toolCopy.authors.push(0); - asyncModule.eachSeries(toolCopy.authors, async (author) => { - + asyncModule.eachSeries(listToEmail, async (author) => { + const user = await UserModel.findById(author) let message = new MessagesModel(); message.messageType = 'author'; message.messageSent = Date.now(); - message.messageDescription = `${toolOwner.name} added you as an author of the ${toolCopy.type} ${toolCopy.name}` + message.messageDescription = `${toolOwner.name} added you as an author of the ${toolCopy.type} ${toolCopy.title}` message.isRead = false; message.messageObjectID = toolCopy.id; message.messageID = parseInt(Math.random().toString().replace('0.', '')); diff --git a/src/resources/course/course.route.js b/src/resources/course/course.route.js index 02d4dd58..d002b841 100644 --- a/src/resources/course/course.route.js +++ b/src/resources/course/course.route.js @@ -9,6 +9,7 @@ import { editCourse, setStatus, getCourseAdmin, + getCourse } from './course.repository'; const router = express.Router(); @@ -67,7 +68,7 @@ router.get( return res.json({ success: false, err }); }); } else if (role === ROLES.Creator) { - await getCourseTools(req) + await getCourse(req) .then((data) => { return res.json({ success: true, data }); }) diff --git a/src/resources/discourse/discourse.service.js b/src/resources/discourse/discourse.service.js index 5698056f..8d26f44c 100644 --- a/src/resources/discourse/discourse.service.js +++ b/src/resources/discourse/discourse.service.js @@ -84,11 +84,18 @@ export async function createDiscourseTopic(tool) { rawIs = `${tool.description}

Original content: ${process.env.homeURL}/paper/${tool.id}`; categoryIs = process.env.DISCOURSE_CATEGORY_PAPERS_ID; } + else if (tool.type === 'course') { + rawIs = `${tool.description}

Original content: ${process.env.homeURL}/course/${tool.id}`; + categoryIs = process.env.DISCOURSE_CATEGORY_COURSES_ID; + } else if (tool.type === 'collection') { rawIs = `${tool.description}

Original content: ${process.env.homeURL}/collection/${tool.id}`; categoryIs = process.env.DISCOURSE_CATEGORY_COLLECTIONS_ID; } // 3. Assemble payload for creating a topic in Discourse + let title = ''; + if (tool.type === 'course') tool.title + else tool.name const payload = { title: tool.name, raw: rawIs, diff --git a/src/resources/message/message.model.js b/src/resources/message/message.model.js index d21e6b17..0118ae06 100644 --- a/src/resources/message/message.model.js +++ b/src/resources/message/message.model.js @@ -20,7 +20,8 @@ const MessageSchema = new Schema({ 'added collection', 'review', 'data access request', - 'data access request unlinked' + 'data access request unlinked', + 'edit' ] }, createdBy:{ diff --git a/src/resources/message/message.route.js b/src/resources/message/message.route.js index 777101fa..d3b00052 100644 --- a/src/resources/message/message.route.js +++ b/src/resources/message/message.route.js @@ -84,7 +84,8 @@ router.get('/:personID', var m = MessagesModel.aggregate([ { $match: { $and: [{ messageTo: idString }] } }, { $sort: { createdDate: -1 } }, - { $lookup: { from: "tools", localField: "messageObjectID", foreignField: "id", as: "tool" } } + { $lookup: { from: "tools", localField: "messageObjectID", foreignField: "id", as: "tool" } }, + { $lookup: { from: "course", localField: "messageObjectID", foreignField: "id", as: "course" } } ]).limit(50); m.exec((err, data) => { if (err) return res.json({ success: false, error: err }); @@ -111,7 +112,8 @@ router.get( var m = MessagesModel.aggregate([ { $match: { $and: [{ $or: [{ messageTo: idString }, { messageTo: 0 }] }] } }, { $sort: { createdDate: -1 } }, - { $lookup: { from: "tools", localField: "messageObjectID", foreignField: "id", as: "tool" } } + { $lookup: { from: "tools", localField: "messageObjectID", foreignField: "id", as: "tool" } }, + { $lookup: { from: "course", localField: "messageObjectID", foreignField: "id", as: "course" } } ]).limit(50); m.exec((err, data) => { if (err) return res.json({ success: false, error: err });