From c39f1b480f60fdd9174ad6c24f9ace71af06f4cd Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Sun, 9 May 2021 10:00:31 +0100 Subject: [PATCH 01/81] Removed redundant data access request route and model --- src/resources/datarequests/datarequests.model.js | 11 ----------- src/resources/datarequests/datarequests.route.js | 15 --------------- src/resources/stats/stats.repository.js | 2 +- 3 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 src/resources/datarequests/datarequests.model.js delete mode 100644 src/resources/datarequests/datarequests.route.js diff --git a/src/resources/datarequests/datarequests.model.js b/src/resources/datarequests/datarequests.model.js deleted file mode 100644 index fcd5b000..00000000 --- a/src/resources/datarequests/datarequests.model.js +++ /dev/null @@ -1,11 +0,0 @@ -import { model, Schema } from 'mongoose' - -const DataRequestSchema = new Schema({ - id: Number, - dataSetId: String, - datasetIds: Array, - userId: Number, - timeStamp: Date -}); - -export const DataRequestModel = model('data_requests', DataRequestSchema) diff --git a/src/resources/datarequests/datarequests.route.js b/src/resources/datarequests/datarequests.route.js deleted file mode 100644 index f5668ea6..00000000 --- a/src/resources/datarequests/datarequests.route.js +++ /dev/null @@ -1,15 +0,0 @@ -import express from 'express' -import { DataRequestModel } from '../datarequests/datarequests.model'; - -const router = express.Router(); - -router.get('/', async (req, res) => { - var q = DataRequestModel.find({}); - - 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 diff --git a/src/resources/stats/stats.repository.js b/src/resources/stats/stats.repository.js index b7104b25..aa05deca 100644 --- a/src/resources/stats/stats.repository.js +++ b/src/resources/stats/stats.repository.js @@ -2,7 +2,7 @@ import Repository from '../base/repository'; import { StatsSnapshot } from './statsSnapshot.model'; import { Data } from '../tool/data.model'; import { RecordSearchData } from '../search/record.search.model'; -import { DataRequestModel } from '../datarequests/datarequests.model'; +import { DataRequestModel } from '../datarequest/datarequest.model'; import { Course } from '../course/course.model'; import constants from '../utilities/constants.util'; From 0202d24b38ba78254aab4730d946641801ed1bcf Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Sun, 9 May 2021 10:21:42 +0100 Subject: [PATCH 02/81] Added logger middleware to all DAR endpoints --- .../datarequest/datarequest.route.js | 145 +++++++++++++++--- 1 file changed, 125 insertions(+), 20 deletions(-) diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 20f6aaf5..c4ef7ec5 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -44,12 +44,22 @@ router.get( // @route GET api/v1/data-access-request/dataset/:datasetId // @desc GET Access request for user // @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer -router.get('/dataset/:dataSetId', passport.authenticate('jwt'), datarequestController.getAccessRequestByUserAndDataset); +router.get( + '/dataset/:dataSetId', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via a dataset' }), + datarequestController.getAccessRequestByUserAndDataset +); // @route GET api/v1/data-access-request/datasets/:datasetIds // @desc GET Access request with multiple datasets for user // @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer -router.get('/datasets/:datasetIds', passport.authenticate('jwt'), datarequestController.getAccessRequestByUserAndMultipleDatasets); +router.get( + '/datasets/:datasetIds', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via multiple datasets' }), + datarequestController.getAccessRequestByUserAndMultipleDatasets +); // @route GET api/v1/data-access-request/:id/file/:fileId // @desc GET @@ -60,97 +70,192 @@ router.get( return value; }), passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Requested an uploaded file from a Data Access Request application' }), datarequestController.getFile ); // @route GET api/v1/data-access-request/:id/file/:fileId/status // @desc GET Status of a file // @access Private -router.get('/:id/file/:fileId/status', passport.authenticate('jwt'), datarequestController.getFileStatus); +router.get( + '/:id/file/:fileId/status', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Requested the status of an uploaded file to a Data Access Request application' }), + datarequestController.getFileStatus +); // @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) -router.patch('/:id', passport.authenticate('jwt'), datarequestController.updateAccessRequestDataElement); +router.patch( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Updating a single question answer in a Data Access Request application' }), + datarequestController.updateAccessRequestDataElement +); // @route PUT api/v1/data-access-request/:id // @desc Update request record by Id for status changes // @access Private - Custodian Manager and Applicant (Gateway User) -router.put('/:id', passport.authenticate('jwt'), datarequestController.updateAccessRequestById); +router.put( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Updating the status of a Data Access Request application' }), + datarequestController.updateAccessRequestById +); // @route PUT api/v1/data-access-request/:id/assignworkflow // @desc Update access request workflow // @access Private - Custodian Manager -router.put('/:id/assignworkflow', passport.authenticate('jwt'), datarequestController.assignWorkflow); +router.put( + '/:id/assignworkflow', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Assigning a workflow to a Data Access Request application' }), + datarequestController.assignWorkflow +); // @route PUT api/v1/data-access-request/:id/vote // @desc Update access request with user vote // @access Private - Custodian Reviewer/Manager -router.put('/:id/vote', passport.authenticate('jwt'), datarequestController.updateAccessRequestReviewVote); +router.put( + '/:id/vote', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Voting against a review phase for a Data Access Request application' }), + 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); +router.put( + '/:id/startreview', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Starting the review process for a Data Access Request application' }), + 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); +router.put( + '/:id/stepoverride', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Overriding a workflow phase for a Data Access Request application' }), + datarequestController.updateAccessRequestStepOverride +); // @route PUT api/v1/data-access-request/:id/deletefile // @desc Update access request deleting a file by Id // @access Private - Applicant (Gateway User) -router.put('/:id/deletefile', passport.authenticate('jwt'), datarequestController.updateAccessRequestDeleteFile); +router.put( + '/:id/deletefile', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Deleting an uploaded file from a Data Access Request application' }), + datarequestController.updateAccessRequestDeleteFile +); // @route POST api/v1/data-access-request/:id/upload // @desc POST application files to scan bucket // @access Private - Applicant (Gateway User / Custodian Manager) -router.post('/:id/upload', passport.authenticate('jwt'), multerMid.array('assets'), datarequestController.uploadFiles); +router.post( + '/:id/upload', + passport.authenticate('jwt'), + multerMid.array('assets'), + logger.logRequestMiddleware({ logCategory, action: 'Uploading a file to a Data Access Request application' }), + datarequestController.uploadFiles +); // @route POST api/v1/data-access-request/:id/amendments // @desc Create or remove amendments from DAR // @access Private - Custodian Reviewer/Manager -router.post('/:id/amendments', passport.authenticate('jwt'), amendmentController.setAmendment); +router.post( + '/:id/amendments', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Creating or removing an amendment against a Data Access Request application' }), + amendmentController.setAmendment +); // @route POST api/v1/data-access-request/:id/requestAmendments // @desc Submit a batch of requested amendments back to the form applicant(s) // @access Private - Manager -router.post('/:id/requestAmendments', passport.authenticate('jwt'), amendmentController.requestAmendments); +router.post( + '/:id/requestAmendments', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Requesting a batch of amendments to a Data Access Request application' }), + amendmentController.requestAmendments +); // @route POST api/v1/data-access-request/:id/actions // @desc Perform an action on a presubmitted application form e.g. add/remove repeatable section // @access Private - Applicant -router.post('/:id/actions', passport.authenticate('jwt'), datarequestController.performAction); +router.post( + '/:id/actions', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Performing a user trggered action on a Data Access Request application' }), + datarequestController.performAction +); // @route POST api/v1/data-access-request/:id/clone // @desc Clone an existing application forms answers into a new one potentially for a different custodian // @access Private - Applicant -router.post('/:id/clone', passport.authenticate('jwt'), datarequestController.cloneApplication); +router.post( + '/:id/clone', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Cloning a Data Access Request application' }), + datarequestController.cloneApplication +); // @route POST api/v1/data-access-request/:id // @desc Submit request record // @access Private - Applicant (Gateway User) -router.post('/:id', passport.authenticate('jwt'), datarequestController.submitAccessRequestById); +router.post( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Submitting a Data Access Request application' }), + 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/notify', passport.authenticate('jwt'), datarequestController.notifyAccessRequestById); +router.post( + '/:id/notify', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ + logCategory, + action: 'Notifying any outstanding or upcoming SLA breaches for review phases against a Data Access Request application', + }), + datarequestController.notifyAccessRequestById +); // @route POST api/v1/data-access-request/:id/updatefilestatus // @desc Update the status of a file. // @access Private -router.post('/:id/file/:fileId/status', passport.authenticate('jwt'), datarequestController.updateFileStatus); +router.post( + '/:id/file/:fileId/status', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Updating the status of an uploaded file to a Data Access Request application' }), + datarequestController.updateFileStatus +); // @route POST api/v1/data-access-request/:id/email // @desc Mail a Data Access Request information in presubmission // @access Private - Applicant -router.post('/:id/email', passport.authenticate('jwt'), datarequestController.mailDataAccessRequestInfoById); +router.post( + '/:id/email', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Emailing a presubmission Data Access Request application to the requesting user' }), + datarequestController.mailDataAccessRequestInfoById +); // @route DELETE api/v1/data-access-request/:id // @desc Delete an application in a presubmissioin // @access Private - Applicant -router.delete('/:id', passport.authenticate('jwt'), datarequestController.deleteDraftAccessRequest); +router.delete( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Deleting a presubmission Data Access Request application' }), + datarequestController.deleteDraftAccessRequest +); module.exports = router; From 86f084d48648d28260e7a08f1929f1e1b94a6533 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Sun, 9 May 2021 12:04:21 +0100 Subject: [PATCH 03/81] Adding model updates and migration scripts --- .../1620558117918-applications_versioning.js | 15 +++++++++++++++ src/resources/datarequest/datarequest.model.js | 9 ++++++++- src/resources/utilities/constants.util.js | 8 ++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 migrations/1620558117918-applications_versioning.js diff --git a/migrations/1620558117918-applications_versioning.js b/migrations/1620558117918-applications_versioning.js new file mode 100644 index 00000000..5f3bd684 --- /dev/null +++ b/migrations/1620558117918-applications_versioning.js @@ -0,0 +1,15 @@ +/** + * Make any changes you need to make to the database here + */ +async function up () { + // Write migration here +} + +/** + * Make any changes that UNDO the up function side effects here (if possible) + */ +async function down () { + // Write migration here +} + +module.exports = { up, down }; diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 49350a35..2abe95b8 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -19,6 +19,11 @@ const DataRequestSchema = new Schema( default: 'inProgress', enum: ['inProgress', 'submitted', 'inReview', 'approved', 'rejected', 'approved with conditions', 'withdrawn'], }, + applicationType: { + type: String, + default: 'Initial', + enum: Object.values(constants.applicationTypes) + }, archived: { Boolean, default: false, @@ -80,7 +85,9 @@ const DataRequestSchema = new Schema( questionAnswers: { type: Object, default: {} }, }, ], - originId: { type: Schema.Types.ObjectId, ref: 'data_request' } + originId: { type: Schema.Types.ObjectId, ref: 'data_request' }, + version: { type: String, default: '1'}, + versionTree: { type: Object, default: {} } }, { timestamps: true, diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 84c61fc1..aa5271c6 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -337,6 +337,13 @@ const _applicationStatuses = { WITHDRAWN: 'withdrawn', }; +const _applicationTypes = { + INITIAL: 'Initial', + AMENDED: 'Amendment', + EXTENDED: 'Extension', + RENEWAL: 'Renewal' +} + const _amendmentModes = { ADDED: 'added', REMOVED: 'removed', @@ -416,6 +423,7 @@ export default { navigationFlags: _navigationFlags, amendmentStatuses: _amendmentStatuses, notificationTypes: _notificationTypes, + applicationTypes: _applicationTypes, applicationStatuses: _applicationStatuses, amendmentModes: _amendmentModes, submissionTypes: _submissionTypes, From 703415e597582867ab41c5e44914a45506ef0556 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Sun, 9 May 2021 12:33:15 +0100 Subject: [PATCH 04/81] continued up migration scripts --- cloudbuild.yaml | 2 +- .../1620558117918-applications_versioning.js | 51 +++++++++++++++---- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/cloudbuild.yaml b/cloudbuild.yaml index cd4cce49..429bc712 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -23,7 +23,7 @@ steps: - name: 'node' args: ['npm', 'install'] - name: 'node' - args: ['node', '-r', 'esm', 'migrations/migrate.js', 'up'] + args: ['node', '-r', 'esm', 'migrations/migrate.js', 'up', '--autosync', 'true'] - name: 'node' args: ['npm', 'test'] env: diff --git a/migrations/1620558117918-applications_versioning.js b/migrations/1620558117918-applications_versioning.js index 5f3bd684..86ae9e3d 100644 --- a/migrations/1620558117918-applications_versioning.js +++ b/migrations/1620558117918-applications_versioning.js @@ -1,15 +1,46 @@ -/** - * Make any changes you need to make to the database here - */ -async function up () { - // Write migration here +import { DataRequestModel } from '../src/resources/datarequest/datarequest.model'; + +async function up() { + + // 1. Add default application type to all applications + // 2. Add version 1 to all applications + // 3. Create version tree for all applications + + // Find all access records + let accessRecords = await DataRequestModel.find(); + + // Loop through each record + for (const accessRecord of accessRecords) { + + accessRecord.applicationType = 'Initial'; + accessRecord.version = 1; + //accessRecord.versionTree = buildVersionTree(accessRecord); + + await accessRecord.save(async (err, doc) => { + if (err) { + console.error(`Object update failed for ${accessRecord._id}: ${err.message}`); + } + }); + } } -/** - * Make any changes that UNDO the up function side effects here (if possible) - */ -async function down () { - // Write migration here +async function down() { + // Find all access records + let accessRecords = await DataRequestModel.find(); + + // Loop through each record + for (const accessRecord of accessRecords) { + + accessRecord.applicationType = undefined; + accessRecord.version = undefined; + accessRecord.versionTree = undefined; + + await accessRecord.save(async (err, doc) => { + if (err) { + console.error(`Object update failed for ${accessRecord._id}: ${err.message}`); + } + }); + } } module.exports = { up, down }; From 5e988769a129bb13116c1891f258ef028a746a75 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 10 May 2021 17:06:35 +0100 Subject: [PATCH 05/81] Continued build --- .../1620558117918-applications_versioning.js | 75 +- .../1620661052855-example-migration2.js | 15 + .../amendment/__tests__/amendments.test.js | 90 +- .../amendment/amendment.controller.js | 998 ++++-------------- .../amendment/amendment.repository.js | 9 + .../amendment/amendment.service.js | 582 ++++++++++ .../datarequest/amendment/dependency.js | 5 + .../datarequest/datarequest.controller.js | 254 +---- .../datarequest/datarequest.entity.js | 56 + .../datarequest/datarequest.model.js | 7 +- .../datarequest/datarequest.repository.js | 20 + .../datarequest/datarequest.route.js | 14 +- .../datarequest/datarequest.service.js | 172 +++ src/resources/datarequest/dependency.js | 15 + .../dataset/datasetonboarding.controller.js | 5 +- .../publisher/publisher.controller.js | 8 +- src/resources/workflow/dependency.js | 5 + src/resources/workflow/workflow.controller.js | 929 +++++----------- src/resources/workflow/workflow.repository.js | 20 + src/resources/workflow/workflow.route.js | 35 +- src/resources/workflow/workflow.service.js | 378 +++++++ 21 files changed, 1938 insertions(+), 1754 deletions(-) create mode 100644 migrations/1620661052855-example-migration2.js create mode 100644 src/resources/datarequest/amendment/amendment.repository.js create mode 100644 src/resources/datarequest/amendment/amendment.service.js create mode 100644 src/resources/datarequest/amendment/dependency.js create mode 100644 src/resources/datarequest/datarequest.entity.js create mode 100644 src/resources/datarequest/datarequest.repository.js create mode 100644 src/resources/datarequest/datarequest.service.js create mode 100644 src/resources/datarequest/dependency.js create mode 100644 src/resources/workflow/dependency.js create mode 100644 src/resources/workflow/workflow.repository.js create mode 100644 src/resources/workflow/workflow.service.js diff --git a/migrations/1620558117918-applications_versioning.js b/migrations/1620558117918-applications_versioning.js index 86ae9e3d..cf0bb320 100644 --- a/migrations/1620558117918-applications_versioning.js +++ b/migrations/1620558117918-applications_versioning.js @@ -1,46 +1,59 @@ import { DataRequestModel } from '../src/resources/datarequest/datarequest.model'; +import { buildVersionTree } from '../src/resources/datarequest/datarequest.entity'; async function up() { - // 1. Add default application type to all applications // 2. Add version 1 to all applications // 3. Create version tree for all applications - // Find all access records - let accessRecords = await DataRequestModel.find(); - - // Loop through each record - for (const accessRecord of accessRecords) { - - accessRecord.applicationType = 'Initial'; - accessRecord.version = 1; - //accessRecord.versionTree = buildVersionTree(accessRecord); - - await accessRecord.save(async (err, doc) => { - if (err) { - console.error(`Object update failed for ${accessRecord._id}: ${err.message}`); - } + let accessRecords = await DataRequestModel.find() + .select('_id version versionTree amendmentIterations') + .lean(); + let ops = []; + + accessRecords.forEach(accessRecord => { + const versionTree = buildVersionTree(accessRecord); + const { _id } = accessRecord; + ops.push({ + updateOne: { + filter: { _id }, + update: { + applicationType: 'Initial', + version: 1, + versionTree, + }, + upsert: false, + }, }); - } + }); + + await DataRequestModel.bulkWrite(ops); } async function down() { - // Find all access records - let accessRecords = await DataRequestModel.find(); - - // Loop through each record - for (const accessRecord of accessRecords) { - - accessRecord.applicationType = undefined; - accessRecord.version = undefined; - accessRecord.versionTree = undefined; - - await accessRecord.save(async (err, doc) => { - if (err) { - console.error(`Object update failed for ${accessRecord._id}: ${err.message}`); - } + // 1. Remove application type from all applications + // 2. Remove version from all applications + // 3. Remove version tree from all applications + + let accessRecords = await DataRequestModel.find().select('_id version versionTree amendmentIterations').lean(); + let ops = []; + + accessRecords.forEach(accessRecord => { + const { _id } = accessRecord; + ops.push({ + updateOne: { + filter: { _id }, + update: { + applicationType: undefined, + version: undefined, + versionTree: undefined, + }, + upsert: false, + }, }); - } + }); + + await DataRequestModel.bulkWrite(ops); } module.exports = { up, down }; diff --git a/migrations/1620661052855-example-migration2.js b/migrations/1620661052855-example-migration2.js new file mode 100644 index 00000000..5f3bd684 --- /dev/null +++ b/migrations/1620661052855-example-migration2.js @@ -0,0 +1,15 @@ +/** + * Make any changes you need to make to the database here + */ +async function up () { + // Write migration here +} + +/** + * Make any changes that UNDO the up function side effects here (if possible) + */ +async function down () { + // Write migration here +} + +module.exports = { up, down }; diff --git a/src/resources/datarequest/amendment/__tests__/amendments.test.js b/src/resources/datarequest/amendment/__tests__/amendments.test.js index 7f10c286..abf347da 100755 --- a/src/resources/datarequest/amendment/__tests__/amendments.test.js +++ b/src/resources/datarequest/amendment/__tests__/amendments.test.js @@ -1,7 +1,7 @@ import constants from '../../../utilities/constants.util'; +import { amendmentService } from '../dependency'; import _ from 'lodash'; -const amendmentController = require('../amendment.controller'); const dataRequest = require('../../__mocks__/datarequest'); const users = require('../../__mocks__/users'); @@ -23,7 +23,7 @@ describe('addAmendment', () => { requestedByUser: user._id, }; // Act - amendmentController.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); + amendmentService.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); // Assert expect(dataRequest[0].amendmentIterations[1].questionAnswers).not.toHaveProperty('title'); expect(Object.keys(data.amendmentIterations[1].questionAnswers).length).toBe(2); @@ -50,7 +50,7 @@ describe('addAmendment', () => { updatedByUser: user._id, }; // Act - amendmentController.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); + amendmentService.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); // Assert expect(dataRequest[0].amendmentIterations[1].questionAnswers).not.toHaveProperty('dateofbirth'); expect(Object.keys(data.amendmentIterations[1].questionAnswers).length).toBe(2); @@ -81,11 +81,11 @@ describe('addAmendment', () => { updatedByUser: user._id, }; // Act - amendmentController.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); + amendmentService.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); let firstAnswer = data.amendmentIterations[1].questionAnswers['dateofbirth']['answer']; let firstDateUpdated = data.amendmentIterations[1].questionAnswers['dateofbirth']['dateUpdated']; setTimeout(() => { - amendmentController.addAmendment(data, questionId, questionSetId, secondAnswer, reason, user, requested); + amendmentService.addAmendment(data, questionId, questionSetId, secondAnswer, reason, user, requested); // Assert expect(dataRequest[0].amendmentIterations[1].questionAnswers).not.toHaveProperty('dateofbirth'); expect(firstAnswer).toBe(answer); @@ -123,7 +123,7 @@ describe('addAmendment', () => { }, }; // Act - amendmentController.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); + amendmentService.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); // Assert expect(dataRequest[1].amendmentIterations).toHaveLength(0); expect(data.amendmentIterations).toHaveLength(1); @@ -156,7 +156,7 @@ describe('addAmendment', () => { }, }; // Act - amendmentController.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); + amendmentService.addAmendment(data, questionId, questionSetId, answer, reason, user, requested); // Assert expect(dataRequest[1].amendmentIterations).toHaveLength(0); expect(data.amendmentIterations).toHaveLength(1); @@ -186,7 +186,7 @@ describe('getCurrentAmendmentIteration', () => { }, }; // Act - const result = amendmentController.getCurrentAmendmentIteration(data.amendmentIterations); + const result = amendmentService.getCurrentAmendmentIteration(data.amendmentIterations); // Assert expect(result).toEqual(expected); }); @@ -197,7 +197,7 @@ describe('getLatestAmendmentIterationIndex', () => { // Arrange let data = _.cloneDeep(dataRequest[0]); // Act - const result = amendmentController.getLatestAmendmentIterationIndex(data); + const result = amendmentService.getLatestAmendmentIterationIndex(data); // Assert expect(result).toBe(1); }); @@ -208,7 +208,7 @@ describe('getAmendmentIterationParty', () => { // Arrange let data = _.cloneDeep(dataRequest[0]); // Act - const result = amendmentController.getAmendmentIterationParty(data); + const result = amendmentService.getAmendmentIterationParty(data); // Assert expect(result).toBe(constants.userTypes.CUSTODIAN); }); @@ -219,7 +219,7 @@ describe('getAmendmentIterationParty', () => { // Act data.amendmentIterations[1].dateReturned = new Date(); // Assert - expect(amendmentController.getAmendmentIterationParty(data)).toBe(constants.userTypes.APPLICANT); + expect(amendmentService.getAmendmentIterationParty(data)).toBe(constants.userTypes.APPLICANT); }); }); @@ -258,7 +258,7 @@ describe('removeIterationAnswers', () => { 'given an amendment iteration which is not resubmitted, it strips answers', (accessRecord, iteration, expectedResult) => { // Act - const result = amendmentController.removeIterationAnswers(accessRecord, iteration); + const result = amendmentService.removeIterationAnswers(accessRecord, iteration); // Assert expect(result).toEqual(expectedResult); } @@ -274,7 +274,7 @@ describe('handleApplicantAmendment', () => { answer = 'Smith', user = users.applicant; // Act - data = amendmentController.handleApplicantAmendment(data, questionId, questionSetId, answer, user); + data = amendmentService.handleApplicantAmendment(data, questionId, questionSetId, answer, user); // Assert expect(dataRequest[1].amendmentIterations.length).toBeFalsy(); expect(Object.keys(data.amendmentIterations[0].questionAnswers).length).toBe(1); @@ -292,9 +292,9 @@ describe('handleApplicantAmendment', () => { answer = 'Smyth', secondAnswer = 'Smith', user = users.applicant; - data = amendmentController.handleApplicantAmendment(data, questionId, questionSetId, answer, user); + data = amendmentService.handleApplicantAmendment(data, questionId, questionSetId, answer, user); // Act - data = amendmentController.handleApplicantAmendment(data, questionId, questionSetId, secondAnswer, user); + data = amendmentService.handleApplicantAmendment(data, questionId, questionSetId, secondAnswer, user); // Assert expect(dataRequest[1].amendmentIterations.length).toBeFalsy(); expect(Object.keys(data.amendmentIterations[0].questionAnswers).length).toBe(1); @@ -320,7 +320,7 @@ describe('removeAmendment', () => { dateRequested: '2020-11-03T11:14:01.840+00:00', }; //Act - amendmentController.removeAmendment(data, questionId); + amendmentService.removeAmendment(data, questionId); //Assert expect(initialLastName).toEqual(expected); expect(dataRequest[0].amendmentIterations[1]).not.toBeFalsy(); @@ -341,7 +341,7 @@ describe('doesAmendmentExist', () => { 'given a data request object %p and %p as the question amended, returns %p for an amendment existing', (data, questionId, expectedResult) => { // Act - const result = amendmentController.doesAmendmentExist(data, questionId); + const result = amendmentService.doesAmendmentExist(data, questionId); // Assert expect(result).toBe(expectedResult); } @@ -357,7 +357,7 @@ describe('updateAmendment', () => { user = users.applicant, initialUpdatedDate = dataRequest[2].amendmentIterations[0].questionAnswers['lastName'].dateUpdated; // Act - data = amendmentController.updateAmendment(data, questionId, answer, user); + data = amendmentService.updateAmendment(data, questionId, answer, user); // Assert expect(Object.keys(data.amendmentIterations[0].questionAnswers).length).toBe(1); expect(new Date(data.amendmentIterations[0].questionAnswers['lastName']['dateUpdated']).getTime()).toBeGreaterThan( @@ -377,7 +377,7 @@ describe('updateAmendment', () => { 'lastName' ]; // Act - data = amendmentController.updateAmendment(data, questionId, answer, user); + data = amendmentService.updateAmendment(data, questionId, answer, user); // Assert expect(initialUpdatedBy).toBe('test applicant 1'); expect(Object.keys(data.amendmentIterations[0].questionAnswers).length).toBe(1); @@ -396,7 +396,7 @@ describe('updateAmendment', () => { answer = 'James', user = users.applicant; // Act - data = amendmentController.updateAmendment(data, questionId, answer, user); + data = amendmentService.updateAmendment(data, questionId, answer, user); // Assert expect(Object.keys(data.amendmentIterations[0].questionAnswers).length).toBe(1); expect(data.amendmentIterations[0].questionAnswers['firstName']).toBeFalsy(); @@ -409,7 +409,7 @@ describe('updateAmendment', () => { answer = 'James', user = users.applicant; // Act - data = amendmentController.updateAmendment(data, questionId, answer, user); + data = amendmentService.updateAmendment(data, questionId, answer, user); // Assert expect(data.amendmentIterations.length).toBeFalsy(); expect(data).toEqual(dataRequest[1]); @@ -421,7 +421,7 @@ describe('formatQuestionAnswers', () => { // Arrange const data = _.cloneDeep(dataRequest[0]); // Act - data.questionAnswers = amendmentController.formatQuestionAnswers(data.questionAnswers, data.amendmentIterations); + data.questionAnswers = amendmentService.formatQuestionAnswers(data.questionAnswers, data.amendmentIterations); // Assert expect(dataRequest[0].questionAnswers['firstName']).toBe('ra'); expect(dataRequest[0].questionAnswers['lastName']).toBe('adsf'); @@ -432,7 +432,7 @@ describe('formatQuestionAnswers', () => { // Arrange const data = _.cloneDeep(dataRequest[3]); // Act - data.questionAnswers = amendmentController.formatQuestionAnswers(data.questionAnswers, data.amendmentIterations); + data.questionAnswers = amendmentService.formatQuestionAnswers(data.questionAnswers, data.amendmentIterations); // Assert expect(data.questionAnswers['firstName']).toBe('Mark'); expect(data.questionAnswers['lastName']).toBe('Connolly'); @@ -444,7 +444,7 @@ describe('filterAmendments', () => { // Arrange const data = _.cloneDeep(dataRequest[3]); // Act - const result = amendmentController.filterAmendments(data, constants.userTypes.APPLICANT); + const result = amendmentService.filterAmendments(data, constants.userTypes.APPLICANT); // Assert expect(result.length).toBe(2); expect(result[result.length - 1].dateReturned).not.toBeFalsy(); @@ -453,7 +453,7 @@ describe('filterAmendments', () => { // Arrange const data = _.cloneDeep(dataRequest[3]); // Act - const result = amendmentController.filterAmendments(data, constants.userTypes.CUSTODIAN); + const result = amendmentService.filterAmendments(data, constants.userTypes.CUSTODIAN); // Assert expect(result.length).toBe(3); expect(result[result.length - 1].dateCreated).not.toBeFalsy(); @@ -463,7 +463,7 @@ describe('filterAmendments', () => { // Arrange const data = _.cloneDeep(dataRequest[4]); // Act - const result = amendmentController.filterAmendments(data, constants.userTypes.CUSTODIAN); + const result = amendmentService.filterAmendments(data, constants.userTypes.CUSTODIAN); // Assert expect(result.length).toBe(3); expect(result[result.length - 1].questionAnswers['country']['answer']).toBe('UK'); @@ -475,7 +475,7 @@ describe('filterAmendments', () => { // Arrange const data = _.cloneDeep(dataRequest[4]); // Act - const result = amendmentController.filterAmendments(data, constants.userTypes.APPLICANT); + const result = amendmentService.filterAmendments(data, constants.userTypes.APPLICANT); // Assert expect(result.length).toBe(3); expect(result[result.length - 1].questionAnswers['country']).toHaveProperty('answer'); @@ -490,7 +490,7 @@ describe('injectAmendments', () => { // Arrange let data = _.cloneDeep(dataRequest[5]); // Act - data = amendmentController.injectAmendments(data, constants.userTypes.CUSTODIAN); + data = amendmentService.injectAmendments(data, constants.userTypes.CUSTODIAN); // Assert expect(data.questionAnswers['firstName']).toBe('Mark'); expect(data.questionAnswers['lastName']).toBe('Connolly'); @@ -500,7 +500,7 @@ describe('injectAmendments', () => { // Arrange let data = _.cloneDeep(dataRequest[5]); // Act - data = amendmentController.injectAmendments(data, constants.userTypes.APPLICANT); + data = amendmentService.injectAmendments(data, constants.userTypes.APPLICANT); // Assert expect(data.questionAnswers['firstName']).toBe('Mark'); expect(data.questionAnswers['lastName']).toBe('Connolly'); @@ -510,7 +510,7 @@ describe('injectAmendments', () => { // Arrange let data = _.cloneDeep(dataRequest[6]); // Act - data = amendmentController.injectAmendments(data, constants.userTypes.APPLICANT); + data = amendmentService.injectAmendments(data, constants.userTypes.APPLICANT); // Assert expect(data).toEqual(dataRequest[6]); }); @@ -518,7 +518,7 @@ describe('injectAmendments', () => { // Arrange let data = _.cloneDeep(dataRequest[6]); // Act - data = amendmentController.injectAmendments(data, constants.userTypes.CUSTODIAN); + data = amendmentService.injectAmendments(data, constants.userTypes.CUSTODIAN); // Assert expect(data).toEqual(dataRequest[6]); }); @@ -529,7 +529,7 @@ describe('doResubmission', () => { // Arrange let data = _.cloneDeep(dataRequest[4]); // Act - data = amendmentController.doResubmission(data, users.applicant._id); + data = amendmentService.doResubmission(data, users.applicant._id); // Assert expect(dataRequest[4].amendmentIterations[2].dateSubmitted).toBeFalsy(); expect(dataRequest[4].amendmentIterations[2].submittedBy).toBeFalsy(); @@ -547,7 +547,7 @@ describe('countUnsubmittedAmendments', () => { // Arrange let data = _.cloneDeep(dataRequest[5]); // Act - const result = amendmentController.countUnsubmittedAmendments(data, constants.userTypes.APPLICANT); + const result = amendmentService.countUnsubmittedAmendments(data, constants.userTypes.APPLICANT); // Assert expect(result.unansweredAmendments).toBe(2); expect(result.answeredAmendments).toBe(1); @@ -556,7 +556,7 @@ describe('countUnsubmittedAmendments', () => { // Arrange let data = _.cloneDeep(dataRequest[6]); // Act - const result = amendmentController.countUnsubmittedAmendments(data, constants.userTypes.APPLICANT); + const result = amendmentService.countUnsubmittedAmendments(data, constants.userTypes.APPLICANT); // Assert expect(result.unansweredAmendments).toBe(0); expect(result.answeredAmendments).toBe(0); @@ -577,7 +577,7 @@ describe('getLatestQuestionAnswer', () => { 'given a data access record with multiple amendment versions, the latest previous answer is returned', (accessRecord, questionId, expectedResult) => { // Act - const result = amendmentController.getLatestQuestionAnswer(accessRecord, questionId); + const result = amendmentService.getLatestQuestionAnswer(accessRecord, questionId); // Assert expect(result).toBe(expectedResult); } @@ -591,7 +591,7 @@ describe('revertAmendmentAnswer', () => { let questionId = 'country'; let user = users.applicant; // Act - amendmentController.revertAmendmentAnswer(data, questionId, user); + amendmentService.revertAmendmentAnswer(data, questionId, user); // Assert expect(dataRequest[4].amendmentIterations[2].questionAnswers[questionId].answer).not.toBeFalsy(); expect(data.amendmentIterations[2].questionAnswers[questionId].answer).toBeFalsy(); @@ -602,7 +602,7 @@ describe('revertAmendmentAnswer', () => { let questionId = 'reasonforaccess'; let user = users.applicant; // Act - amendmentController.revertAmendmentAnswer(data, questionId, user); + amendmentService.revertAmendmentAnswer(data, questionId, user); // Assert expect(dataRequest[4]).toEqual(data); }); @@ -612,7 +612,7 @@ describe('revertAmendmentAnswer', () => { let questionId = 'firstname'; let user = users.applicant; // Act - amendmentController.revertAmendmentAnswer(data, questionId, user); + amendmentService.revertAmendmentAnswer(data, questionId, user); // Assert expect(dataRequest[4]).toEqual(data); }); @@ -634,7 +634,7 @@ describe('injectNavigationAmendment', () => { 'given a valid json schema, and a requested amendment, then the corresponding navigation panels are highlighted to reflect the amendment status', (jsonSchema, questionSetId, pageId, userType, completed, iterationStatus, expectedPageResult, expectedPanelResult) => { // Act - const result = amendmentController.injectNavigationAmendment(jsonSchema, questionSetId, userType, completed, iterationStatus); + const result = amendmentService.injectNavigationAmendment(jsonSchema, questionSetId, userType, completed, iterationStatus); // Assert expect(result.pages.find(page => page.pageId === pageId)).toMatchObject(expectedPageResult); expect(result.questionPanels.find(panel => panel.panelId === questionSetId)).toMatchObject(expectedPageResult); @@ -645,8 +645,8 @@ describe('injectNavigationAmendment', () => { let data = _.cloneDeep(dataRequest[0]); let pageId = 'safePeople'; // Act - let jsonSchema = amendmentController.injectNavigationAmendment(data.jsonSchema, 'applicant', constants.userTypes.APPLICANT, 'completed', 'submitted'); - jsonSchema = amendmentController.injectNavigationAmendment(data.jsonSchema, 'principleInvestigator', constants.userTypes.APPLICANT, 'incomplete', 'submitted'); + let jsonSchema = amendmentService.injectNavigationAmendment(data.jsonSchema, 'applicant', constants.userTypes.APPLICANT, 'completed', 'submitted'); + jsonSchema = amendmentService.injectNavigationAmendment(data.jsonSchema, 'principleInvestigator', constants.userTypes.APPLICANT, 'incomplete', 'submitted'); // Assert expect(jsonSchema.pages.find(page => page.pageId === pageId)).toMatchObject({"flag": "DANGER"}); expect(jsonSchema.questionPanels.find(panel => panel.panelId === 'applicant')).toMatchObject({"flag": "SUCCESS"}); @@ -657,8 +657,8 @@ describe('injectNavigationAmendment', () => { let data = _.cloneDeep(dataRequest[0]); let pageId = 'safePeople'; // Act - let jsonSchema = amendmentController.injectNavigationAmendment(data.jsonSchema, 'applicant', constants.userTypes.APPLICANT, 'incomplete', 'submitted'); - jsonSchema = amendmentController.injectNavigationAmendment(data.jsonSchema, 'principleInvestigator', constants.userTypes.APPLICANT, 'incomplete', 'submitted'); + let jsonSchema = amendmentService.injectNavigationAmendment(data.jsonSchema, 'applicant', constants.userTypes.APPLICANT, 'incomplete', 'submitted'); + jsonSchema = amendmentService.injectNavigationAmendment(data.jsonSchema, 'principleInvestigator', constants.userTypes.APPLICANT, 'incomplete', 'submitted'); // Assert expect(jsonSchema.pages.find(page => page.pageId === pageId)).toMatchObject({"flag": "DANGER"}); expect(jsonSchema.questionPanels.find(panel => panel.panelId === 'applicant')).toMatchObject({"flag": "DANGER"}); @@ -669,8 +669,8 @@ describe('injectNavigationAmendment', () => { let data = _.cloneDeep(dataRequest[0]); let pageId = 'safePeople'; // Act - let jsonSchema = amendmentController.injectNavigationAmendment(data.jsonSchema, 'applicant', constants.userTypes.APPLICANT, 'completed', 'submitted'); - jsonSchema = amendmentController.injectNavigationAmendment(data.jsonSchema, 'principleInvestigator', constants.userTypes.APPLICANT, 'completed', 'submitted'); + let jsonSchema = amendmentService.injectNavigationAmendment(data.jsonSchema, 'applicant', constants.userTypes.APPLICANT, 'completed', 'submitted'); + jsonSchema = amendmentService.injectNavigationAmendment(data.jsonSchema, 'principleInvestigator', constants.userTypes.APPLICANT, 'completed', 'submitted'); // Assert expect(jsonSchema.pages.find(page => page.pageId === pageId)).toMatchObject({"flag": "SUCCESS"}); expect(jsonSchema.questionPanels.find(panel => panel.panelId === 'applicant')).toMatchObject({"flag": "SUCCESS"}); diff --git a/src/resources/datarequest/amendment/amendment.controller.js b/src/resources/datarequest/amendment/amendment.controller.js index f679ff4e..69015d11 100644 --- a/src/resources/datarequest/amendment/amendment.controller.js +++ b/src/resources/datarequest/amendment/amendment.controller.js @@ -1,170 +1,40 @@ import { DataRequestModel } from '../datarequest.model'; -import { AmendmentModel } from './amendment.model'; import constants from '../../utilities/constants.util'; -import helperUtil from '../../utilities/helper.util'; import datarequestUtil from '../utils/datarequest.util'; import teamController from '../../team/team.controller'; -import notificationBuilder from '../../utilities/notificationBuilder'; -import emailGenerator from '../../utilities/emailGenerator.util'; +import Controller from '../../base/controller'; import _ from 'lodash'; -//POST api/v1/data-access-request/:id/amendments -const setAmendment = async (req, res) => { - try { - // 1. Get the required request params - const { - params: { id }, - } = req; - let { questionId, questionSetId, mode, reason, answer } = req.body; - if (_.isEmpty(questionId) || _.isEmpty(questionSetId)) { - return res.status(400).json({ - success: false, - message: 'You must supply the unique identifiers for the question requiring amendment', - }); - } - // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ - { - path: 'datasets dataset', - }, - { - path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - ]); - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - // 3. If application is not in review or submitted, amendments cannot be made - if ( - accessRecord.applicationStatus !== constants.applicationStatuses.SUBMITTED && - accessRecord.applicationStatus !== constants.applicationStatuses.INREVIEW - ) { - return res.status(400).json({ - success: false, - message: 'This application is not within a reviewable state and amendments cannot be made or requested at this time.', - }); - } - // 4. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), req.user.id, req.user._id); - // 5. Get the current iteration amendment party - let validParty = false; - let activeParty = getAmendmentIterationParty(accessRecord); - // 6. Add/remove/revert amendment depending on mode - if (authorised) { - switch (mode) { - case constants.amendmentModes.ADDED: - authorised = userType === constants.userTypes.CUSTODIAN; - validParty = activeParty === constants.userTypes.CUSTODIAN; - if (!authorised || !validParty) { - break; - } - addAmendment(accessRecord, questionId, questionSetId, answer, reason, req.user, true); - break; - case constants.amendmentModes.REMOVED: - authorised = userType === constants.userTypes.CUSTODIAN; - validParty = activeParty === constants.userTypes.CUSTODIAN; - if (!authorised || !validParty) { - break; - } - removeAmendment(accessRecord, questionId); - break; - case constants.amendmentModes.REVERTED: - authorised = userType === constants.userTypes.APPLICANT; - validParty = activeParty === constants.userTypes.APPLICANT; - if (!authorised || !validParty) { - break; - } - revertAmendmentAnswer(accessRecord, questionId, req.user); - break; - } - } - // 7. Return unauthorised message if the user did not have sufficient access for action requested - if (!authorised) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - // 8. Return bad request if the opposite party is editing the application - if (!validParty) { - return res.status(400).json({ - status: 'failure', - message: 'You cannot make or request amendments to this application as the opposite party are currently responsible for it.', - }); - } - // 9. Save changes to database - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - return res.status(500).json({ status: 'error', message: err.message }); - } else { - // 10. Update json schema and question answers with modifications since original submission - let accessRecordObj = accessRecord.toObject(); - accessRecordObj = injectAmendments(accessRecordObj, userType, req.user); - // 11. Append question actions depending on user type and application status - let userRole = activeParty === constants.userTypes.CUSTODIAN ? constants.roleTypes.MANAGER : ''; - accessRecordObj.jsonSchema = datarequestUtil.injectQuestionActions( - accessRecordObj.jsonSchema, - userType, - accessRecordObj.applicationStatus, - userRole, - activeParty - ); - // 12. Count the number of answered/unanswered amendments - const { answeredAmendments = 0, unansweredAmendments = 0 } = countUnsubmittedAmendments(accessRecord, userType); - return res.status(200).json({ - success: true, - accessRecord: { - amendmentIterations: accessRecordObj.amendmentIterations, - questionAnswers: accessRecordObj.questionAnswers, - jsonSchema: accessRecordObj.jsonSchema, - answeredAmendments, - unansweredAmendments, - }, +import { logger } from '../../utilities/logger'; +const logCategory = 'Data Access Request'; + +export default class AmendmentController extends Controller { + constructor(amendmentService) { + super(amendmentService); + this.amendmentService = amendmentService; + } + + async setAmendment(req, res) { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + let { questionId, questionSetId, mode, reason, answer } = req.body; + if (_.isEmpty(questionId) || _.isEmpty(questionSetId)) { + return res.status(400).json({ + success: false, + message: 'You must supply the unique identifiers for the question requiring amendment', }); } - }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred updating the application amendment', - }); - } -}; - -//POST api/v1/data-access-request/:id/requestAmendments -const requestAmendments = async (req, res) => { - try { - // 1. Get the required request params - const { - params: { id }, - } = req; - // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }) - .select({ - _id: 1, - publisher: 1, - amendmentIterations: 1, - datasetIds: 1, - dataSetId: 1, - userId: 1, - authorIds: 1, - applicationStatus: 1, - aboutApplication: 1, - dateSubmitted: 1, - }) - .populate([ + // 2. Retrieve DAR from database + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ { - path: 'datasets dataset mainApplicant authors', + path: 'datasets dataset', }, { path: 'publisherObj', - select: '_id', populate: { path: 'team', populate: { @@ -173,654 +43,196 @@ const requestAmendments = async (req, res) => { }, }, ]); - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - // 3. Check permissions of user is manager of associated team - let authorised = false; - if (_.has(accessRecord.toObject(), 'publisherObj.team')) { - const { team } = accessRecord.publisherObj; - authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team.toObject(), req.user._id); - } - if (!authorised) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) - if (_.isEmpty(accessRecord.datasets)) { - accessRecord.datasets = [accessRecord.dataset]; - } - // 5. Get the current iteration amendment party and return bad request if the opposite party is editing the application - const activeParty = getAmendmentIterationParty(accessRecord); - if (activeParty !== constants.userTypes.CUSTODIAN) { - return res.status(400).json({ - status: 'failure', - message: 'You cannot make or request amendments to this application as the applicant(s) are amending the current version.', - }); - } - // 6. Check some amendments exist to be submitted to the applicant(s) - const { unansweredAmendments } = countUnsubmittedAmendments(accessRecord, constants.userTypes.CUSTODIAN); - if (unansweredAmendments === 0) { - return res.status(400).json({ - status: 'failure', - message: 'You cannot submit requested amendments as none have been requested in the current version', - }); - } - // 7. Find current amendment iteration index - const index = getLatestAmendmentIterationIndex(accessRecord); - // 8. Update amendment iteration status to returned, handing responsibility over to the applicant(s) - accessRecord.amendmentIterations[index].dateReturned = new Date(); - accessRecord.amendmentIterations[index].returnedBy = req.user._id; - // 9. Save changes to database - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - return res.status(500).json({ status: 'error', message: err.message }); - } else { - // 10. Send update request notifications - createNotifications(constants.notificationTypes.RETURNED, accessRecord); - return res.status(200).json({ - success: true, + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + // 3. If application is not in review or submitted, amendments cannot be made + if ( + accessRecord.applicationStatus !== constants.applicationStatuses.SUBMITTED && + accessRecord.applicationStatus !== constants.applicationStatuses.INREVIEW + ) { + return res.status(400).json({ + success: false, + message: 'This application is not within a reviewable state and amendments cannot be made or requested at this time.', }); } - }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred attempting to submit the requested updates', - }); - } -}; - -const addAmendment = (accessRecord, questionId, questionSetId, answer, reason, user, requested) => { - // 1. Create new amendment object with key representing the questionId - let amendment = { - [`${questionId}`]: new AmendmentModel({ - questionSetId, - requested, - reason, - answer, - requestedBy: requested ? `${user.firstname} ${user.lastname}` : undefined, - requestedByUser: requested ? user._id : undefined, - dateRequested: requested ? Date.now() : undefined, - updatedBy: requested ? undefined : `${user.firstname} ${user.lastname}`, - updatedByUser: requested ? undefined : user._id, - dateUpdated: requested ? undefined : Date.now(), - }), - }; - // 2. Find the index of the latest amendment iteration of the DAR - let index = getLatestAmendmentIterationIndex(accessRecord); - // 3. If index is not -1, we need to append the new amendment to existing iteration object otherwise create a new one - if (index !== -1) { - accessRecord.amendmentIterations[index].questionAnswers = { - ...accessRecord.amendmentIterations[index].questionAnswers, - ...amendment, - }; - } else { - // 4. If new iteration has been trigger by applicant given requested is false, then we automatically return the iteration - let amendmentIteration = { - dateReturned: requested ? undefined : Date.now(), - returnedBy: requested ? undefined : user._id, - dateCreated: Date.now(), - createdBy: user._id, - questionAnswers: { ...amendment }, - }; - accessRecord.amendmentIterations = [...accessRecord.amendmentIterations, amendmentIteration]; - } -}; - -const updateAmendment = (accessRecord, questionId, answer, user) => { - // 1. Locate amendment in current iteration - const currentIterationIndex = getLatestAmendmentIterationIndex(accessRecord); - // 2. Return unmoodified record if invalid update - if (currentIterationIndex === -1 || _.isNil(accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId])) { - return accessRecord; - } - // 3. Check if the update amendment reflects a change since the last version of the answer - if (currentIterationIndex > -1) { - const latestAnswer = getLatestQuestionAnswer(accessRecord, questionId); - const requested = accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId].requested || false; - if (!_.isNil(latestAnswer)) { - if (answer === latestAnswer || helperUtil.arraysEqual(answer, latestAnswer)) { - if (requested) { - // Retain the requested amendment but remove the answer - delete accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId].answer; - } else { - removeAmendment(accessRecord, questionId); + // 4. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), req.user.id, req.user._id); + // 5. Get the current iteration amendment party + let validParty = false; + let activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); + // 6. Add/remove/revert amendment depending on mode + if (authorised) { + switch (mode) { + case constants.amendmentModes.ADDED: + authorised = userType === constants.userTypes.CUSTODIAN; + validParty = activeParty === constants.userTypes.CUSTODIAN; + if (!authorised || !validParty) { + break; + } + this.amendmentService.addAmendment(accessRecord, questionId, questionSetId, answer, reason, req.user, true); + break; + case constants.amendmentModes.REMOVED: + authorised = userType === constants.userTypes.CUSTODIAN; + validParty = activeParty === constants.userTypes.CUSTODIAN; + if (!authorised || !validParty) { + break; + } + this.amendmentService.removeAmendment(accessRecord, questionId); + break; + case constants.amendmentModes.REVERTED: + authorised = userType === constants.userTypes.APPLICANT; + validParty = activeParty === constants.userTypes.APPLICANT; + if (!authorised || !validParty) { + break; + } + this.amendmentService.revertAmendmentAnswer(accessRecord, questionId, req.user); + break; } - return accessRecord; } - } else if (_.isNil(latestAnswer) && _.isEmpty(answer) && !requested) { - // Remove the amendment if there was no previous answer and the latest update is empty - removeAmendment(accessRecord, questionId); - return accessRecord; - } - } - // 4. Find and update the question with the new answer - accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId] = { - ...accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId], - answer, - updatedBy: `${user.firstname} ${user.lastname}`, - updatedByUser: user._id, - dateUpdated: Date.now(), - }; - // 5. Return updated access record - return accessRecord; -}; - -const removeAmendment = (accessRecord, questionId) => { - // 1. Find the index of the latest amendment amendmentIteration of the DAR - let index = getLatestAmendmentIterationIndex(accessRecord); - // 2. Remove the key and associated object from the current iteration if it exists - if (index !== -1) { - accessRecord.amendmentIterations[index].questionAnswers = _.omit(accessRecord.amendmentIterations[index].questionAnswers, questionId); - // 3. If question answers is now empty, remove the iteration - _.remove(accessRecord.amendmentIterations, amendmentIteration => { - return _.isEmpty(amendmentIteration.questionAnswers); - }); - } -}; - -const doesAmendmentExist = (accessRecord, questionId) => { - // 1. Get current amendment iteration - const latestIteration = getCurrentAmendmentIteration(accessRecord.amendmentIterations); - if (_.isNil(latestIteration) || _.isNil(latestIteration.questionAnswers)) { - return false; - } - // 2. Check if questionId has been added by Custodian for amendment - return latestIteration.questionAnswers.hasOwnProperty(questionId); -}; - -const handleApplicantAmendment = (accessRecord, questionId, questionSetId, answer = '', user) => { - // 1. Check if an amendment already exists for the question - let isExisting = doesAmendmentExist(accessRecord, questionId); - // 2. Update existing - if (isExisting) { - accessRecord = updateAmendment(accessRecord, questionId, answer, user); - } else { - // 3. Get the latest/previous answer for this question for comparison to new answer - const latestAnswer = getLatestQuestionAnswer(accessRecord, questionId); - let performAdd = false; - // 4. Always add the new amendment if there was no original answer - if (_.isNil(latestAnswer)) { - performAdd = true; - // 5. If a previous answer exists, ensure it is different to the most recent answer before adding - } else if (answer !== latestAnswer || !helperUtil.arraysEqual(answer, latestAnswer)) { - performAdd = true; - } - - if (performAdd) { - // 6. Add new amendment otherwise - addAmendment(accessRecord, questionId, questionSetId, answer, '', user, false); - } - } - // 7. Update the amendment count - let { unansweredAmendments = 0, answeredAmendments = 0 } = countUnsubmittedAmendments(accessRecord, constants.userTypes.APPLICANT); - accessRecord.unansweredAmendments = unansweredAmendments; - accessRecord.answeredAmendments = answeredAmendments; - accessRecord.dirtySchema = true; - // 8. Return updated access record - return accessRecord; -}; - -const getLatestAmendmentIterationIndex = accessRecord => { - // 1. Guard for incorrect type passed - let { amendmentIterations = [] } = accessRecord; - if (_.isEmpty(amendmentIterations)) { - return -1; - } - // 2. Find the latest unsubmitted date created in the amendment iterations array - let mostRecentDate = new Date( - Math.max.apply( - null, - amendmentIterations.map(iteration => (_.isUndefined(iteration.dateSubmitted) ? new Date(iteration.dateCreated) : '')) - ) - ); - // 3. Pull out the related object using a filter to find the object with the latest date - return amendmentIterations.findIndex(iteration => { - let date = new Date(iteration.dateCreated); - return date.getTime() == mostRecentDate.getTime(); - }); -}; - -const getAmendmentIterationParty = accessRecord => { - // 1. Look for an amendment iteration that is in flight - // An empty date submitted with populated date returned indicates that the current correction iteration is now with the applicants - let index = accessRecord.amendmentIterations.findIndex(v => _.isUndefined(v.dateSubmitted) && !_.isUndefined(v.dateReturned)); - // 2. Deduce the user type from the current iteration state - if (index === -1) { - return constants.userTypes.CUSTODIAN; - } else { - return constants.userTypes.APPLICANT; - } -}; - -const filterAmendments = (accessRecord = {}, userType) => { - if (_.isEmpty(accessRecord)) { - return {}; - } - let { amendmentIterations = [] } = accessRecord; - // 1. Extract all relevant iteration objects and answers based on the user type - // Applicant should only see requested amendments that have been returned by the custodian - if (userType === constants.userTypes.APPLICANT) { - amendmentIterations = [...amendmentIterations].filter(iteration => { - return !_.isUndefined(iteration.dateReturned); - }); - } else if (userType === constants.userTypes.CUSTODIAN) { - // Custodian should only see amendment answers that have been submitted by the applicants - amendmentIterations = [...amendmentIterations].map(iteration => { - if (_.isUndefined(iteration.dateSubmitted) && !_.isNil(iteration.questionAnswers)) { - iteration = removeIterationAnswers(accessRecord, iteration); + // 7. Return unauthorised message if the user did not have sufficient access for action requested + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - return iteration; - }); - } - // 2. Return relevant iterations - return amendmentIterations; -}; - -const injectAmendments = (accessRecord, userType, user) => { - // 1. Get latest iteration created by Custodian - if (accessRecord.amendmentIterations.length === 0) { - return accessRecord; - } - const lastIndex = _.findLastIndex(accessRecord.amendmentIterations); - let latestIteration = accessRecord.amendmentIterations[lastIndex]; - const { dateReturned } = latestIteration; - // 2. Applicants should see previous amendment iteration requests until current iteration has been returned with new requests - if ( - lastIndex > 0 && (userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) || - (userType === constants.userTypes.CUSTODIAN && _.isNil(latestIteration.questionAnswers)) - ) { - latestIteration = accessRecord.amendmentIterations[lastIndex - 1]; - } else if (lastIndex === 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) { - return accessRecord; - } - // 3. Update schema if there is a new iteration - const { publisher = 'Custodian' } = accessRecord; - if(!_.isNil(latestIteration)) { - accessRecord.jsonSchema = formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher); - } - // 4. Filter out amendments that have not yet been exposed to the opposite party - let amendmentIterations = filterAmendments(accessRecord, userType); - // 5. Update the question answers to reflect all the changes that have been made in later iterations - accessRecord.questionAnswers = formatQuestionAnswers(accessRecord.questionAnswers, amendmentIterations); - // 6. Return the updated access record - return accessRecord; -}; - -const formatSchema = (jsonSchema, latestAmendmentIteration, userType, user, publisher) => { - const { questionAnswers = {}, dateSubmitted, dateReturned } = latestAmendmentIteration; - if(_.isEmpty(questionAnswers)) { - return jsonSchema; - } - // Loop through each amendment - for (let questionId in questionAnswers) { - const { questionSetId, answer } = questionAnswers[questionId]; - // 1. Update parent/child navigation with flags for amendments - const amendmentCompleted = _.isNil(answer) ? 'incomplete' : 'completed'; - const iterationStatus = !_.isNil(dateSubmitted) ? 'submitted' : !_.isNil(dateReturned) ? 'returned' : 'inProgress'; - jsonSchema = injectNavigationAmendment(jsonSchema, questionSetId, userType, amendmentCompleted, iterationStatus); - // 2. Update questions with alerts/actions - jsonSchema = injectQuestionAmendment( - jsonSchema, - questionId, - questionAnswers[questionId], - userType, - amendmentCompleted, - iterationStatus, - user, - publisher - ); - } - return jsonSchema; -}; - -const injectQuestionAmendment = (jsonSchema, questionId, amendment, userType, completed, iterationStatus, user, publisher) => { - const { questionSetId } = amendment; - // 1. Find question set containing question - const qsIndex = jsonSchema.questionSets.findIndex(qs => qs.questionSetId === questionSetId); - if (qsIndex === -1) { - return jsonSchema; - } - let { questions } = jsonSchema.questionSets[qsIndex]; - // 2. Find question object - let question = datarequestUtil.findQuestion(questions, questionId); - if (_.isEmpty(question) || _.isNil(question.input)) { - return jsonSchema; - } - // 3. Create question alert object to highlight amendment - const questionAlert = datarequestUtil.buildQuestionAlert(userType, iterationStatus, completed, amendment, user, publisher); - // 4. Update question to contain amendment state - const readOnly = userType === constants.userTypes.CUSTODIAN || iterationStatus === 'submitted'; - question = datarequestUtil.setQuestionState(question, questionAlert, readOnly); - // 5. Update jsonSchema with updated question - jsonSchema.questionSets[qsIndex].questions = datarequestUtil.updateQuestion(questions, question); - // 6. Return updated schema - return jsonSchema; -}; - -const injectNavigationAmendment = (jsonSchema, questionSetId, userType, completed, iterationStatus) => { - // 1. Find question in schema - const qpIndex = jsonSchema.questionPanels.findIndex(qp => qp.panelId === questionSetId); - if (qpIndex === -1) { - return jsonSchema; - } - const pageIndex = jsonSchema.pages.findIndex(page => page.pageId === jsonSchema.questionPanels[qpIndex].pageId); - if (pageIndex === -1) { - return jsonSchema; - } - // 2. Update child navigation item (panel) - jsonSchema.questionPanels[qpIndex].flag = constants.navigationFlags[userType][iterationStatus][completed].status; - // 3. Update parent navigation item (page) - const { flag: pageFlag = '' } = jsonSchema.pages[pageIndex]; - if (pageFlag !== 'DANGER' && pageFlag !== 'WARNING') { - jsonSchema.pages[pageIndex].flag = constants.navigationFlags[userType][iterationStatus][completed].status; - } - // 4. Return schema - return jsonSchema; -}; - -const getLatestQuestionAnswer = (accessRecord, questionId) => { - // 1. Include original submission of question answer - let parsedQuestionAnswers = _.cloneDeep(accessRecord.questionAnswers); - let initialSubmission = { - questionAnswers: { - [`${questionId}`]: { - answer: parsedQuestionAnswers[questionId], - dateUpdated: accessRecord.dateSubmitted, - }, - }, - }; - let relevantVersions = [initialSubmission, ...accessRecord.amendmentIterations]; - if (relevantVersions.length > 1) { - relevantVersions = _.slice(relevantVersions, 0, relevantVersions.length - 1); - } - // 2. Reduce all versions to find latest instance of question answer - const latestAnswers = relevantVersions.reduce((arr, version) => { - // 3. Move to next version if the question was not modified in this one - if (_.isNil(version.questionAnswers[questionId])) { - return arr; - } - let { answer, dateUpdated } = version.questionAnswers[questionId]; - let foundIndex = arr.findIndex(amendment => amendment.questionId === questionId); - // 4. If the amendment does not exist in our array of latest answers, add it - if (foundIndex === -1) { - arr.push({ questionId, answer, dateUpdated }); - // 5. Otherwise update the amendment if this amendment was made more recently - } else if (new Date(dateUpdated).getTime() > new Date(arr[foundIndex].dateUpdated).getTime()) { - arr[foundIndex] = { questionId, answer, dateUpdated }; - } - return arr; - }, []); - - if (_.isEmpty(latestAnswers)) { - return undefined; - } else { - return latestAnswers[0].answer; - } -}; - -const formatQuestionAnswers = (questionAnswers, amendmentIterations) => { - if (_.isNil(amendmentIterations) || _.isEmpty(amendmentIterations)) { - return questionAnswers; - } - // 1. Reduce all amendment iterations to find latest answers - const latestAnswers = amendmentIterations.reduce((arr, iteration) => { - if (_.isNil(iteration.questionAnswers)) { - return arr; - } - // 2. Loop through each amendment key per iteration - Object.keys(iteration.questionAnswers).forEach(questionId => { - let { answer, dateUpdated } = iteration.questionAnswers[questionId]; - let foundIndex = arr.findIndex(amendment => amendment.questionId === questionId); - // 3. If the amendment does not exist in our array of latest answers, add it - if (foundIndex === -1) { - arr.push({ questionId, answer, dateUpdated }); - // 4. Otherwise update the amendment if this amendment was made more recently - } else if (new Date(dateUpdated).getTime() > new Date(arr[foundIndex].dateUpdated).getTime()) { - arr[foundIndex] = { questionId, answer, dateUpdated }; + // 8. Return bad request if the opposite party is editing the application + if (!validParty) { + return res.status(400).json({ + status: 'failure', + message: 'You cannot make or request amendments to this application as the opposite party are currently responsible for it.', + }); } - }); - return arr; - }, []); - // 5. Format data correctly for question answers - const formattedLatestAnswers = [...latestAnswers].reduce((obj, item) => { - if (!_.isNil(item.answer)) { - obj[item.questionId] = item.answer; - } - return obj; - }, {}); - // 6. Return combined object - return { ...questionAnswers, ...formattedLatestAnswers }; -}; - -const getCurrentAmendmentIteration = amendmentIterations => { - // 1. Guard for incorrect type passed - if (_.isEmpty(amendmentIterations) || _.isNull(amendmentIterations) || _.isUndefined(amendmentIterations)) { - return undefined; - } - // 2. Find the latest unsubmitted date created in the amendment iterations array - let mostRecentDate = new Date( - Math.max.apply( - null, - amendmentIterations.map(iteration => (_.isUndefined(iteration.dateSubmitted) ? new Date(iteration.dateCreated) : '')) - ) - ); - // 3. Pull out the related object using a filter to find the object with the latest date - let mostRecentObject = amendmentIterations.filter(iteration => { - let date = new Date(iteration.dateCreated); - return date.getTime() == mostRecentDate.getTime(); - })[0]; - // 4. Return the correct object - return mostRecentObject; -}; - -const removeIterationAnswers = (accessRecord = {}, iteration) => { - // 1. Guard for invalid object passed - if (!iteration || _.isEmpty(accessRecord)) { - return undefined; - } - // 2. Loop through each question answer by key (questionId) - Object.keys(iteration.questionAnswers).forEach(key => { - // 3. Fetch the previous answer - iteration.questionAnswers[key]['answer'] = getLatestQuestionAnswer(accessRecord, key); - }); - // 4. Return answer stripped iteration object - return iteration; -}; - -const doResubmission = (accessRecord, userId) => { - // 1. Find latest iteration and if not found, return access record unmodified as no resubmission should take place - let index = getLatestAmendmentIterationIndex(accessRecord); - if (index === -1) { - return accessRecord; - } - // 2. Mark submission type as a resubmission later used to determine notification generation - accessRecord.submissionType = constants.submissionTypes.RESUBMISSION; - accessRecord.amendmentIterations[index] = { - ...accessRecord.amendmentIterations[index], - dateSubmitted: new Date(), - submittedBy: userId, - }; - // 3. Return updated access record for saving - return accessRecord; -}; - -const countUnsubmittedAmendments = (accessRecord, userType) => { - // 1. Find latest iteration and if not found, return 0 - let unansweredAmendments = 0; - let answeredAmendments = 0; - let index = getLatestAmendmentIterationIndex(accessRecord); - if ( - index === -1 || - _.isNil(accessRecord.amendmentIterations[index].questionAnswers) || - (_.isNil(accessRecord.amendmentIterations[index].dateReturned) && userType == constants.userTypes.APPLICANT) - ) { - return { unansweredAmendments: 0, answeredAmendments: 0 }; - } - // 2. Count answered and unanswered amendments in unsubmitted iteration - Object.keys(accessRecord.amendmentIterations[index].questionAnswers).forEach(questionId => { - if (_.isNil(accessRecord.amendmentIterations[index].questionAnswers[questionId].answer)) { - unansweredAmendments++; - } else { - answeredAmendments++; + // 9. Save changes to database + await accessRecord.save(async err => { + if (err) { + console.error(err.message); + return res.status(500).json({ status: 'error', message: err.message }); + } else { + // 10. Update json schema and question answers with modifications since original submission + let accessRecordObj = accessRecord.toObject(); + accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user); + // 11. Append question actions depending on user type and application status + let userRole = activeParty === constants.userTypes.CUSTODIAN ? constants.roleTypes.MANAGER : ''; + accessRecordObj.jsonSchema = datarequestUtil.injectQuestionActions( + accessRecordObj.jsonSchema, + userType, + accessRecordObj.applicationStatus, + userRole, + activeParty + ); + // 12. Count the number of answered/unanswered amendments + const { answeredAmendments = 0, unansweredAmendments = 0 } = this.amendmentService.countUnsubmittedAmendments(accessRecord, userType); + return res.status(200).json({ + success: true, + accessRecord: { + amendmentIterations: accessRecordObj.amendmentIterations, + questionAnswers: accessRecordObj.questionAnswers, + jsonSchema: accessRecordObj.jsonSchema, + answeredAmendments, + unansweredAmendments, + }, + }); + } + }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the application amendment', + }); } - }); - // 3. Return counts - return { unansweredAmendments, answeredAmendments }; -}; - -const revertAmendmentAnswer = (accessRecord, questionId, user) => { - // 1. Locate the latest amendment iteration - let index = getLatestAmendmentIterationIndex(accessRecord); - // 2. Verify the amendment was previously requested and a new answer exists - let amendment = accessRecord.amendmentIterations[index].questionAnswers[questionId]; - if (_.isNil(amendment) || _.isNil(amendment.answer)) { - return; - } else { - // 3. Remove the updated answer - amendment = { - [`${questionId}`]: new AmendmentModel({ - ...amendment, - updatedBy: undefined, - updatedByUser: undefined, - dateUpdated: undefined, - answer: undefined, - }), - }; - accessRecord.amendmentIterations[index].questionAnswers = { ...accessRecord.amendmentIterations[index].questionAnswers, ...amendment }; } -}; - -const createNotifications = async (type, accessRecord) => { - // Project details from about application - let { aboutApplication = {}, questionAnswers } = accessRecord; - let { projectName = 'No project name set' } = aboutApplication; - let { dateSubmitted = '' } = accessRecord; - // Publisher details from single dataset - let { - datasetfields: { publisher }, - } = accessRecord.datasets[0]; - // Dataset titles - let datasetTitles = accessRecord.datasets.map(dataset => dataset.name).join(', '); - // Main applicant (user obj) - let { firstname: appFirstName, lastname: appLastName } = accessRecord.mainApplicant; - // Instantiate default params - let emailRecipients = [], - options = {}, - html = '', - authors = []; - let applicants = datarequestUtil.extractApplicantNames(questionAnswers).join(', '); - // Fall back for single applicant - if (_.isEmpty(applicants)) { - applicants = `${appFirstName} ${appLastName}`; - } - // Get authors/contributors (user obj) - if (!_.isEmpty(accessRecord.authors)) { - authors = accessRecord.authors.map(author => { - let { firstname, lastname, email, id } = author; - return { firstname, lastname, email, id }; - }); - } - - switch (type) { - case constants.notificationTypes.RETURNED: - // 1. Create notifications - // Applicant notification - await notificationBuilder.triggerNotificationMessage( - [accessRecord.userId], - `Updates have been requested by ${publisher} for your Data Access Request application`, - 'data access request', - accessRecord._id - ); - // Authors notification - if (!_.isEmpty(authors)) { - await notificationBuilder.triggerNotificationMessage( - authors.map(author => author.id), - `Updates have been requested by ${publisher} for a Data Access Request application you are contributing to`, - 'data access request', - accessRecord._id - ); + async requestAmendments(req, res) { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + // 2. Retrieve DAR from database + let accessRecord = await DataRequestModel.findOne({ _id: id }) + .select({ + _id: 1, + publisher: 1, + amendmentIterations: 1, + datasetIds: 1, + dataSetId: 1, + userId: 1, + authorIds: 1, + applicationStatus: 1, + aboutApplication: 1, + dateSubmitted: 1, + }) + .populate([ + { + path: 'datasets dataset mainApplicant authors', + }, + { + path: 'publisherObj', + select: '_id', + populate: { + path: 'team', + populate: { + path: 'users', + }, + }, + }, + ]); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); } - - // 2. Send emails to relevant users - emailRecipients = [accessRecord.mainApplicant, ...accessRecord.authors]; - // Create object to pass through email data - options = { - id: accessRecord._id, - publisher, - projectName, - datasetTitles, - dateSubmitted, - applicants, - }; - // Create email body content - html = emailGenerator.generateDARReturnedEmail(options); - // Send email - await emailGenerator.sendEmail( - emailRecipients, - constants.hdrukEmail, - `Updates have been requested by ${publisher} for your Data Access Request application`, - html, - false - ); - break; - } -}; - -const calculateAmendmentStatus = (accessRecord, userType) => { - let amendmentStatus = ''; - const lastAmendmentIteration = _.last(accessRecord.amendmentIterations); - const { applicationStatus } = accessRecord; - // 1. Amendment status is blank if no amendments have ever been created or the application has had a final decision - if ( - _.isNil(lastAmendmentIteration) || - applicationStatus === constants.applicationStatuses.APPROVED || - applicationStatus === constants.applicationStatuses.APPROVEDWITHCONDITIONS || - applicationStatus === constants.applicationStatuses.REJECTED - ) { - return ''; - } - const { dateSubmitted = '', dateReturned = '' } = lastAmendmentIteration; - // 2a. If the requesting user is the applicant - if (userType === constants.userTypes.APPLICANT) { - if (!_.isEmpty(dateSubmitted.toString())) { - amendmentStatus = constants.amendmentStatuses.UPDATESSUBMITTED; - } else if (!_.isEmpty(dateReturned.toString())) { - amendmentStatus = constants.amendmentStatuses.UPDATESREQUESTED; - } - // 2b. If the requester user is the custodian - } else if (userType === constants.userTypes.CUSTODIAN) { - if (!_.isEmpty(dateSubmitted.toString())) { - amendmentStatus = constants.amendmentStatuses.UPDATESRECEIVED; - } else if (!_.isEmpty(dateReturned.toString())) { - amendmentStatus = constants.amendmentStatuses.AWAITINGUPDATES; + // 3. Check permissions of user is manager of associated team + let authorised = false; + if (_.has(accessRecord.toObject(), 'publisherObj.team')) { + const { team } = accessRecord.publisherObj; + authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team.toObject(), req.user._id); + } + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) + if (_.isEmpty(accessRecord.datasets)) { + accessRecord.datasets = [accessRecord.dataset]; + } + // 5. Get the current iteration amendment party and return bad request if the opposite party is editing the application + const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); + if (activeParty !== constants.userTypes.CUSTODIAN) { + return res.status(400).json({ + status: 'failure', + message: 'You cannot make or request amendments to this application as the applicant(s) are amending the current version.', + }); + } + // 6. Check some amendments exist to be submitted to the applicant(s) + const { unansweredAmendments } = this.amendmentService.countUnsubmittedAmendments(accessRecord, constants.userTypes.CUSTODIAN); + if (unansweredAmendments === 0) { + return res.status(400).json({ + status: 'failure', + message: 'You cannot submit requested amendments as none have been requested in the current version', + }); + } + // 7. Find current amendment iteration index + const index = this.amendmentService.getLatestAmendmentIterationIndex(accessRecord); + // 8. Update amendment iteration status to returned, handing responsibility over to the applicant(s) + accessRecord.amendmentIterations[index].dateReturned = new Date(); + accessRecord.amendmentIterations[index].returnedBy = req.user._id; + // 9. Save changes to database + await accessRecord.save(async err => { + if (err) { + console.error(err.message); + return res.status(500).json({ status: 'error', message: err.message }); + } else { + // 10. Send update request notifications + createNotifications(constants.notificationTypes.RETURNED, accessRecord); + return res.status(200).json({ + success: true, + }); + } + }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred attempting to submit the requested updates', + }); } } - return amendmentStatus; -}; - -module.exports = { - handleApplicantAmendment: handleApplicantAmendment, - doesAmendmentExist: doesAmendmentExist, - doResubmission: doResubmission, - updateAmendment: updateAmendment, - revertAmendmentAnswer: revertAmendmentAnswer, - setAmendment: setAmendment, - addAmendment: addAmendment, - removeAmendment: removeAmendment, - filterAmendments: filterAmendments, - removeIterationAnswers: removeIterationAnswers, - getCurrentAmendmentIteration: getCurrentAmendmentIteration, - getLatestAmendmentIterationIndex: getLatestAmendmentIterationIndex, - getAmendmentIterationParty: getAmendmentIterationParty, - injectAmendments: injectAmendments, - formatQuestionAnswers: formatQuestionAnswers, - countUnsubmittedAmendments: countUnsubmittedAmendments, - getLatestQuestionAnswer: getLatestQuestionAnswer, - requestAmendments: requestAmendments, - calculateAmendmentStatus: calculateAmendmentStatus, - injectNavigationAmendment: injectNavigationAmendment, -}; +} diff --git a/src/resources/datarequest/amendment/amendment.repository.js b/src/resources/datarequest/amendment/amendment.repository.js new file mode 100644 index 00000000..34f43879 --- /dev/null +++ b/src/resources/datarequest/amendment/amendment.repository.js @@ -0,0 +1,9 @@ +import Repository from '../../base/repository'; +import { DataRequestModel } from '../datarequest.model'; + +export default class AmendmentRepository extends Repository { + constructor() { + super(DataRequestModel); + this.dataRequestModel = DataRequestModel; + } +} diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js new file mode 100644 index 00000000..cd5e578f --- /dev/null +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -0,0 +1,582 @@ +import { AmendmentModel } from './amendment.model'; +import constants from '../../utilities/constants.util'; +import helperUtil from '../../utilities/helper.util'; +import datarequestUtil from '../utils/datarequest.util'; +import notificationBuilder from '../../utilities/notificationBuilder'; +import emailGenerator from '../../utilities/emailGenerator.util'; + +export default class AmendmentService { + constructor(amendmentRepository) { + this.amendmentRepository = amendmentRepository; + } + + addAmendment (accessRecord, questionId, questionSetId, answer, reason, user, requested) { + // 1. Create new amendment object with key representing the questionId + let amendment = { + [`${questionId}`]: new AmendmentModel({ + questionSetId, + requested, + reason, + answer, + requestedBy: requested ? `${user.firstname} ${user.lastname}` : undefined, + requestedByUser: requested ? user._id : undefined, + dateRequested: requested ? Date.now() : undefined, + updatedBy: requested ? undefined : `${user.firstname} ${user.lastname}`, + updatedByUser: requested ? undefined : user._id, + dateUpdated: requested ? undefined : Date.now(), + }), + }; + // 2. Find the index of the latest amendment iteration of the DAR + let index = this.getLatestAmendmentIterationIndex(accessRecord); + // 3. If index is not -1, we need to append the new amendment to existing iteration object otherwise create a new one + if (index !== -1) { + accessRecord.amendmentIterations[index].questionAnswers = { + ...accessRecord.amendmentIterations[index].questionAnswers, + ...amendment, + }; + } else { + // 4. If new iteration has been trigger by applicant given requested is false, then we automatically return the iteration + let amendmentIteration = { + dateReturned: requested ? undefined : Date.now(), + returnedBy: requested ? undefined : user._id, + dateCreated: Date.now(), + createdBy: user._id, + questionAnswers: { ...amendment }, + }; + accessRecord.amendmentIterations = [...accessRecord.amendmentIterations, amendmentIteration]; + } + }; + + updateAmendment (accessRecord, questionId, answer, user) { + // 1. Locate amendment in current iteration + const currentIterationIndex = this.getLatestAmendmentIterationIndex(accessRecord); + // 2. Return unmoodified record if invalid update + if (currentIterationIndex === -1 || _.isNil(accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId])) { + return accessRecord; + } + // 3. Check if the update amendment reflects a change since the last version of the answer + if (currentIterationIndex > -1) { + const latestAnswer = this.getLatestQuestionAnswer(accessRecord, questionId); + const requested = accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId].requested || false; + if (!_.isNil(latestAnswer)) { + if (answer === latestAnswer || helperUtil.arraysEqual(answer, latestAnswer)) { + if (requested) { + // Retain the requested amendment but remove the answer + delete accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId].answer; + } else { + this.removeAmendment(accessRecord, questionId); + } + return accessRecord; + } + } else if (_.isNil(latestAnswer) && _.isEmpty(answer) && !requested) { + // Remove the amendment if there was no previous answer and the latest update is empty + this.removeAmendment(accessRecord, questionId); + return accessRecord; + } + } + // 4. Find and update the question with the new answer + accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId] = { + ...accessRecord.amendmentIterations[currentIterationIndex].questionAnswers[questionId], + answer, + updatedBy: `${user.firstname} ${user.lastname}`, + updatedByUser: user._id, + dateUpdated: Date.now(), + }; + // 5. Return updated access record + return accessRecord; + }; + + removeAmendment (accessRecord, questionId) { + // 1. Find the index of the latest amendment amendmentIteration of the DAR + let index = this.getLatestAmendmentIterationIndex(accessRecord); + // 2. Remove the key and associated object from the current iteration if it exists + if (index !== -1) { + accessRecord.amendmentIterations[index].questionAnswers = _.omit(accessRecord.amendmentIterations[index].questionAnswers, questionId); + // 3. If question answers is now empty, remove the iteration + _.remove(accessRecord.amendmentIterations, amendmentIteration => { + return _.isEmpty(amendmentIteration.questionAnswers); + }); + } + }; + + doesAmendmentExist (accessRecord, questionId) { + // 1. Get current amendment iteration + const latestIteration = this.getCurrentAmendmentIteration(accessRecord.amendmentIterations); + if (_.isNil(latestIteration) || _.isNil(latestIteration.questionAnswers)) { + return false; + } + // 2. Check if questionId has been added by Custodian for amendment + return latestIteration.questionAnswers.hasOwnProperty(questionId); + }; + + handleApplicantAmendment (accessRecord, questionId, questionSetId, answer = '', user) { + // 1. Check if an amendment already exists for the question + let isExisting = this.doesAmendmentExist(accessRecord, questionId); + // 2. Update existing + if (isExisting) { + accessRecord = this.updateAmendment(accessRecord, questionId, answer, user); + } else { + // 3. Get the latest/previous answer for this question for comparison to new answer + const latestAnswer = this.getLatestQuestionAnswer(accessRecord, questionId); + let performAdd = false; + // 4. Always add the new amendment if there was no original answer + if (_.isNil(latestAnswer)) { + performAdd = true; + // 5. If a previous answer exists, ensure it is different to the most recent answer before adding + } else if (answer !== latestAnswer || !helperUtil.arraysEqual(answer, latestAnswer)) { + performAdd = true; + } + + if (performAdd) { + // 6. Add new amendment otherwise + this.addAmendment(accessRecord, questionId, questionSetId, answer, '', user, false); + } + } + // 7. Update the amendment count + let { unansweredAmendments = 0, answeredAmendments = 0 } = this.countUnsubmittedAmendments(accessRecord, constants.userTypes.APPLICANT); + accessRecord.unansweredAmendments = unansweredAmendments; + accessRecord.answeredAmendments = answeredAmendments; + accessRecord.dirtySchema = true; + // 8. Return updated access record + return accessRecord; + }; + + getLatestAmendmentIterationIndex (accessRecord) { + // 1. Guard for incorrect type passed + let { amendmentIterations = [] } = accessRecord; + if (_.isEmpty(amendmentIterations)) { + return -1; + } + // 2. Find the latest unsubmitted date created in the amendment iterations array + let mostRecentDate = new Date( + Math.max.apply( + null, + amendmentIterations.map(iteration => (_.isUndefined(iteration.dateSubmitted) ? new Date(iteration.dateCreated) : '')) + ) + ); + // 3. Pull out the related object using a filter to find the object with the latest date + return amendmentIterations.findIndex(iteration => { + let date = new Date(iteration.dateCreated); + return date.getTime() == mostRecentDate.getTime(); + }); + }; + + getAmendmentIterationParty (accessRecord) { + // 1. Look for an amendment iteration that is in flight + // An empty date submitted with populated date returned indicates that the current correction iteration is now with the applicants + let index = accessRecord.amendmentIterations.findIndex(v => _.isUndefined(v.dateSubmitted) && !_.isUndefined(v.dateReturned)); + // 2. Deduce the user type from the current iteration state + if (index === -1) { + return constants.userTypes.CUSTODIAN; + } else { + return constants.userTypes.APPLICANT; + } + }; + + filterAmendments (accessRecord = {}, userType) { + if (_.isEmpty(accessRecord)) { + return {}; + } + let { amendmentIterations = [] } = accessRecord; + // 1. Extract all relevant iteration objects and answers based on the user type + // Applicant should only see requested amendments that have been returned by the custodian + if (userType === constants.userTypes.APPLICANT) { + amendmentIterations = [...amendmentIterations].filter(iteration => { + return !_.isUndefined(iteration.dateReturned); + }); + } else if (userType === constants.userTypes.CUSTODIAN) { + // Custodian should only see amendment answers that have been submitted by the applicants + amendmentIterations = [...amendmentIterations].map(iteration => { + if (_.isUndefined(iteration.dateSubmitted) && !_.isNil(iteration.questionAnswers)) { + iteration = this.removeIterationAnswers(accessRecord, iteration); + } + return iteration; + }); + } + // 2. Return relevant iterations + return amendmentIterations; + }; + + injectAmendments (accessRecord, userType, user) { + // 1. Get latest iteration created by Custodian + if (accessRecord.amendmentIterations.length === 0) { + return accessRecord; + } + const lastIndex = _.findLastIndex(accessRecord.amendmentIterations); + let latestIteration = accessRecord.amendmentIterations[lastIndex]; + const { dateReturned } = latestIteration; + // 2. Applicants should see previous amendment iteration requests until current iteration has been returned with new requests + if ( + lastIndex > 0 && (userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) || + (userType === constants.userTypes.CUSTODIAN && _.isNil(latestIteration.questionAnswers)) + ) { + latestIteration = accessRecord.amendmentIterations[lastIndex - 1]; + } else if (lastIndex === 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) { + return accessRecord; + } + // 3. Update schema if there is a new iteration + const { publisher = 'Custodian' } = accessRecord; + if(!_.isNil(latestIteration)) { + accessRecord.jsonSchema = this.formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher); + } + // 4. Filter out amendments that have not yet been exposed to the opposite party + let amendmentIterations = this.filterAmendments(accessRecord, userType); + // 5. Update the question answers to reflect all the changes that have been made in later iterations + accessRecord.questionAnswers = this.formatQuestionAnswers(accessRecord.questionAnswers, amendmentIterations); + // 6. Return the updated access record + return accessRecord; + }; + + formatSchema (jsonSchema, latestAmendmentIteration, userType, user, publisher) { + const { questionAnswers = {}, dateSubmitted, dateReturned } = latestAmendmentIteration; + if(_.isEmpty(questionAnswers)) { + return jsonSchema; + } + // Loop through each amendment + for (let questionId in questionAnswers) { + const { questionSetId, answer } = questionAnswers[questionId]; + // 1. Update parent/child navigation with flags for amendments + const amendmentCompleted = _.isNil(answer) ? 'incomplete' : 'completed'; + const iterationStatus = !_.isNil(dateSubmitted) ? 'submitted' : !_.isNil(dateReturned) ? 'returned' : 'inProgress'; + jsonSchema = this.injectNavigationAmendment(jsonSchema, questionSetId, userType, amendmentCompleted, iterationStatus); + // 2. Update questions with alerts/actions + jsonSchema = this.injectQuestionAmendment( + jsonSchema, + questionId, + questionAnswers[questionId], + userType, + amendmentCompleted, + iterationStatus, + user, + publisher + ); + } + return jsonSchema; + }; + + injectQuestionAmendment (jsonSchema, questionId, amendment, userType, completed, iterationStatus, user, publisher) { + const { questionSetId } = amendment; + // 1. Find question set containing question + const qsIndex = jsonSchema.questionSets.findIndex(qs => qs.questionSetId === questionSetId); + if (qsIndex === -1) { + return jsonSchema; + } + let { questions } = jsonSchema.questionSets[qsIndex]; + // 2. Find question object + let question = datarequestUtil.findQuestion(questions, questionId); + if (_.isEmpty(question) || _.isNil(question.input)) { + return jsonSchema; + } + // 3. Create question alert object to highlight amendment + const questionAlert = datarequestUtil.buildQuestionAlert(userType, iterationStatus, completed, amendment, user, publisher); + // 4. Update question to contain amendment state + const readOnly = userType === constants.userTypes.CUSTODIAN || iterationStatus === 'submitted'; + question = datarequestUtil.setQuestionState(question, questionAlert, readOnly); + // 5. Update jsonSchema with updated question + jsonSchema.questionSets[qsIndex].questions = datarequestUtil.updateQuestion(questions, question); + // 6. Return updated schema + return jsonSchema; + }; + + injectNavigationAmendment (jsonSchema, questionSetId, userType, completed, iterationStatus) { + // 1. Find question in schema + const qpIndex = jsonSchema.questionPanels.findIndex(qp => qp.panelId === questionSetId); + if (qpIndex === -1) { + return jsonSchema; + } + const pageIndex = jsonSchema.pages.findIndex(page => page.pageId === jsonSchema.questionPanels[qpIndex].pageId); + if (pageIndex === -1) { + return jsonSchema; + } + // 2. Update child navigation item (panel) + jsonSchema.questionPanels[qpIndex].flag = constants.navigationFlags[userType][iterationStatus][completed].status; + // 3. Update parent navigation item (page) + const { flag: pageFlag = '' } = jsonSchema.pages[pageIndex]; + if (pageFlag !== 'DANGER' && pageFlag !== 'WARNING') { + jsonSchema.pages[pageIndex].flag = constants.navigationFlags[userType][iterationStatus][completed].status; + } + // 4. Return schema + return jsonSchema; + }; + + getLatestQuestionAnswer (accessRecord, questionId) { + // 1. Include original submission of question answer + let parsedQuestionAnswers = _.cloneDeep(accessRecord.questionAnswers); + let initialSubmission = { + questionAnswers: { + [`${questionId}`]: { + answer: parsedQuestionAnswers[questionId], + dateUpdated: accessRecord.dateSubmitted, + }, + }, + }; + let relevantVersions = [initialSubmission, ...accessRecord.amendmentIterations]; + if (relevantVersions.length > 1) { + relevantVersions = _.slice(relevantVersions, 0, relevantVersions.length - 1); + } + // 2. Reduce all versions to find latest instance of question answer + const latestAnswers = relevantVersions.reduce((arr, version) => { + // 3. Move to next version if the question was not modified in this one + if (_.isNil(version.questionAnswers[questionId])) { + return arr; + } + let { answer, dateUpdated } = version.questionAnswers[questionId]; + let foundIndex = arr.findIndex(amendment => amendment.questionId === questionId); + // 4. If the amendment does not exist in our array of latest answers, add it + if (foundIndex === -1) { + arr.push({ questionId, answer, dateUpdated }); + // 5. Otherwise update the amendment if this amendment was made more recently + } else if (new Date(dateUpdated).getTime() > new Date(arr[foundIndex].dateUpdated).getTime()) { + arr[foundIndex] = { questionId, answer, dateUpdated }; + } + return arr; + }, []); + + if (_.isEmpty(latestAnswers)) { + return undefined; + } else { + return latestAnswers[0].answer; + } + }; + + formatQuestionAnswers (questionAnswers, amendmentIterations) { + if (_.isNil(amendmentIterations) || _.isEmpty(amendmentIterations)) { + return questionAnswers; + } + // 1. Reduce all amendment iterations to find latest answers + const latestAnswers = amendmentIterations.reduce((arr, iteration) => { + if (_.isNil(iteration.questionAnswers)) { + return arr; + } + // 2. Loop through each amendment key per iteration + Object.keys(iteration.questionAnswers).forEach(questionId => { + let { answer, dateUpdated } = iteration.questionAnswers[questionId]; + let foundIndex = arr.findIndex(amendment => amendment.questionId === questionId); + // 3. If the amendment does not exist in our array of latest answers, add it + if (foundIndex === -1) { + arr.push({ questionId, answer, dateUpdated }); + // 4. Otherwise update the amendment if this amendment was made more recently + } else if (new Date(dateUpdated).getTime() > new Date(arr[foundIndex].dateUpdated).getTime()) { + arr[foundIndex] = { questionId, answer, dateUpdated }; + } + }); + return arr; + }, []); + // 5. Format data correctly for question answers + const formattedLatestAnswers = [...latestAnswers].reduce((obj, item) => { + if (!_.isNil(item.answer)) { + obj[item.questionId] = item.answer; + } + return obj; + }, {}); + // 6. Return combined object + return { ...questionAnswers, ...formattedLatestAnswers }; + }; + + getCurrentAmendmentIteration (amendmentIterations) { + // 1. Guard for incorrect type passed + if (_.isEmpty(amendmentIterations) || _.isNull(amendmentIterations) || _.isUndefined(amendmentIterations)) { + return undefined; + } + // 2. Find the latest unsubmitted date created in the amendment iterations array + let mostRecentDate = new Date( + Math.max.apply( + null, + amendmentIterations.map(iteration => (_.isUndefined(iteration.dateSubmitted) ? new Date(iteration.dateCreated) : '')) + ) + ); + // 3. Pull out the related object using a filter to find the object with the latest date + let mostRecentObject = amendmentIterations.filter(iteration => { + let date = new Date(iteration.dateCreated); + return date.getTime() == mostRecentDate.getTime(); + })[0]; + // 4. Return the correct object + return mostRecentObject; + }; + + removeIterationAnswers (accessRecord = {}, iteration) { + // 1. Guard for invalid object passed + if (!iteration || _.isEmpty(accessRecord)) { + return undefined; + } + // 2. Loop through each question answer by key (questionId) + Object.keys(iteration.questionAnswers).forEach(key => { + // 3. Fetch the previous answer + iteration.questionAnswers[key]['answer'] = this.getLatestQuestionAnswer(accessRecord, key); + }); + // 4. Return answer stripped iteration object + return iteration; + }; + + doResubmission (accessRecord, userId) { + // 1. Find latest iteration and if not found, return access record unmodified as no resubmission should take place + let index = this.getLatestAmendmentIterationIndex(accessRecord); + if (index === -1) { + return accessRecord; + } + // 2. Mark submission type as a resubmission later used to determine notification generation + accessRecord.submissionType = constants.submissionTypes.RESUBMISSION; + accessRecord.amendmentIterations[index] = { + ...accessRecord.amendmentIterations[index], + dateSubmitted: new Date(), + submittedBy: userId, + }; + // 3. Return updated access record for saving + return accessRecord; + }; + + countUnsubmittedAmendments (accessRecord, userType) { + // 1. Find latest iteration and if not found, return 0 + let unansweredAmendments = 0; + let answeredAmendments = 0; + let index = this.getLatestAmendmentIterationIndex(accessRecord); + if ( + index === -1 || + _.isNil(accessRecord.amendmentIterations[index].questionAnswers) || + (_.isNil(accessRecord.amendmentIterations[index].dateReturned) && userType == constants.userTypes.APPLICANT) + ) { + return { unansweredAmendments: 0, answeredAmendments: 0 }; + } + // 2. Count answered and unanswered amendments in unsubmitted iteration + Object.keys(accessRecord.amendmentIterations[index].questionAnswers).forEach(questionId => { + if (_.isNil(accessRecord.amendmentIterations[index].questionAnswers[questionId].answer)) { + unansweredAmendments++; + } else { + answeredAmendments++; + } + }); + // 3. Return counts + return { unansweredAmendments, answeredAmendments }; + }; + + revertAmendmentAnswer (accessRecord, questionId, user) { + // 1. Locate the latest amendment iteration + let index = this.getLatestAmendmentIterationIndex(accessRecord); + // 2. Verify the amendment was previously requested and a new answer exists + let amendment = accessRecord.amendmentIterations[index].questionAnswers[questionId]; + if (_.isNil(amendment) || _.isNil(amendment.answer)) { + return; + } else { + // 3. Remove the updated answer + amendment = { + [`${questionId}`]: new AmendmentModel({ + ...amendment, + updatedBy: undefined, + updatedByUser: undefined, + dateUpdated: undefined, + answer: undefined, + }), + }; + accessRecord.amendmentIterations[index].questionAnswers = { ...accessRecord.amendmentIterations[index].questionAnswers, ...amendment }; + } + }; + + calculateAmendmentStatus (accessRecord, userType) { + let amendmentStatus = ''; + const lastAmendmentIteration = _.last(accessRecord.amendmentIterations); + const { applicationStatus } = accessRecord; + // 1. Amendment status is blank if no amendments have ever been created or the application has had a final decision + if ( + _.isNil(lastAmendmentIteration) || + applicationStatus === constants.applicationStatuses.APPROVED || + applicationStatus === constants.applicationStatuses.APPROVEDWITHCONDITIONS || + applicationStatus === constants.applicationStatuses.REJECTED + ) { + return ''; + } + const { dateSubmitted = '', dateReturned = '' } = lastAmendmentIteration; + // 2a. If the requesting user is the applicant + if (userType === constants.userTypes.APPLICANT) { + if (!_.isEmpty(dateSubmitted.toString())) { + amendmentStatus = constants.amendmentStatuses.UPDATESSUBMITTED; + } else if (!_.isEmpty(dateReturned.toString())) { + amendmentStatus = constants.amendmentStatuses.UPDATESREQUESTED; + } + // 2b. If the requester user is the custodian + } else if (userType === constants.userTypes.CUSTODIAN) { + if (!_.isEmpty(dateSubmitted.toString())) { + amendmentStatus = constants.amendmentStatuses.UPDATESRECEIVED; + } else if (!_.isEmpty(dateReturned.toString())) { + amendmentStatus = constants.amendmentStatuses.AWAITINGUPDATES; + } + } + return amendmentStatus; + }; + + async createNotifications (type, accessRecord) { + // Project details from about application + let { aboutApplication = {}, questionAnswers } = accessRecord; + let { projectName = 'No project name set' } = aboutApplication; + let { dateSubmitted = '' } = accessRecord; + // Publisher details from single dataset + let { + datasetfields: { publisher }, + } = accessRecord.datasets[0]; + // Dataset titles + let datasetTitles = accessRecord.datasets.map(dataset => dataset.name).join(', '); + // Main applicant (user obj) + let { firstname: appFirstName, lastname: appLastName } = accessRecord.mainApplicant; + // Instantiate default params + let emailRecipients = [], + options = {}, + html = '', + authors = []; + let applicants = datarequestUtil.extractApplicantNames(questionAnswers).join(', '); + // Fall back for single applicant + if (_.isEmpty(applicants)) { + applicants = `${appFirstName} ${appLastName}`; + } + // Get authors/contributors (user obj) + if (!_.isEmpty(accessRecord.authors)) { + authors = accessRecord.authors.map(author => { + let { firstname, lastname, email, id } = author; + return { firstname, lastname, email, id }; + }); + } + + switch (type) { + case constants.notificationTypes.RETURNED: + // 1. Create notifications + // Applicant notification + await notificationBuilder.triggerNotificationMessage( + [accessRecord.userId], + `Updates have been requested by ${publisher} for your Data Access Request application`, + 'data access request', + accessRecord._id + ); + + // Authors notification + if (!_.isEmpty(authors)) { + await notificationBuilder.triggerNotificationMessage( + authors.map(author => author.id), + `Updates have been requested by ${publisher} for a Data Access Request application you are contributing to`, + 'data access request', + accessRecord._id + ); + } + + // 2. Send emails to relevant users + emailRecipients = [accessRecord.mainApplicant, ...accessRecord.authors]; + // Create object to pass through email data + options = { + id: accessRecord._id, + publisher, + projectName, + datasetTitles, + dateSubmitted, + applicants, + }; + // Create email body content + html = emailGenerator.generateDARReturnedEmail(options); + // Send email + await emailGenerator.sendEmail( + emailRecipients, + constants.hdrukEmail, + `Updates have been requested by ${publisher} for your Data Access Request application`, + html, + false + ); + break; + } + }; +} diff --git a/src/resources/datarequest/amendment/dependency.js b/src/resources/datarequest/amendment/dependency.js new file mode 100644 index 00000000..d7a1fd52 --- /dev/null +++ b/src/resources/datarequest/amendment/dependency.js @@ -0,0 +1,5 @@ +import AmendmentRepository from './amendment.repository'; +import AmendmentService from './amendment.service'; + +export const amendmentRepository = new AmendmentRepository(); +export const amendmentService = new AmendmentService(amendmentRepository); \ No newline at end of file diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index ba53f9af..2f409cd8 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -16,40 +16,48 @@ import constants from '../utilities/constants.util'; import { processFile, getFile, fileStatus } from '../utilities/cloudStorage.util'; import _ from 'lodash'; import inputSanitizer from '../utilities/inputSanitizer'; +import Controller from '../base/controller'; import moment from 'moment'; import mongoose from 'mongoose'; +import { logger } from '../utilities/logger'; +const logCategory = 'Data Access Request'; + const amendmentController = require('./amendment/amendment.controller'); const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); -module.exports = { - //GET api/v1/data-access-request - getAccessRequestsByUser: async (req, res) => { +export default class DataRequestController extends Controller { + constructor(dataRequestService, workflowService, amendmentService) { + super(dataRequestService); + this.dataRequestService = dataRequestService; + this.workflowService = workflowService; + this.amendmentService = amendmentService; + } + + async getAccessRequestsByUser(req, res) { try { - // 1. Deconstruct the parameters passed - let { id: userId } = req.user; + // Deconstruct the parameters passed let { query = {} } = req; + const userId = parseInt(req.user.id); - // 2. Find all data access request applications created with multi dataset version - let applications = await DataRequestModel.find({ - $and: [{ ...query }, { $or: [{ userId: parseInt(userId) }, { authorIds: userId }] }], - }) - .select('-jsonSchema -questionAnswers -files') - .populate('datasets mainApplicant') - .lean(); + // Find all data access request applications for requesting user + let applications = await this.dataRequestService.getAccessRequestsByUser(userId, query); - // 3. Append project name and applicants + // Create detailed application object including workflow, review meta details let modifiedApplications = [...applications] - .map(app => { - return module.exports.createApplicationDTO(app, constants.userTypes.APPLICANT); + .map(accessRecord => { + let accessRecordDTO = this.dataRequestService.createApplicationDTO(accessRecord, constants.userTypes.APPLICANT); + // Append amendment status + accessRecordDTO.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, userType); + return accessRecordDTO; }) .sort((a, b) => b.updatedAt - a.updatedAt); - // 4. Calculate average decision time across submitted applications - let avgDecisionTime = module.exports.calculateAvgDecisionTime(applications); + // Calculate average decision time across submitted applications + let avgDecisionTime = this.dataRequestService.calculateAvgDecisionTime(applications); - // 5. Return payload + // Return payload return res.status(200).json({ success: true, data: modifiedApplications, @@ -57,14 +65,17 @@ module.exports = { canViewSubmitted: true, }); } catch (err) { - console.error(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); return res.status(500).json({ success: false, message: 'An error occurred searching for user applications', }); } - }, + } +} +module.exports = { //GET api/v1/data-access-request/:requestId getAccessRequestById: async (req, res) => { try { @@ -105,11 +116,11 @@ module.exports = { readOnly = false; } // 7. Count unsubmitted amendments - let countUnsubmittedAmendments = amendmentController.countUnsubmittedAmendments(accessRecord, userType); + let countUnsubmittedAmendments = this.amendmentService.countUnsubmittedAmendments(accessRecord, userType); // 8. 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 } = this.workflowService.getReviewStatus(accessRecord, req.user._id); // 9. Get the workflow/voting status - let workflow = workflowController.getWorkflowStatus(accessRecord); + let workflow = this.workflowService.getWorkflowStatus(accessRecord); let isManager = false; // 10. Check if the current user can override the current step if (_.has(accessRecord.datasets[0], 'publisher.team')) { @@ -120,9 +131,9 @@ module.exports = { } } // 11. Update json schema and question answers with modifications since original submission - accessRecord = amendmentController.injectAmendments(accessRecord, userType, req.user); + accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); // 12. Determine the current active party handling the form - let activeParty = amendmentController.getAmendmentIterationParty(accessRecord); + let activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); // 13. Append question actions depending on user type and application status let userRole = userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; @@ -400,7 +411,7 @@ module.exports = { module.exports.updateApplication(accessRequestRecord, updateObj).then(accessRequestRecord => { const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = accessRequestRecord; if (dirtySchema) { - accessRequestRecord = amendmentController.injectAmendments(accessRequestRecord, constants.userTypes.APPLICANT, req.user); + accessRequestRecord = this.amendmentService.injectAmendments(accessRequestRecord, constants.userTypes.APPLICANT, req.user); } let data = { status: 'success', @@ -470,7 +481,7 @@ module.exports = { return accessRecord; } let updatedAnswer = updateObj.questionAnswers[updatedQuestionId]; - accessRecord = amendmentController.handleApplicantAmendment(accessRecord.toObject(), updatedQuestionId, '', updatedAnswer, user); + accessRecord = this.amendmentService.handleApplicantAmendment(accessRecord.toObject(), updatedQuestionId, '', updatedAnswer, user); await DataRequestModel.replaceOne({ _id }, accessRecord, err => { if (err) { console.error(err.message); @@ -789,12 +800,12 @@ module.exports = { dataRequestUserId: userId.toString(), dataRequestPublisher, dataRequestStepName: workflowObj.steps[0].stepName, - notifyReviewerSLA: workflowController.calculateStepDeadlineReminderDate(workflowObj.steps[0]), + notifyReviewerSLA: this.workflowService.calculateStepDeadlineReminderDate(workflowObj.steps[0]), reviewerList, }; bpmController.postStartStepReview(bpmContext); // 14. Gather context for notifications - const emailContext = workflowController.getWorkflowEmailContext(accessRecord, workflowObj, 0); + const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflowObj, 0); // 15. Create notifications to reviewers of the step that has been completed module.exports.createNotifications(constants.notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); // 16. Create our notifications to the custodian team managers if assigned a workflow to a DAR application @@ -1140,7 +1151,7 @@ module.exports = { newRecommendation, ]; // 11. Workflow management - construct Camunda payloads - let bpmContext = workflowController.buildNextStep(userId, accessRecord, activeStepIndex, false); + let bpmContext = this.workflowService.buildNextStep(userId, accessRecord, activeStepIndex, false); // 12. If step is now complete, update database record if (bpmContext.stepComplete) { accessRecord.workflow.steps[activeStepIndex].active = false; @@ -1172,7 +1183,7 @@ module.exports = { } // Continue only if notification required if (!_.isEmpty(relevantNotificationType)) { - const emailContext = workflowController.getWorkflowEmailContext(accessRecord, workflow, relevantStepIndex); + const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflow, relevantStepIndex); module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user); } // 16. Call Camunda controller to update workflow process @@ -1262,7 +1273,7 @@ module.exports = { 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); + let bpmContext = this.workflowService.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; @@ -1275,7 +1286,7 @@ module.exports = { res.status(500).json({ status: 'error', message: err.message }); } else { // 12. Gather context for notifications (active step) - let emailContext = workflowController.getWorkflowEmailContext(accessRecord, workflow, activeStepIndex); + let emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflow, activeStepIndex); // 13. Create notifications to reviewers of the step that has been completed module.exports.createNotifications(constants.notificationTypes.STEPOVERRIDE, emailContext, accessRecord, req.user); // 14. Create emails and notifications @@ -1292,7 +1303,7 @@ module.exports = { } // Get the email context only if required if (relevantStepIndex !== activeStepIndex) { - emailContext = workflowController.getWorkflowEmailContext(accessRecord, workflow, relevantStepIndex); + emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflow, relevantStepIndex); } module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user); // 15. Call Camunda controller to start manager review process @@ -1413,7 +1424,7 @@ module.exports = { accessRecord.applicationStatus === constants.applicationStatuses.INREVIEW || accessRecord.applicationStatus === constants.applicationStatuses.SUBMITTED ) { - accessRecord = amendmentController.doResubmission(accessRecord.toObject(), req.user._id.toString()); + accessRecord = this.amendmentService.doResubmission(accessRecord.toObject(), req.user._id.toString()); } // 6. Ensure a valid submission is taking place if (_.isNil(accessRecord.submissionType)) { @@ -1432,7 +1443,7 @@ module.exports = { }); } else { // 8. Send notifications and emails with amendments - accessRecord = amendmentController.injectAmendments(accessRecord, userType, req.user); + accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); await module.exports.createNotifications( accessRecord.submissionType === constants.submissionTypes.INITIAL ? constants.notificationTypes.SUBMITTED @@ -1581,7 +1592,7 @@ module.exports = { return step.active === true; }); // 3. Determine email context if deadline has elapsed or is approaching - const emailContext = workflowController.getWorkflowEmailContext(accessRecord, workflow, activeStepIndex); + const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflow, activeStepIndex); // 4. Send emails based on deadline elapsed or approaching if (emailContext.deadlineElapsed) { module.exports.createNotifications(constants.notificationTypes.DEADLINEPASSED, emailContext, accessRecord, req.user); @@ -1753,7 +1764,7 @@ module.exports = { } // 5. Update question answers with modifications since original submission - appToClone = amendmentController.injectAmendments(appToClone, constants.userTypes.APPLICANT, req.user); + appToClone = this.amendmentService.injectAmendments(appToClone, constants.userTypes.APPLICANT, req.user); // 6. Create callback function used to complete the save process const saveCallBack = (err, doc) => { @@ -2043,8 +2054,8 @@ module.exports = { if (_.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, constants.roleTypes.MANAGER); - let activeStep = workflowController.getActiveWorkflowStep(workflow); - stepReviewers = workflowController.getStepReviewers(activeStep); + let activeStep = this.workflowService.getActiveWorkflowStep(workflow); + stepReviewers = this.workflowService.getStepReviewers(activeStep); // Create custodian notification let statusChangeUserIds = [...custodianManagers, ...stepReviewers].map(user => user.id); await notificationBuilder.triggerNotificationMessage( @@ -2626,166 +2637,5 @@ module.exports = { ); break; } - }, - - createApplicationDTO: (app, userType, userId = '') => { - let projectName = '', - applicants = '', - workflowName = '', - workflowCompleted = false, - remainingActioners = [], - decisionDuration = '', - decisionMade = false, - decisionStatus = '', - decisionComments = '', - decisionDate = '', - decisionApproved = false, - managerUsers = [], - stepName = '', - deadlinePassed = '', - reviewStatus = '', - isReviewer = false, - reviewPanels = [], - amendmentStatus = ''; - - // 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())) - .map(user => { - let isCurrentUser = user._id.toString() === userId.toString(); - return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)` : ``}`; - }); - if ( - applicationStatus === constants.applicationStatuses.SUBMITTED || - (applicationStatus === constants.applicationStatuses.INREVIEW && _.isEmpty(workflow)) - ) { - remainingActioners = managerUsers.join(', '); - } - if (!_.isEmpty(workflow)) { - ({ workflowName } = workflow); - workflowCompleted = workflowController.getWorkflowCompleted(workflow); - let activeStep = workflowController.getActiveWorkflowStep(workflow); - // Calculate active step status - if (!_.isEmpty(activeStep)) { - ({ - stepName = '', - remainingActioners = [], - deadlinePassed = '', - reviewStatus = '', - decisionMade = false, - decisionStatus = '', - decisionComments = '', - decisionApproved, - decisionDate, - isReviewer = false, - reviewPanels = [], - } = workflowController.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 === constants.applicationStatuses.INREVIEW) { - reviewStatus = 'Final decision required'; - remainingActioners = managerUsers.join(', '); - } - // Get decision duration if completed - let { dateFinalStatus, dateSubmitted } = app; - if (dateFinalStatus) { - decisionDuration = parseInt(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 => constants.darPanelMapper[section]), - }; - arr.push(step); - return arr; - }, []); - workflow.steps = [...formattedSteps]; - } - } - - // 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) { - ({ projectName } = aboutApplication); - } - if (_.isEmpty(projectName)) { - projectName = `${publisher} - ${name}`; - } - if (questionAnswers) { - applicants = datarequestUtil.extractApplicantNames(questionAnswers).join(', '); - } - if (_.isEmpty(applicants)) { - let { firstname, lastname } = app.mainApplicant; - applicants = `${firstname} ${lastname}`; - } - amendmentStatus = amendmentController.calculateAmendmentStatus(app, userType); - return { - ...app, - projectName, - applicants, - publisher, - workflowName, - workflowCompleted, - decisionDuration, - decisionMade, - decisionStatus, - decisionComments, - decisionDate, - decisionApproved, - remainingActioners, - stepName, - deadlinePassed, - reviewStatus, - isReviewer, - reviewPanels, - amendmentStatus, - }; - }, - - 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.entity.js b/src/resources/datarequest/datarequest.entity.js new file mode 100644 index 00000000..28c930c9 --- /dev/null +++ b/src/resources/datarequest/datarequest.entity.js @@ -0,0 +1,56 @@ +import Entity from '../base/entity'; +import { isEmpty } from 'lodash'; + +export default class DataRequestClass extends Entity { + constructor(obj) { + super(); + Object.assign(this, obj); + } + + /** + * Increment version + * @description Increments the major version of this access record instance and assigns an updated version tree + */ + incrementVersion = () => { + this.version++; + this.versionTree = buildVersionTree(this); + }; +} + +/** + * Builds and self assigns a version tree for this access record instance + * @description Build a new version tree for an access record using the passed object's version property as the major version. + * Therefore this must be incremented prior to calling this function if creating a new tree for a new major version. + */ +export const buildVersionTree = accessRecord => { + // 1. Guard for invalid accessRecord + if (!accessRecord) return {}; + // 2. Extract values required to build version tree, defaulting version to 1 + let { _id, version, versionTree = {}, amendmentIterations = [] } = accessRecord; + let versionKey = version ? version.toString() : '1'; + // 3. Reverse iterate through amendment iterations and construct minor versions + let minorVersions = {}; + for (let i = amendmentIterations.length; i > 0; i--) { + minorVersions = { + ...minorVersions, + [`${versionKey}.${i}`]: _id, + }; + } + // 4. Create latest major version + let majorVersion = { [`${versionKey}`]: _id }; + // 5. Assemble version tree + if (isEmpty(versionTree)) { + versionTree = { + ...minorVersions, + ...majorVersion, + }; + } else { + versionTree = { + ...minorVersions, + ...majorVersion, + ...versionTree, + }; + } + // 6. Return tree + return versionTree; +}; diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 2abe95b8..11c953e4 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -1,10 +1,11 @@ import { model, Schema } from 'mongoose'; import { WorkflowSchema } from '../workflow/workflow.model'; import constants from '../utilities/constants.util'; +import DataRequestClass from './datarequest.entity'; const DataRequestSchema = new Schema( { - version: Number, + version: { type: Number, default: 1}, userId: Number, // Main applicant authorIds: [Number], dataSetId: String, @@ -86,7 +87,6 @@ const DataRequestSchema = new Schema( }, ], originId: { type: Schema.Types.ObjectId, ref: 'data_request' }, - version: { type: String, default: '1'}, versionTree: { type: Object, default: {} } }, { @@ -130,4 +130,7 @@ DataRequestSchema.virtual('authors', { localField: 'authorIds', }); +// Load entity class +DataRequestSchema.loadClass(DataRequestClass); + export const DataRequestModel = model('data_request', DataRequestSchema); diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js new file mode 100644 index 00000000..b84c9d5d --- /dev/null +++ b/src/resources/datarequest/datarequest.repository.js @@ -0,0 +1,20 @@ +import Repository from '../base/repository'; +import { DataRequestModel } from './datarequest.model'; + +export default class DataRequestRepository extends Repository { + constructor() { + super(DataRequestModel); + this.dataRequestModel = DataRequestModel; + } + + async getAccessRequestsByUser(userId, query) { + if (!userId) return []; + + return DataRequestModel.find({ + $and: [{ ...query }, { $or: [{ userId }, { authorIds: userId }] }], + }) + .select('-jsonSchema -questionAnswers -files') + .populate('datasets mainApplicant') + .lean(); + } +} diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index c4ef7ec5..5edb2a70 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -4,7 +4,10 @@ import _ from 'lodash'; import multer from 'multer'; import { param } from 'express-validator'; import { logger } from '../utilities/logger'; -const amendmentController = require('./amendment/amendment.controller'); +import DataRequestController from './datarequest.controller'; +import AmendmentController from './amendment/amendment.controller'; +import { dataRequestService, workflowService, amendmentService } from './dependency'; + const datarequestController = require('./datarequest.controller'); const fs = require('fs'); const path = './tmp'; @@ -18,7 +21,8 @@ const storage = multer.diskStorage({ }); const multerMid = multer({ storage: storage }); const logCategory = 'Data Access Request'; - +const dataRequestController = new DataRequestController(dataRequestService, workflowService, amendmentService); +const amendmentController = new AmendmentController(amendmentService); const router = express.Router(); // @route GET api/v1/data-access-request @@ -28,7 +32,7 @@ router.get( '/', passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Viewed personal Data Access Request dashboard' }), - datarequestController.getAccessRequestsByUser + (req, res) => dataRequestController.getAccessRequestsByUser(req, res) ); // @route GET api/v1/data-access-request/:requestId @@ -172,7 +176,7 @@ router.post( '/:id/amendments', passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Creating or removing an amendment against a Data Access Request application' }), - amendmentController.setAmendment + (req, res) => amendmentController.setAmendment(req, res) ); // @route POST api/v1/data-access-request/:id/requestAmendments @@ -182,7 +186,7 @@ router.post( '/:id/requestAmendments', passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Requesting a batch of amendments to a Data Access Request application' }), - amendmentController.requestAmendments + (req, res) => amendmentController.requestAmendments(req, res) ); // @route POST api/v1/data-access-request/:id/actions diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js new file mode 100644 index 00000000..6b0b6c1d --- /dev/null +++ b/src/resources/datarequest/datarequest.service.js @@ -0,0 +1,172 @@ +import { isEmpty, has, isUndefined } from 'lodash'; +import moment from 'moment'; + +export default class DataRequestService { + constructor(dataRequestRepository) { + this.dataRequestRepository = dataRequestRepository; + } + + async getAccessRequestsByUser(userId, query = {}) { + return this.dataRequestRepository.getAccessRequestsByUser(userId, query); + } + + calculateAvgDecisionTime(accessRecords = []) { + // Guard for empty array passed + if (isEmpty(accessRecords)) return 0; + // Extract dateSubmitted dateFinalStatus + let decidedApplications = accessRecords.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; + } + + createApplicationDTO(accessRecord, userType, userId = '') { + let projectName = '', + applicants = '', + workflowName = '', + workflowCompleted = false, + remainingActioners = [], + decisionDuration = '', + decisionMade = false, + decisionStatus = '', + decisionComments = '', + decisionDate = '', + decisionApproved = false, + managerUsers = [], + stepName = '', + deadlinePassed = '', + reviewStatus = '', + isReviewer = false, + reviewPanels = [] + + // Check if the application has a workflow assigned + let { workflow = {}, applicationStatus } = accessRecord; + if (has(accessRecord, 'publisherObj.team.members')) { + let { + publisherObj: { + team: { members, users }, + }, + } = accessRecord; + let managers = members.filter(mem => { + return mem.roles.includes('manager'); + }); + managerUsers = users + .filter(user => managers.some(manager => manager.memberid.toString() === user._id.toString())) + .map(user => { + let isCurrentUser = user._id.toString() === userId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)` : ``}`; + }); + if ( + applicationStatus === constants.applicationStatuses.SUBMITTED || + (applicationStatus === constants.applicationStatuses.INREVIEW && isEmpty(workflow)) + ) { + remainingActioners = managerUsers.join(', '); + } + if (!isEmpty(workflow)) { + ({ workflowName } = workflow); + workflowCompleted = this.workflowService.getWorkflowCompleted(workflow); + let activeStep = this.workflowService.getActiveWorkflowStep(workflow); + // Calculate active step status + if (!isEmpty(activeStep)) { + ({ + stepName = '', + remainingActioners = [], + deadlinePassed = '', + reviewStatus = '', + decisionMade = false, + decisionStatus = '', + decisionComments = '', + decisionApproved, + decisionDate, + isReviewer = false, + reviewPanels = [], + } = this.workflowService.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 === constants.applicationStatuses.INREVIEW) { + reviewStatus = 'Final decision required'; + remainingActioners = managerUsers.join(', '); + } + // Get decision duration if completed + let { dateFinalStatus, dateSubmitted } = accessRecord; + if (dateFinalStatus) { + decisionDuration = parseInt(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 => constants.darPanelMapper[section]), + }; + arr.push(step); + return arr; + }, []); + workflow.steps = [...formattedSteps]; + } + } + + // Ensure backward compatibility with old single dataset DARs + if (isEmpty(accessRecord.datasets) || isUndefined(accessRecord.datasets)) { + accessRecord.datasets = [accessRecord.dataset]; + accessRecord.datasetIds = [accessRecord.datasetid]; + } + let { + datasetfields: { publisher }, + name, + } = accessRecord.datasets[0]; + let { aboutApplication, questionAnswers } = accessRecord; + + if (aboutApplication) { + ({ projectName } = aboutApplication); + } + if (isEmpty(projectName)) { + projectName = `${publisher} - ${name}`; + } + if (questionAnswers) { + applicants = datarequestUtil.extractApplicantNames(questionAnswers).join(', '); + } + if (isEmpty(applicants)) { + let { firstname, lastname } = accessRecord.mainApplicant; + applicants = `${firstname} ${lastname}`; + } + return { + ...accessRecord, + projectName, + applicants, + publisher, + workflowName, + workflowCompleted, + decisionDuration, + decisionMade, + decisionStatus, + decisionComments, + decisionDate, + decisionApproved, + remainingActioners, + stepName, + deadlinePassed, + reviewStatus, + isReviewer, + reviewPanels + }; + } +} diff --git a/src/resources/datarequest/dependency.js b/src/resources/datarequest/dependency.js new file mode 100644 index 00000000..2d087006 --- /dev/null +++ b/src/resources/datarequest/dependency.js @@ -0,0 +1,15 @@ +import DataRequestRepository from './datarequest.repository'; +import DataRequestService from './datarequest.service'; +import WorkflowRepository from '../workflow/workflow.repository'; +import WorkflowService from '../workflow/workflow.service'; +import AmendmentRepository from './amendment/amendment.repository'; +import AmendmentService from './amendment/amendment.service'; + +export const dataRequestRepository = new DataRequestRepository(); +export const dataRequestService = new DataRequestService(dataRequestRepository); + +export const workflowRepository = new WorkflowRepository(); +export const workflowService = new WorkflowService(workflowRepository); + +export const amendmentRepository = new AmendmentRepository(); +export const amendmentService = new AmendmentService(amendmentRepository); \ No newline at end of file diff --git a/src/resources/dataset/datasetonboarding.controller.js b/src/resources/dataset/datasetonboarding.controller.js index 3ecf4277..107d0733 100644 --- a/src/resources/dataset/datasetonboarding.controller.js +++ b/src/resources/dataset/datasetonboarding.controller.js @@ -17,6 +17,7 @@ import moment from 'moment'; var fs = require('fs'); import constants from '../utilities/constants.util'; +import { amendmentService } from '../datarequest/amendment/dependency'; module.exports = { //GET api/v1/dataset-onboarding @@ -529,7 +530,7 @@ module.exports = { const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = dataset; if (dirtySchema) { accessRequestRecord.jsonSchema = JSON.parse(accessRequestRecord.jsonSchema); - accessRequestRecord = amendmentController.injectAmendments(accessRequestRecord, constants.userTypes.APPLICANT, req.user); + accessRequestRecord = this.amendmentService.injectAmendments(accessRequestRecord, constants.userTypes.APPLICANT, req.user); } let data = { status: 'success', @@ -632,7 +633,7 @@ module.exports = { return accessRecord; } let updatedAnswer = JSON.parse(updateObj.questionAnswers)[updatedQuestionId]; - accessRecord = amendmentController.handleApplicantAmendment(accessRecord.toObject(), updatedQuestionId, '', updatedAnswer, user); + accessRecord = amendmentService.handleApplicantAmendment(accessRecord.toObject(), updatedQuestionId, '', updatedAnswer, user); await DataRequestModel.replaceOne({ _id }, accessRecord, err => { if (err) { console.error(err); diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 99c5f3c6..a3845ffa 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -7,8 +7,6 @@ import { WorkflowModel } from '../workflow/workflow.model'; import constants from '../utilities/constants.util'; import teamController from '../team/team.controller'; -const datarequestController = require('../datarequest/datarequest.controller'); - module.exports = { // GET api/v1/publishers/:id getPublisherById: async (req, res) => { @@ -172,12 +170,12 @@ module.exports = { // 6. Append projectName and applicants let modifiedApplications = [...applications] - .map(app => { - return datarequestController.createApplicationDTO(app, constants.userTypes.CUSTODIAN, _id.toString()); + .map(accessRecord => { + return this.dataRequestService.createApplicationDTO(accessRecord, constants.userTypes.CUSTODIAN, _id.toString()); }) .sort((a, b) => b.updatedAt - a.updatedAt); - let avgDecisionTime = datarequestController.calculateAvgDecisionTime(applications); + let avgDecisionTime = this.dataRequestService.calculateAvgDecisionTime(applications); // 7. Return all applications return res.status(200).json({ success: true, data: modifiedApplications, avgDecisionTime, canViewSubmitted: isManager }); } catch (err) { diff --git a/src/resources/workflow/dependency.js b/src/resources/workflow/dependency.js new file mode 100644 index 00000000..263ece27 --- /dev/null +++ b/src/resources/workflow/dependency.js @@ -0,0 +1,5 @@ +import WorkflowRepository from './workflow.repository'; +import WorkflowService from './workflow.service'; + +export const workflowRepository = new WorkflowRepository(); +export const workflowService = new WorkflowService(workflowRepository); diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index e3513c6a..ef40c8fe 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -4,711 +4,312 @@ import { WorkflowModel } from './workflow.model'; import teamController from '../team/team.controller'; import helper from '../utilities/helper.util'; import constants from '../utilities/constants.util'; -import emailGenerator from '../utilities/emailGenerator.util'; -import notificationBuilder from '../utilities/notificationBuilder'; +import Controller from '../base/controller'; -import moment from 'moment'; import _ from 'lodash'; -import mongoose from 'mongoose'; +import Mongoose from 'mongoose'; -// GET api/v1/workflows/:id -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({ - _id: 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' }, - }, - ]); - if (!workflow) { - return res.status(404).json({ success: false }); - } - // 2. Check the requesting user is a manager of the custodian team - let { _id: userId } = req.user; - let authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, workflow.publisher.team.toObject(), userId); - // 3. If not return unauthorised - if (!authorised) { - return res.status(401).json({ success: false }); - } - // 4. Build workflow response - let { active, _id, id, workflowName, version, steps, applications = [] } = workflow.toObject(); - applications = applications.map(app => { - let { aboutApplication = {}, _id } = app; - let { projectName = 'No project name' } = aboutApplication; - 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, - applications, - appCount: applications.length, - canDelete, - canEdit, - }, - }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred searching for the specified workflow', - }); +export default class WorkflowController extends Controller { + constructor(workflowService) { + super(workflowService); + this.workflowService = workflowService; } -}; -// POST api/v1/workflows -const createWorkflow = async (req, res) => { - try { - const { _id: userId, firstname, lastname } = 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', + async getWorkflowById(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({ + _id: 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' }, + }, + ]); + if (!workflow) { + return res.status(404).json({ success: false }); + } + // 2. Check the requesting user is a manager of the custodian team + let { _id: userId } = req.user; + let authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, workflow.publisher.team.toObject(), userId); + // 3. If not return unauthorised + if (!authorised) { + return res.status(401).json({ success: false }); + } + // 4. Build workflow response + let { active, _id, id, workflowName, version, steps, applications = [] } = workflow.toObject(); + applications = applications.map(app => { + let { aboutApplication = {}, _id } = app; + let { projectName = 'No project name' } = aboutApplication; + return { projectName, _id }; }); - } - // 2. Look up publisher and team - const publisherObj = await PublisherModel.findOne({ - _id: publisher, - }).populate({ - path: 'team members', - populate: { - path: 'users', - select: '_id id email firstname lastname', - }, - }); - - if (!publisherObj) { - return res.status(400).json({ + // 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, + applications, + appCount: applications.length, + canDelete, + canEdit, + }, + }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ success: false, - message: 'You must supply a valid publisher to create the workflow against', + message: 'An error occurred searching for the specified workflow', }); } - // 3. Check the requesting user is a manager of the custodian team - let authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, publisherObj.team.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(); - // 6. set workflow obj for saving - let workflow = new WorkflowModel({ - id, - workflowName, - publisher, - steps, - createdBy: new mongoose.Types.ObjectId(userId), - }); - // 7. save new workflow to db - workflow.save(function (err) { - if (err) { + async createWorkflow(req, res) { + try { + const { _id: userId, firstname, lastname } = 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: err.message, + message: 'You must supply a workflow name, publisher, and at least one step definition to create a workflow', }); } - // 8. populate the workflow with the needed fiedls for our new notification and email - workflow.populate( - { - path: 'steps.reviewers', - select: 'firstname lastname email -_id', + // 2. Look up publisher and team + const publisherObj = await PublisherModel.findOne({ + _id: publisher, + }).populate({ + path: 'team members', + populate: { + path: 'users', + select: '_id id email firstname lastname', }, - (err, doc) => { - if (err) { - // 9. if issue - return res.status(400).json({ - success: false, - message: err.message, - }); - } - // 10. set context - let context = { - publisherObj: publisherObj.team.toObject(), - actioner: `${firstname} ${lastname}`, - workflow: doc.toObject(), - }; - // 11. Generate new notifications / emails for managers of the team only on creation of a workflow - createNotifications(context, constants.notificationTypes.WORKFLOWCREATED); - // 12. full complete return - 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 -const updateWorkflow = async (req, res) => { - try { - 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 = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, workflow.publisher.team.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', + 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 = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, publisherObj.team.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(); + // 6. set workflow obj for saving + let workflow = new WorkflowModel({ + id, + workflowName, + publisher, + steps, + createdBy: new Mongoose.Types.ObjectId(userId), }); - } - // 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 changes have been made - if (isDirty) { - workflow.save(async err => { + // 7. save new workflow to db + workflow.save(function (err) { if (err) { - console.error(err.message); return res.status(400).json({ success: false, message: err.message, }); - } else { - // 7. Return workflow payload - return res.status(204).json({ - success: true, - workflow, - }); } + // 8. populate the workflow with the needed fiedls for our new notification and email + workflow.populate( + { + path: 'steps.reviewers', + select: 'firstname lastname email -_id', + }, + (err, doc) => { + if (err) { + // 9. if issue + return res.status(400).json({ + success: false, + message: err.message, + }); + } + // 10. set context + let context = { + publisherObj: publisherObj.team.toObject(), + actioner: `${firstname} ${lastname}`, + workflow: doc.toObject(), + }; + // 11. Generate new notifications / emails for managers of the team only on creation of a workflow + this.workflowService.createNotifications(context, constants.notificationTypes.WORKFLOWCREATED); + // 12. full complete return + return res.status(201).json({ + success: true, + workflow, + }); + } + ); }); - } else { - return res.status(200).json({ - success: true, + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred creating the workflow', }); } - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred editing the workflow', - }); } -}; -// DELETE api/v1/workflows/:id -const 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 = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, workflow.publisher.team.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', + async updateWorkflow(req, res) { + try { + 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', + }, }); - } - // 5. Delete workflow - WorkflowModel.deleteOne({ _id: workflowId }, function (err) { - if (err) { - console.error(err.message); + if (!workflow) { + return res.status(404).json({ success: false }); + } + // 2. Check the requesting user is a manager of the custodian team + let authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, workflow.publisher.team.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: 'An error occurred deleting the workflow', + 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 changes have been made + if (isDirty) { + workflow.save(async err => { + if (err) { + console.error(err.message); + return res.status(400).json({ + success: false, + message: err.message, + }); + } else { + // 7. Return workflow payload + return res.status(204).json({ + success: true, + workflow, + }); + } }); } else { - // 7. Return workflow payload - return res.status(204).json({ + return res.status(200).json({ success: true, }); } - }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred deleting the workflow', - }); - } -}; - -const createNotifications = async (context, type = '') => { - if (!_.isEmpty(type)) { - // local variables set here - let custodianManagers = [], - managerUserIds = [], - options = {}, - html = ''; - - // deconstruct context - let { publisherObj, workflow = {}, actioner = '' } = context; - - // switch over types - switch (type) { - case constants.notificationTypes.WORKFLOWCREATED: - // 1. Get managers for publisher - custodianManagers = teamController.getTeamMembersByRole(publisherObj, constants.roleTypes.MANAGER); - // 2. Get managerIds for notifications - managerUserIds = custodianManagers.map(user => user.id); - // 3. deconstruct workflow - let { workflowName = 'Workflow Title', _id, steps, createdAt } = workflow; - // 4. setup options - options = { - actioner, - workflowName, - _id, - steps, - createdAt, - }; - // 4. Create notifications for the managers only - await notificationBuilder.triggerNotificationMessage( - managerUserIds, - `A new workflow of ${workflowName} has been created`, - 'workflow', - _id - ); - // 5. Generate the email - html = await emailGenerator.generateWorkflowCreated(options); - // 6. Send email to custodian managers only within the team - await emailGenerator.sendEmail(custodianManagers, constants.hdrukEmail, `A Workflow has been created`, html, false); - break; + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred editing the workflow', + }); } } -}; - -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`; -}; -const 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()); - // 4. If the user was found check if they are a manager - if (userMember) { - let { roles } = userMember; - if (roles.includes(constants.roleTypes.MANAGER)) { - managerExists = true; + async deleteWorkflow(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 }); } - } - }); - return managerExists; -}; - -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; - 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 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 - // 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: calculateStepDeadlineReminderDate(nextStep), - reviewerList, - }; - } - return bpmContext; -}; - -const getWorkflowCompleted = (workflow = {}) => { - let workflowCompleted = false; - if (!_.isEmpty(workflow)) { - let { steps } = workflow; - workflowCompleted = steps.every(step => step.completed); - } - return workflowCompleted; -}; - -const getActiveWorkflowStep = (workflow = {}) => { - let activeStep = {}; - if (!_.isEmpty(workflow)) { - let { steps } = workflow; - activeStep = steps.find(step => { - return step.active; - }); - } - return activeStep; -}; - -const getStepReviewers = (step = {}) => { - let stepReviewers = []; - // Attempt to get step reviewers if workflow passed - if (!_.isEmpty(step)) { - // Get active reviewers - if (step) { - ({ reviewers: stepReviewers } = step); - } - } - return stepReviewers; -}; - -const getRemainingReviewers = (Step = {}, users) => { - let { reviewers = [], recommendations = [] } = Step; - let remainingActioners = reviewers.filter(reviewer => !recommendations.some(rec => rec.reviewer.toString() === reviewer._id.toString())); - remainingActioners = [...users].filter(user => remainingActioners.some(actioner => actioner._id.toString() === user._id.toString())); - - return remainingActioners; -}; - -const 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._id.toString())); - remainingActioners = users - .filter(user => remainingActioners.some(actioner => actioner._id.toString() === user._id.toString())) - .map(user => { - let isCurrentUser = user._id.toString() === userId.toString(); - return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)` : ``}`; - }); - - let isReviewer = reviewers.some(reviewer => reviewer._id.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 => constants.darPanelMapper[section]).join(', '); - - return { - stepName, - remainingActioners: remainingActioners.join(', '), - deadlinePassed, - isReviewer, - reviewStatus, - decisionMade, - decisionApproved, - decisionDate, - decisionStatus, - decisionComments, - reviewPanels, - }; -}; - -const getWorkflowStatus = application => { - let workflowStatus = {}; - let { workflow = {} } = application; - if (!_.isEmpty(workflow)) { - let { workflowName, steps } = workflow; - // Find the active step in steps - let activeStep = getActiveWorkflowStep(workflow); - let activeStepIndex = steps.findIndex(step => { - return step.active === true; - }); - if (activeStep) { - let { reviewStatus, deadlinePassed } = 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 => constants.darPanelMapper[section]), - }; - arr.push(step); - return arr; - }, []); - - workflowStatus = { - workflowName, - steps: formattedSteps, - isCompleted: getWorkflowCompleted(workflow), - }; - } - return workflowStatus; -}; - -const 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()); + // 2. Check the requesting user is a manager of the custodian team + let authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, workflow.publisher.team.toObject(), userId); + // 3. Refuse access if not authorised + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - } - } - // Return active review mode if conditions apply - if (applicationStatus === 'inReview' && isActiveStepReviewer) { - inReviewMode = true; - } - - return { inReviewMode, reviewSections, hasRecommended }; -}; - -const getWorkflowEmailContext = (accessRecord, workflow, relatedStepIndex) => { - // Extract workflow email variables - const { dateReviewStart = '' } = accessRecord; - const { workflowName, steps } = workflow; - 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 => constants.darPanelMapper[section]).join(', '); - const stepReviewerUserIds = [...stepReviewers].map(user => user.id); - const currentDeadline = stepDeadline === 0 ? 'No deadline specified' : moment().add(stepDeadline, 'days'); - let nextStepName = '', - nextReviewerNames = '', - nextReviewSections = '', - duration = '', - totalDuration = '', - nextDeadline = '', - dateDeadline = '', - deadlineElapsed = false, - deadlineApproaching = false, - remainingReviewers = [], - remainingReviewerUserIds = []; - - // 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) { - dateDeadline = moment(startDateTime).add(stepDeadline, 'days'); - deadlineElapsed = moment().isAfter(dateDeadline, 'second'); - - // If deadline is not elapsed, check if it is within SLA period - if (!deadlineElapsed && reminderOffset !== 0) { - let deadlineReminderDate = moment(dateDeadline).subtract(reminderOffset, 'days'); - deadlineApproaching = moment().isAfter(deadlineReminderDate, 'second'); + // 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.message); + 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', + }); } - // Find reviewers of the current incomplete phase - let accessRecordObj = accessRecord.toObject(); - if (_.has(accessRecordObj, 'publisherObj.team.users')) { - let { - publisherObj: { - team: { users = [] }, - }, - } = accessRecordObj; - remainingReviewers = getRemainingReviewers(steps[relatedStepIndex], users); - remainingReviewerUserIds = [...remainingReviewers].map(user => user.id); - } - } - - // Check if there is another step after the current related step - 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 => constants.darPanelMapper[section]).join(', '); - let { deadline = 0 } = steps[relatedStepIndex + 1]; - nextDeadline = deadline === 0 ? 'No deadline specified' : moment().add(deadline, 'days'); } - return { - workflowName, - steps, - stepName, - startDateTime, - endDateTime, - stepReviewers, - duration, - totalDuration, - reviewerNames, - stepReviewerUserIds, - reviewSections, - currentDeadline, - nextStepName, - nextReviewerNames, - nextReviewSections, - nextDeadline, - dateDeadline, - deadlineElapsed, - deadlineApproaching, - remainingReviewers, - remainingReviewerUserIds, - }; -}; - -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, - createNotifications: createNotifications, -}; +} diff --git a/src/resources/workflow/workflow.repository.js b/src/resources/workflow/workflow.repository.js new file mode 100644 index 00000000..79364b1b --- /dev/null +++ b/src/resources/workflow/workflow.repository.js @@ -0,0 +1,20 @@ +import Repository from '../base/repository'; +import { WorkflowModel } from './workflow.model'; + +export default class WorkflowRepository extends Repository { + constructor() { + super(WorkflowModel); + this.workflowModel = WorkflowModel; + } + + // async getAccessRequestsByUser(userId, query) { + // if (!userId) return []; + + // return DataRequestModel.find({ + // $and: [{ ...query }, { $or: [{ userId }, { authorIds: userId }] }], + // }) + // .select('-jsonSchema -questionAnswers -files') + // .populate('datasets mainApplicant') + // .lean(); + // } +} diff --git a/src/resources/workflow/workflow.route.js b/src/resources/workflow/workflow.route.js index 75910fb1..ca67746b 100644 --- a/src/resources/workflow/workflow.route.js +++ b/src/resources/workflow/workflow.route.js @@ -1,27 +1,52 @@ import express from 'express'; import passport from 'passport'; -import workflowController from './workflow.controller'; +import { logger } from '../utilities/logger'; +import WorkflowController from './workflow.controller'; +import { workflowService } from './dependency'; + +const logCategory = 'Workflow'; +const workflowController = new WorkflowController(workflowService); const router = express.Router(); // @route GET api/v1/workflows/:id // @desc Fetch a workflow by id // @access Private -router.get('/:id', passport.authenticate('jwt'), workflowController.getWorkflowById); +router.get( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Viewed a workflow instance details' }), + (req, res) => workflowController.getWorkflowById(req, res) +); // @route POST api/v1/workflows/ // @desc Create a new workflow // @access Private -router.post('/', passport.authenticate('jwt'), workflowController.createWorkflow); +router.post( + '/', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Creating a new workflow definition' }), + (req, res) => workflowController.createWorkflow(req, res) +); // @route PUT api/v1/workflows/:id // @desc Edit a workflow by id // @access Private -router.put('/:id', passport.authenticate('jwt'), workflowController.updateWorkflow); +router.put( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Updating an existing workflow definition' }), + (req, res) => workflowController.updateWorkflow(req, res) +); // @route DELETE api/v1/workflows/ // @desc Delete a workflow by id // @access Private -router.delete('/:id', passport.authenticate('jwt'), workflowController.deleteWorkflow); +router.delete( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Deleting a workflow definition' }), + (req, res) => workflowController.deleteWorkflow(req, res) +); module.exports = router; diff --git a/src/resources/workflow/workflow.service.js b/src/resources/workflow/workflow.service.js new file mode 100644 index 00000000..7a3363b1 --- /dev/null +++ b/src/resources/workflow/workflow.service.js @@ -0,0 +1,378 @@ +import teamController from '../team/team.controller'; +import constants from '../utilities/constants.util'; +import emailGenerator from '../utilities/emailGenerator.util'; +import notificationBuilder from '../utilities/notificationBuilder'; + +import moment from 'moment'; +import _ from 'lodash'; + +export default class WorkflowService { + constructor(workflowRepository) { + this.workflowRepository = workflowRepository; + } + + async createNotifications(context, type = '') { + if (!_.isEmpty(type)) { + // local variables set here + let custodianManagers = [], + managerUserIds = [], + options = {}, + html = ''; + + // deconstruct context + let { publisherObj, workflow = {}, actioner = '' } = context; + + // switch over types + switch (type) { + case constants.notificationTypes.WORKFLOWCREATED: + // 1. Get managers for publisher + custodianManagers = teamController.getTeamMembersByRole(publisherObj, constants.roleTypes.MANAGER); + // 2. Get managerIds for notifications + managerUserIds = custodianManagers.map(user => user.id); + // 3. deconstruct workflow + let { workflowName = 'Workflow Title', _id, steps, createdAt } = workflow; + // 4. setup options + options = { + actioner, + workflowName, + _id, + steps, + createdAt, + }; + // 4. Create notifications for the managers only + await notificationBuilder.triggerNotificationMessage( + managerUserIds, + `A new workflow of ${workflowName} has been created`, + 'workflow', + _id + ); + // 5. Generate the email + html = await emailGenerator.generateWorkflowCreated(options); + // 6. Send email to custodian managers only within the team + await emailGenerator.sendEmail(custodianManagers, constants.hdrukEmail, `A Workflow has been created`, html, false); + break; + } + } + } + + 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`; + } + + 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 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 + // 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: this.calculateStepDeadlineReminderDate(nextStep), + reviewerList, + }; + } + 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; + } + + getStepReviewers(step = {}) { + let stepReviewers = []; + // Attempt to get step reviewers if workflow passed + if (!_.isEmpty(step)) { + // Get active reviewers + if (step) { + ({ reviewers: stepReviewers } = step); + } + } + return stepReviewers; + } + + getRemainingReviewers(Step = {}, users) { + let { reviewers = [], recommendations = [] } = Step; + let remainingActioners = reviewers.filter( + reviewer => !recommendations.some(rec => rec.reviewer.toString() === reviewer._id.toString()) + ); + remainingActioners = [...users].filter(user => remainingActioners.some(actioner => actioner._id.toString() === user._id.toString())); + + return remainingActioners; + } + + 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._id.toString())); + remainingActioners = users + .filter(user => remainingActioners.some(actioner => actioner._id.toString() === user._id.toString())) + .map(user => { + let isCurrentUser = user._id.toString() === userId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)` : ``}`; + }); + + let isReviewer = reviewers.some(reviewer => reviewer._id.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 => constants.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 = this.getActiveWorkflowStep(workflow); + let activeStepIndex = steps.findIndex(step => { + return step.active === true; + }); + if (activeStep) { + let { reviewStatus, deadlinePassed } = this.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 => constants.darPanelMapper[section]), + }; + arr.push(step); + return arr; + }, []); + + workflowStatus = { + workflowName, + steps: formattedSteps, + isCompleted: this.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 }; + } + + getWorkflowEmailContext(accessRecord, workflow, relatedStepIndex) { + // Extract workflow email variables + const { dateReviewStart = '' } = accessRecord; + const { workflowName, steps } = workflow; + const { stepName, startDateTime = '', endDateTime = '', completed = false, deadline: stepDeadline = 0, reminderOffset = 0 } = steps[ + relatedStepIndex + ]; + const stepReviewers = this.getStepReviewers(steps[relatedStepIndex]); + const reviewerNames = [...stepReviewers].map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + const reviewSections = [...steps[relatedStepIndex].sections].map(section => constants.darPanelMapper[section]).join(', '); + const stepReviewerUserIds = [...stepReviewers].map(user => user.id); + const currentDeadline = stepDeadline === 0 ? 'No deadline specified' : moment().add(stepDeadline, 'days'); + let nextStepName = '', + nextReviewerNames = '', + nextReviewSections = '', + duration = '', + totalDuration = '', + nextDeadline = '', + dateDeadline = '', + deadlineElapsed = false, + deadlineApproaching = false, + remainingReviewers = [], + remainingReviewerUserIds = []; + + // 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) { + dateDeadline = moment(startDateTime).add(stepDeadline, 'days'); + deadlineElapsed = moment().isAfter(dateDeadline, 'second'); + + // If deadline is not elapsed, check if it is within SLA period + if (!deadlineElapsed && reminderOffset !== 0) { + let deadlineReminderDate = moment(dateDeadline).subtract(reminderOffset, 'days'); + deadlineApproaching = moment().isAfter(deadlineReminderDate, 'second'); + } + } + // Find reviewers of the current incomplete phase + let accessRecordObj = accessRecord.toObject(); + if (_.has(accessRecordObj, 'publisherObj.team.users')) { + let { + publisherObj: { + team: { users = [] }, + }, + } = accessRecordObj; + remainingReviewers = getRemainingReviewers(steps[relatedStepIndex], users); + remainingReviewerUserIds = [...remainingReviewers].map(user => user.id); + } + } + + // Check if there is another step after the current related step + 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 = this.getStepReviewers(steps[relatedStepIndex + 1]); + nextReviewerNames = [...nextStepReviewers].map(reviewer => `${reviewer.firstname} ${reviewer.lastname}`).join(', '); + nextReviewSections = [...steps[relatedStepIndex + 1].sections].map(section => constants.darPanelMapper[section]).join(', '); + let { deadline = 0 } = steps[relatedStepIndex + 1]; + nextDeadline = deadline === 0 ? 'No deadline specified' : moment().add(deadline, 'days'); + } + return { + workflowName, + steps, + stepName, + startDateTime, + endDateTime, + stepReviewers, + duration, + totalDuration, + reviewerNames, + stepReviewerUserIds, + reviewSections, + currentDeadline, + nextStepName, + nextReviewerNames, + nextReviewSections, + nextDeadline, + dateDeadline, + deadlineElapsed, + deadlineApproaching, + remainingReviewers, + remainingReviewerUserIds, + }; + } +} From e462f89fb128d9fd47ea638f6875146e3b49bd58 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 12 May 2021 13:17:21 +0100 Subject: [PATCH 06/81] Continued refactoring --- .../bpmnworkflow/bpmnworkflow.controller.js | 5 + .../amendment/amendment.service.js | 2 + .../datarequest/datarequest.controller.js | 136 ++++----- .../datarequest/datarequest.repository.js | 18 +- .../datarequest/datarequest.route.js | 6 +- .../datarequest/datarequest.service.js | 202 ++++--------- src/resources/publisher/dependency.js | 17 ++ .../publisher/publisher.controller.js | 285 ++++++------------ .../publisher/publisher.repository.js | 61 ++++ src/resources/publisher/publisher.route.js | 32 +- src/resources/publisher/publisher.service.js | 79 +++++ src/resources/stats/stats.controller.js | 4 +- src/resources/team/team.controller.js | 4 +- src/resources/team/team.model.js | 1 + src/resources/workflow/workflow.controller.js | 49 ++- src/resources/workflow/workflow.repository.js | 34 ++- src/resources/workflow/workflow.route.js | 1 + src/resources/workflow/workflow.service.js | 169 +++++++++-- 18 files changed, 613 insertions(+), 492 deletions(-) create mode 100644 src/resources/publisher/dependency.js create mode 100644 src/resources/publisher/publisher.repository.js create mode 100644 src/resources/publisher/publisher.service.js diff --git a/src/resources/bpmnworkflow/bpmnworkflow.controller.js b/src/resources/bpmnworkflow/bpmnworkflow.controller.js index d568b4d8..d02fda88 100644 --- a/src/resources/bpmnworkflow/bpmnworkflow.controller.js +++ b/src/resources/bpmnworkflow/bpmnworkflow.controller.js @@ -51,6 +51,7 @@ module.exports = { console.error(err.message); }); }, + postUpdateProcess: async bpmContext => { // Create Axios requet to start Camunda process let { taskId, applicationStatus, dateSubmitted, publisher, actioner, archived } = bpmContext; @@ -108,6 +109,7 @@ module.exports = { console.error(err.message); }); }, + postStartManagerReview: async bpmContext => { // Start manager-review process let { applicationStatus, managerId, publisher, notifyManager, taskId } = bpmContext; @@ -135,6 +137,7 @@ module.exports = { console.error(err.message); }); }, + postManagerApproval: async bpmContext => { // Manager has approved sectoin let { businessKey } = bpmContext; @@ -142,6 +145,7 @@ module.exports = { console.error(err.message); }); }, + postStartStepReview: async bpmContext => { //Start Step-Review process let { businessKey } = bpmContext; @@ -149,6 +153,7 @@ module.exports = { console.error(err.message); }); }, + postCompleteReview: async bpmContext => { //Start Next-Step process let { businessKey } = bpmContext; diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index cd5e578f..6c8fe42b 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -5,6 +5,8 @@ import datarequestUtil from '../utils/datarequest.util'; import notificationBuilder from '../../utilities/notificationBuilder'; import emailGenerator from '../../utilities/emailGenerator.util'; +import _ from 'lodash'; + export default class AmendmentService { constructor(amendmentRepository) { this.amendmentRepository = amendmentRepository; diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 2f409cd8..aaa08b76 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -5,7 +5,6 @@ import { DataRequestSchemaModel } from './datarequest.schemas.model'; import { UserModel } from '../user/user.model'; import teamController from '../team/team.controller'; -import workflowController from '../workflow/workflow.controller'; import datarequestUtil from './utils/datarequest.util'; import notificationBuilder from '../utilities/notificationBuilder'; @@ -24,7 +23,6 @@ import mongoose from 'mongoose'; import { logger } from '../utilities/logger'; const logCategory = 'Data Access Request'; -const amendmentController = require('./amendment/amendment.controller'); const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); export default class DataRequestController extends Controller { @@ -39,18 +37,20 @@ export default class DataRequestController extends Controller { try { // Deconstruct the parameters passed let { query = {} } = req; - const userId = parseInt(req.user.id); + const requestingUserId = parseInt(req.user.id); // Find all data access request applications for requesting user - let applications = await this.dataRequestService.getAccessRequestsByUser(userId, query); + let applications = await this.dataRequestService.getAccessRequestsByUser(requestingUserId, query); // Create detailed application object including workflow, review meta details let modifiedApplications = [...applications] .map(accessRecord => { - let accessRecordDTO = this.dataRequestService.createApplicationDTO(accessRecord, constants.userTypes.APPLICANT); - // Append amendment status - accessRecordDTO.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, userType); - return accessRecordDTO; + accessRecord = this.workflowService.getWorkflowDetails(accessRecord, requestingUserId); + accessRecord.projectName = this.dataRequestService.getProjectName(accessRecord); + accessRecord.applicants = this.dataRequestService.getApplicantNames(accessRecord); + accessRecord.decisionDuration = this.dataRequestService.getDecisionDuration(accessRecord); + accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.APPLICANT); + return accessRecord; }) .sort((a, b) => b.updatedAt - a.updatedAt); @@ -73,68 +73,54 @@ export default class DataRequestController extends Controller { }); } } -} -module.exports = { - //GET api/v1/data-access-request/:requestId - getAccessRequestById: async (req, res) => { + async getAccessRequestById(req, res) { try { // 1. Get dataSetId from params - let { - params: { requestId }, + const { + params: { id }, } = req; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; // 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' }, - { - path: 'datasets dataset authors', - populate: { path: 'publisher', populate: { path: 'team' } }, - }, - { path: 'workflow.steps.reviewers', select: 'firstname lastname' }, - { path: 'files.owner', select: 'firstname lastname' }, - ]); + let accessRecord = await this.dataRequestService.getApplicationById(id); // 3. If no matching application found, return 404 if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } else { - accessRecord = accessRecord.toObject(); - } - // 4. Ensure single datasets are mapped correctly into array - if (_.isEmpty(accessRecord.datasets)) { - accessRecord.datasets = [accessRecord.dataset]; } - // 5. Check if requesting user is custodian member or applicant/contributor - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord, req.user.id, req.user._id); - let readOnly = true; + // 4. Check if requesting user is custodian member or applicant/contributor + const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - // 6. Set edit mode for applicants who have not yet submitted - if (userType === constants.userTypes.APPLICANT && accessRecord.applicationStatus === constants.applicationStatuses.INPROGRESS) { - readOnly = false; - } - // 7. Count unsubmitted amendments - let countUnsubmittedAmendments = this.amendmentService.countUnsubmittedAmendments(accessRecord, userType); - // 8. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections, hasRecommended } = this.workflowService.getReviewStatus(accessRecord, req.user._id); - // 9. Get the workflow/voting status + // 5. Set edit mode for applicants who have not yet submitted + const { applicationStatus } = accessRecord; + accessRecord.readOnly = this.dataRequestService.getApplicationIsReadOnly(userType, applicationStatus); + // 6. Count unsubmitted amendments + const countUnsubmittedAmendments = this.amendmentService.countUnsubmittedAmendments(accessRecord, userType); + // 7. Set the review mode if user is a custodian reviewing the current step + let { inReviewMode, reviewSections, hasRecommended } = this.workflowService.getReviewStatus(accessRecord, requestingUserObjectId); + // 8. Get the workflow/voting status let workflow = this.workflowService.getWorkflowStatus(accessRecord); let isManager = false; - // 10. Check if the current user can override the current step + // 9. Check if the current user can override the current step if (_.has(accessRecord.datasets[0], 'publisher.team')) { - isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, accessRecord.datasets[0].publisher.team, req.user._id); + const { team } = accessRecord.datasets[0].publisher; + isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserObjectId); // Set the workflow override capability if there is an active step and user is a manager if (!_.isEmpty(workflow)) { workflow.canOverrideStep = !workflow.isCompleted && isManager; } } - // 11. Update json schema and question answers with modifications since original submission + // 10. Update json schema and question answers with modifications since original submission accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); - // 12. Determine the current active party handling the form + // 11. Determine the current active party handling the form let activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); - // 13. Append question actions depending on user type and application status + // 12. Append question actions depending on user type and application status let userRole = userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( @@ -144,13 +130,12 @@ module.exports = { userRole, activeParty ); - // 14. Return application form + // 13. Return application form return res.status(200).json({ status: 'success', data: { ...accessRecord, datasets: accessRecord.datasets, - readOnly, ...countUnsubmittedAmendments, userType, activeParty, @@ -163,11 +148,17 @@ module.exports = { }, }); } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred opening this data access request application', + }); } - }, + } +} +module.exports = { //GET api/v1/data-access-request/dataset/:datasetId getAccessRequestByUserAndDataset: async (req, res) => { let accessRecord, dataset; @@ -400,32 +391,26 @@ module.exports = { user: req.user, }); // 3. Find data request by _id to determine current status - let accessRequestRecord = await DataRequestModel.findOne({ + let accessRecord = await DataRequestModel.findOne({ _id: id, }); // 4. Check access record - if (!accessRequestRecord) { + if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Data Access Request not found.' }); } // 5. Update record object - module.exports.updateApplication(accessRequestRecord, updateObj).then(accessRequestRecord => { - const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = accessRequestRecord; - if (dirtySchema) { - accessRequestRecord = this.amendmentService.injectAmendments(accessRequestRecord, constants.userTypes.APPLICANT, req.user); - } - let data = { - status: 'success', - unansweredAmendments, - answeredAmendments, - }; - if (dirtySchema) { - data = { - ...data, - jsonSchema: accessRequestRecord.jsonSchema, - }; - } - // 6. Return new data object - return res.status(200).json(data); + accessRecord = await module.exports.updateApplication(accessRecord, updateObj); + const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = accessRecord; + + if (dirtySchema) { + accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, req.user); + } + // 6. Return new data object + return res.status(200).json({ + status: 'success', + unansweredAmendments, + answeredAmendments, + jsonSchema: dirtySchema ? accessRecord.jsonSchema : undefined, }); } catch (err) { console.error(err.message); @@ -471,7 +456,6 @@ module.exports = { throw err; } }); - return accessRecord; // 3. Else if application has already been submitted make amendment } else if ( applicationStatus === constants.applicationStatuses.INREVIEW || @@ -488,8 +472,8 @@ module.exports = { throw err; } }); - return accessRecord; } + return accessRecord; }, //PUT api/v1/data-access-request/:id @@ -2637,5 +2621,5 @@ module.exports = { ); break; } - } + }, }; diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index b84c9d5d..8e608473 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -14,7 +14,23 @@ export default class DataRequestRepository extends Repository { $and: [{ ...query }, { $or: [{ userId }, { authorIds: userId }] }], }) .select('-jsonSchema -questionAnswers -files') - .populate('datasets mainApplicant') + .populate([{ path: 'mainApplicant', select: 'firstname lastname -id' }, { path: 'datasets' }]) + .lean(); + } + + async getApplicationById(id) { + return DataRequestModel.findOne({ + _id: id, + }) + .populate([ + { path: 'mainApplicant', select: 'firstname lastname -id' }, + { + path: 'datasets dataset authors', + populate: { path: 'publisher', populate: { path: 'team' } }, + }, + { path: 'workflow.steps.reviewers', select: 'firstname lastname' }, + { path: 'files.owner', select: 'firstname lastname' }, + ]) .lean(); } } diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 5edb2a70..7ccfb56c 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -35,14 +35,14 @@ router.get( (req, res) => dataRequestController.getAccessRequestsByUser(req, res) ); -// @route GET api/v1/data-access-request/:requestId +// @route GET api/v1/data-access-request/:id // @desc GET a single data access request by Id // @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer router.get( - '/:requestId', + '/:id', passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application' }), - datarequestController.getAccessRequestById + (req, res) => dataRequestController.getAccessRequestById(req, res) ); // @route GET api/v1/data-access-request/dataset/:datasetId diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 6b0b6c1d..83014ee7 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -1,6 +1,9 @@ -import { isEmpty, has, isUndefined } from 'lodash'; +import { isEmpty } from 'lodash'; import moment from 'moment'; +import datarequestUtil from '../datarequest/utils/datarequest.util'; +import constants from '../utilities/constants.util'; + export default class DataRequestService { constructor(dataRequestRepository) { this.dataRequestRepository = dataRequestRepository; @@ -10,6 +13,67 @@ export default class DataRequestService { return this.dataRequestRepository.getAccessRequestsByUser(userId, query); } + getApplicationById(id) { + return this.dataRequestRepository.getApplicationById(id); + } + + getApplicationIsReadOnly(userType, applicationStatus) { + let readOnly = true; + if (userType === constants.userTypes.APPLICANT && applicationStatus === constants.applicationStatuses.INPROGRESS) { + readOnly = false; + } + return readOnly; + } + + getProjectName(accessRecord) { + // Retrieve project name from about application section + const { + aboutApplication: { projectName }, + } = accessRecord; + if (projectName) { + return projectName; + } else { + // Build default project name from publisher and dataset name + const { + datasetfields: { publisher }, + name, + } = accessRecord.datasets[0]; + return `${publisher} - ${name}`; + } + } + + getProjectNames(applications = []) { + return [...applications].map(accessRecord => { + const projectName = this.getProjectName(accessRecord); + const { _id } = accessRecord; + return { projectName, _id }; + }); + } + + getApplicantNames(accessRecord) { + // Retrieve applicant names from form answers + const { questionAnswers = {} } = accessRecord; + let applicants = datarequestUtil.extractApplicantNames(questionAnswers); + let applicantNames = ''; + // Return only main applicant if no applicants added + if (isEmpty(applicants)) { + const { firstname, lastname } = accessRecord.mainApplicant; + applicantNames = `${firstname} ${lastname}`; + } else { + applicantNames = applicants.join(', '); + } + return applicantNames; + } + + getDecisionDuration(accessRecord) { + const { dateFinalStatus, dateSubmitted } = accessRecord; + if (dateFinalStatus && dateSubmitted) { + return parseInt(moment(dateFinalStatus).diff(dateSubmitted, 'days')); + } else { + return ''; + } + } + calculateAvgDecisionTime(accessRecords = []) { // Guard for empty array passed if (isEmpty(accessRecords)) return 0; @@ -33,140 +97,4 @@ export default class DataRequestService { } return 0; } - - createApplicationDTO(accessRecord, userType, userId = '') { - let projectName = '', - applicants = '', - workflowName = '', - workflowCompleted = false, - remainingActioners = [], - decisionDuration = '', - decisionMade = false, - decisionStatus = '', - decisionComments = '', - decisionDate = '', - decisionApproved = false, - managerUsers = [], - stepName = '', - deadlinePassed = '', - reviewStatus = '', - isReviewer = false, - reviewPanels = [] - - // Check if the application has a workflow assigned - let { workflow = {}, applicationStatus } = accessRecord; - if (has(accessRecord, 'publisherObj.team.members')) { - let { - publisherObj: { - team: { members, users }, - }, - } = accessRecord; - let managers = members.filter(mem => { - return mem.roles.includes('manager'); - }); - managerUsers = users - .filter(user => managers.some(manager => manager.memberid.toString() === user._id.toString())) - .map(user => { - let isCurrentUser = user._id.toString() === userId.toString(); - return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)` : ``}`; - }); - if ( - applicationStatus === constants.applicationStatuses.SUBMITTED || - (applicationStatus === constants.applicationStatuses.INREVIEW && isEmpty(workflow)) - ) { - remainingActioners = managerUsers.join(', '); - } - if (!isEmpty(workflow)) { - ({ workflowName } = workflow); - workflowCompleted = this.workflowService.getWorkflowCompleted(workflow); - let activeStep = this.workflowService.getActiveWorkflowStep(workflow); - // Calculate active step status - if (!isEmpty(activeStep)) { - ({ - stepName = '', - remainingActioners = [], - deadlinePassed = '', - reviewStatus = '', - decisionMade = false, - decisionStatus = '', - decisionComments = '', - decisionApproved, - decisionDate, - isReviewer = false, - reviewPanels = [], - } = this.workflowService.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 === constants.applicationStatuses.INREVIEW) { - reviewStatus = 'Final decision required'; - remainingActioners = managerUsers.join(', '); - } - // Get decision duration if completed - let { dateFinalStatus, dateSubmitted } = accessRecord; - if (dateFinalStatus) { - decisionDuration = parseInt(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 => constants.darPanelMapper[section]), - }; - arr.push(step); - return arr; - }, []); - workflow.steps = [...formattedSteps]; - } - } - - // Ensure backward compatibility with old single dataset DARs - if (isEmpty(accessRecord.datasets) || isUndefined(accessRecord.datasets)) { - accessRecord.datasets = [accessRecord.dataset]; - accessRecord.datasetIds = [accessRecord.datasetid]; - } - let { - datasetfields: { publisher }, - name, - } = accessRecord.datasets[0]; - let { aboutApplication, questionAnswers } = accessRecord; - - if (aboutApplication) { - ({ projectName } = aboutApplication); - } - if (isEmpty(projectName)) { - projectName = `${publisher} - ${name}`; - } - if (questionAnswers) { - applicants = datarequestUtil.extractApplicantNames(questionAnswers).join(', '); - } - if (isEmpty(applicants)) { - let { firstname, lastname } = accessRecord.mainApplicant; - applicants = `${firstname} ${lastname}`; - } - return { - ...accessRecord, - projectName, - applicants, - publisher, - workflowName, - workflowCompleted, - decisionDuration, - decisionMade, - decisionStatus, - decisionComments, - decisionDate, - decisionApproved, - remainingActioners, - stepName, - deadlinePassed, - reviewStatus, - isReviewer, - reviewPanels - }; - } } diff --git a/src/resources/publisher/dependency.js b/src/resources/publisher/dependency.js new file mode 100644 index 00000000..947baede --- /dev/null +++ b/src/resources/publisher/dependency.js @@ -0,0 +1,17 @@ +import PublisherRepository from './publisher.repository'; +import PublisherService from './publisher.service'; +import WorkflowRepository from '../workflow/workflow.repository'; +import WorkflowService from '../workflow/workflow.service'; +import DataRequestRepository from '../datarequest/datarequest.repository'; +import DataRequestService from '../datarequest/datarequest.service'; +import AmendmentRepository from '../datarequest/amendment/amendment.repository'; +import AmendmentService from '../datarequest/amendment/amendment.service'; + +export const publisherRepository = new PublisherRepository(); +export const publisherService = new PublisherService(publisherRepository); +export const workflowRepository = new WorkflowRepository(); +export const workflowService = new WorkflowService(workflowRepository); +export const dataRequestRepository = new DataRequestRepository(); +export const dataRequestService = new DataRequestService(dataRequestRepository); +export const amendmentRepository = new AmendmentRepository(); +export const amendmentService = new AmendmentService(amendmentRepository); diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index a3845ffa..3011880a 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -1,23 +1,27 @@ import _ from 'lodash'; -import mongoose from 'mongoose'; -import { PublisherModel } from './publisher.model'; -import { Data } from '../tool/data.model'; -import { DataRequestModel } from '../datarequest/datarequest.model'; -import { WorkflowModel } from '../workflow/workflow.model'; import constants from '../utilities/constants.util'; import teamController from '../team/team.controller'; +import Controller from '../base/controller'; -module.exports = { - // GET api/v1/publishers/:id - getPublisherById: async (req, res) => { +import { logger } from '../utilities/logger'; +const logCategory = 'Publisher'; + +export default class PublisherController extends Controller { + constructor(publisherService, workflowService, dataRequestService, amendmentService) { + super(publisherService); + this.publisherService = publisherService; + this.workflowService = workflowService; + this.dataRequestService = dataRequestService; + this.amendmentService = amendmentService; + } + + async getPublisher(req, res) { try { // 1. Get the publisher from the database - let publisher; - if (mongoose.Types.ObjectId.isValid(req.params.id)) { - publisher = await PublisherModel.findOne({ _id: req.params.id }); - } else { - publisher = await PublisherModel.findOne({ name: req.params.id }); - } + const { id } = req.params; + const publisher = await this.publisherService.getPublisher(id).catch(err => { + logger.logError(err, logCategory); + }); if (!publisher) { return res.status(200).json({ success: true, @@ -27,243 +31,122 @@ module.exports = { // 2. Return publisher return res.status(200).json({ success: true, publisher }); } catch (err) { - console.error(err.message); - return res.status(500).json(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred fetching the custodian details', + }); } - }, + } - // GET api/v1/publishers/:id/datasets - getPublisherDatasets: async (req, res) => { + async getPublisherDatasets(req, res) { try { // 1. Get the datasets for the publisher from the database - let datasets = await Data.find({ - type: 'dataset', - activeflag: 'active', - 'datasetfields.publisher': req.params.id, - }) - .populate('publisher') - .select('datasetid name description datasetfields.abstract _id datasetfields.publisher datasetfields.contactPoint publisher'); - if (!datasets) { - return res.status(404).json({ success: false }); - } - // 2. Map datasets to flatten datasetfields nested object - datasets = datasets.map(dataset => { - let { - _id, - datasetid: datasetId, - name, - description, - publisher: publisherObj, - datasetfields: { abstract, publisher, contactPoint }, - } = dataset; - return { - _id, - datasetId, - name, - description, - abstract, - publisher, - publisherObj, - contactPoint, - }; + const { id } = req.params; + let datasets = await this.publisherService.getPublisherDatasets(id).catch(err => { + logger.logError(err, logCategory); }); - // 3. Return publisher datasets + // 2. Return publisher datasets return res.status(200).json({ success: true, datasets }); } catch (err) { - console.error(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); return res.status(500).json({ success: false, message: 'An error occurred searching for custodian datasets', }); } - }, + } - // GET api/v1/publishers/:id/dataaccessrequests - getPublisherDataAccessRequests: async (req, res) => { + async getPublisherDataAccessRequests(req, res) { try { // 1. Deconstruct the request - let { _id } = req.user; + const { _id: requestingUserId } = req.user; + const { id } = req.params; // 2. Lookup publisher team - const publisher = await PublisherModel.findOne({ name: req.params.id }).populate('team', 'members').lean(); + const options = { lean: true, populate: [{ path: 'team' }, { path: 'members' }] }; + const publisher = await this.publisherService.getPublisher(id, options).catch(err => { + logger.logError(err, logCategory); + }); 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, 'team.members')) { - let { members } = publisher.team; - found = members.some(el => el.memberid.toString() === _id.toString()); - } - - if (!found) return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - - //Check if current use is a manager - let isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, publisher.team, _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', - 'datasetfields.publisher': req.params.id, - }).distinct('datasetid'); - // 5. Find all applications where any datasetId exists - let applications = await DataRequestModel.find({ - $and: [ - { - $or: [{ dataSetId: { $in: datasetIds } }, { datasetIds: { $elemMatch: { $in: datasetIds } } }], - }, - { applicationStatus: { $nin: applicationStatus } }, - ], - }) - .select('-jsonSchema -questionAnswers -files') - .sort({ updatedAt: -1 }) - .populate([ - { - path: 'datasets dataset mainApplicant', - }, - { - path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - select: 'firstname lastname', - }, - }, - }, - { - path: 'workflow.steps.reviewers', - select: 'firstname lastname', - }, - ]) - .lean(); + const isAuthenticated = teamController.checkTeamPermissions('', publisher.team, requestingUserId); + if (!isAuthenticated) return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - if (!isManager) { - applications = applications.filter(app => { - let { workflow = {} } = app; - if (_.isEmpty(workflow)) { - return app; - } + //Check if current user is a manager + const isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, publisher.team, requestingUserId); - 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._id.equals(_id))); - - if (found) { - return app; - } - }); - } + // 4. Find all applications for current team member view + const applications = await this.publisherService.getPublisherDataAccessRequests(id, requestingUserId, isManager).catch(err => { + logger.logError(err, logCategory); + }); - // 6. Append projectName and applicants - let modifiedApplications = [...applications] + // 5. Append projectName and applicants + const modifiedApplications = [...applications] .map(accessRecord => { - return this.dataRequestService.createApplicationDTO(accessRecord, constants.userTypes.CUSTODIAN, _id.toString()); + accessRecord = this.workflowService.getWorkflowDetails(accessRecord, requestingUserId); + accessRecord.projectName = this.dataRequestService.getProjectName(accessRecord); + accessRecord.applicants = this.dataRequestService.getApplicantNames(accessRecord); + accessRecord.decisionDuration = this.dataRequestService.getDecisionDuration(accessRecord); + accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.CUSTODIAN); + return accessRecord; }) .sort((a, b) => b.updatedAt - a.updatedAt); - let avgDecisionTime = this.dataRequestService.calculateAvgDecisionTime(applications); - // 7. Return all applications + const avgDecisionTime = this.dataRequestService.calculateAvgDecisionTime(applications); + // 6. Return all applications return res.status(200).json({ success: true, data: modifiedApplications, avgDecisionTime, canViewSubmitted: isManager }); } catch (err) { - console.error(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); 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) => { + async getPublisherWorkflows(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', - }, - { - path: 'applications', - select: 'aboutApplication', - match: { applicationStatus: 'inReview' }, - }, - ]); + const { id } = req.params; + let workflows = await this.workflowService.getWorkflowsByPublisher(id).catch(err => { + logger.logError(err, logCategory); + }); 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 authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, workflows[0].publisher.team.toObject(), userId); - // 3. If not return unauthorised - if (!authorised) { - return res.status(401).json({ success: false }); - } - // 4. Build workflows + // 2. Get attached data access request application project names workflows = workflows.map(workflow => { - let { active, _id, id, workflowName, version, steps, applications = [] } = workflow.toObject(); - - let formattedSteps = [...steps].reduce((arr, item) => { - let step = { - ...item, - displaySections: [...item.sections].map(section => constants.darPanelMapper[section]), - }; - arr.push(step); - return arr; - }, []); - - applications = applications.map(app => { - let { aboutApplication = {}, _id } = app; - let { projectName = 'No project name' } = aboutApplication; - return { projectName, _id }; - }); - let canDelete = applications.length === 0, - canEdit = applications.length === 0; + let { applications = [] } = workflow; return { - active, - _id, - id, - workflowName, - version, - steps: formattedSteps, - applications, - appCount: applications.length, - canDelete, - canEdit, + ...workflow, + applications: this.dataRequestService.getProjectNames(applications) }; }); + // 3. Check the requesting user is a member of the team + const { _id: requestingUserId } = req.user; + const { + publisher: { team }, + } = workflows[0]; + const authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserId); + // 4. If not return unauthorised + if (!authorised) { + return res.status(401).json({ success: false }); + } // 5. Return payload return res.status(200).json({ success: true, workflows }); } catch (err) { - console.error(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); return res.status(500).json({ success: false, message: 'An error occurred searching for custodian workflows', }); } - }, -}; + } +} diff --git a/src/resources/publisher/publisher.repository.js b/src/resources/publisher/publisher.repository.js new file mode 100644 index 00000000..a5316037 --- /dev/null +++ b/src/resources/publisher/publisher.repository.js @@ -0,0 +1,61 @@ +import Repository from '../base/repository'; +import { PublisherModel } from './publisher.model'; +import { Dataset } from '../dataset/dataset.model'; +import { DataRequestModel } from '../datarequest/datarequest.model'; + +import mongoose from 'mongoose'; + +export default class PublisherRepository extends Repository { + constructor() { + super(PublisherModel); + this.publisherModel = PublisherModel; + } + + getPublisher(id, options = {}) { + let query = {}; + + if (mongoose.Types.ObjectId.isValid(id)) { + query = { _id: id }; + } else { + query = { name: id }; + } + + return this.findOne(query, options); + } + + getPublisherDatasets(id) { + return Dataset.find({ + type: 'dataset', + activeflag: 'active', + 'datasetfields.publisher': id, + }) + .populate('publisher') + .select('datasetid name description datasetfields.abstract _id datasetfields.publisher datasetfields.contactPoint publisher'); + } + + getPublisherDataAccessRequests(query) { + return DataRequestModel.find(query) + .select('-jsonSchema -questionAnswers -files') + .sort({ updatedAt: -1 }) + .populate([ + { + path: 'datasets dataset mainApplicant', + }, + { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users', + select: 'firstname lastname', + }, + }, + }, + { + path: 'workflow.steps.reviewers', + select: 'firstname lastname', + }, + ]) + .lean(); + } +} diff --git a/src/resources/publisher/publisher.route.js b/src/resources/publisher/publisher.route.js index 19bd0ac5..b991f6e9 100644 --- a/src/resources/publisher/publisher.route.js +++ b/src/resources/publisher/publisher.route.js @@ -1,28 +1,50 @@ import express from 'express'; import passport from 'passport'; -const publisherController = require('./publisher.controller'); +import { logger } from '../utilities/logger'; +import PublisherController from './publisher.controller'; +import { publisherService, workflowService, dataRequestService, amendmentService } from './dependency'; + +const logCategory = 'Publisher'; +const publisherController = new PublisherController(publisherService, workflowService, dataRequestService, amendmentService); const router = express.Router(); // @route GET api/publishers/:id // @desc GET A publishers by :id // @access Public -router.get('/:id', publisherController.getPublisherById); +router.get('/:id', logger.logRequestMiddleware({ logCategory, action: 'Viewed a publishers details' }), (req, res) => + publisherController.getPublisher(req, res) +); // @route GET api/publishers/:id/datasets // @desc GET all datasets owned by publisher // @access Private -router.get('/:id/datasets', passport.authenticate('jwt'), publisherController.getPublisherDatasets); +router.get( + '/:id/datasets', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Viewed datasets for a publisher' }), + (req, res) => publisherController.getPublisherDatasets(req, res) +); // @route GET api/publishers/:id/dataaccessrequests // @desc GET all data access requests to a publisher // @access Private -router.get('/:id/dataaccessrequests', passport.authenticate('jwt'), publisherController.getPublisherDataAccessRequests); +router.get( + '/:id/dataaccessrequests', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Viewed data access requests for a publisher' }), + (req, res) => publisherController.getPublisherDataAccessRequests(req, res) +); // @route GET api/publishers/:id/workflows // @desc GET workflows for publisher // @access Private -router.get('/:id/workflows', passport.authenticate('jwt'), publisherController.getPublisherWorkflows); +router.get( + '/:id/workflows', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Viewed workflows for a publisher' }), + (req, res) => publisherController.getPublisherWorkflows(req, res) +); module.exports = router; diff --git a/src/resources/publisher/publisher.service.js b/src/resources/publisher/publisher.service.js new file mode 100644 index 00000000..dd482911 --- /dev/null +++ b/src/resources/publisher/publisher.service.js @@ -0,0 +1,79 @@ +import { isEmpty, findIndex } from 'lodash'; + +export default class PublisherService { + constructor(publisherRepository) { + this.publisherRepository = publisherRepository; + } + + getPublisher(id, options = {}) { + return this.publisherRepository.getPublisher(id, options); + } + + async getPublisherDatasets(id) { + const datasets = this.publisherRepository.getPublisherDatasets(id); + + return [...datasets].map(dataset => { + const { + _id, + datasetid: datasetId, + name, + description, + publisher: publisherObj, + datasetfields: { abstract, publisher, contactPoint }, + } = dataset; + return { + _id, + datasetId, + name, + description, + abstract, + publisher, + publisherObj, + contactPoint, + }; + }); + } + + async getPublisherDataAccessRequests(id, requestingUserId, isManager) { + const excludedApplicationStatuses = ['inProgress']; + if (!isManager) { + applicationStatus.push('submitted'); + } + const query = { publisher: id, applicationStatus: { $nin: excludedApplicationStatuses } }; + + let applications = await this.publisherRepository.getPublisherDataAccessRequests(query); + + if (!isManager) { + applications = this.filterApplicationsForReviewer(applications, requestingUserId); + } + + return applications; + } + + filterApplicationsForReviewer(applications, reviewerUserId) { + const filteredApplications = [...applications].filter(app => { + let { workflow = {} } = app; + 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._id.equals(reviewerUserId))); + + if (found) { + return app; + } + }); + + return filteredApplications; + } +} diff --git a/src/resources/stats/stats.controller.js b/src/resources/stats/stats.controller.js index 963277d7..9f7f8b3d 100644 --- a/src/resources/stats/stats.controller.js +++ b/src/resources/stats/stats.controller.js @@ -12,7 +12,9 @@ export default class StatsController extends Controller { async getSnapshots(req, res) { try { // Find the relevant snapshots - let snapshots = await this.statsService.getSnapshots(req.query); + let snapshots = await this.statsService.getSnapshots(req.query).catch(err => { + logger.logError(err, logCategory); + });; // Return the snapshots return res.status(200).json({ success: true, diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index e79f6c76..b932fe51 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -42,12 +42,12 @@ const getTeamMembers = async (req, res) => { path: 'additionalInfo', select: 'organisation bio showOrganisation showBio', }, - }); + }).lean(); if (!team) { return res.status(404).json({ success: false }); } // 2. Check the current user is a member of the team - let authorised = checkTeamPermissions('', team.toObject(), req.user._id); + let authorised = checkTeamPermissions('', team, req.user._id); // 3. If not return unauthorised if (!authorised) { return res.status(401).json({ success: false }); diff --git a/src/resources/team/team.model.js b/src/resources/team/team.model.js index 3797aba7..91a2f189 100644 --- a/src/resources/team/team.model.js +++ b/src/resources/team/team.model.js @@ -60,6 +60,7 @@ TeamSchema.virtual('users', { ref: 'User', foreignField: '_id', localField: 'members.memberid', + match: { isServiceAccount: { $ne: true } } }); export const TeamModel = model('Team', TeamSchema); diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index ef40c8fe..fabd9051 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -131,42 +131,31 @@ export default class WorkflowController extends Controller { createdBy: new Mongoose.Types.ObjectId(userId), }); // 7. save new workflow to db - workflow.save(function (err) { + workflow = await workflow.save().catch(err => { if (err) { return res.status(400).json({ success: false, message: err.message, }); } - // 8. populate the workflow with the needed fiedls for our new notification and email - workflow.populate( - { - path: 'steps.reviewers', - select: 'firstname lastname email -_id', - }, - (err, doc) => { - if (err) { - // 9. if issue - return res.status(400).json({ - success: false, - message: err.message, - }); - } - // 10. set context - let context = { - publisherObj: publisherObj.team.toObject(), - actioner: `${firstname} ${lastname}`, - workflow: doc.toObject(), - }; - // 11. Generate new notifications / emails for managers of the team only on creation of a workflow - this.workflowService.createNotifications(context, constants.notificationTypes.WORKFLOWCREATED); - // 12. full complete return - return res.status(201).json({ - success: true, - workflow, - }); - } - ); + }); + // 8. populate the workflow with the needed fields for our new notification and email + const detailedWorkflow = await WorkflowModel.findById(workflow._id).populate({ + path: 'steps.reviewers', + select: 'firstname lastname email -_id', + }).lean(); + // 9. set context + let context = { + publisherObj: publisherObj.team.toObject(), + actioner: `${firstname} ${lastname}`, + workflow: detailedWorkflow, + }; + // 10. Generate new notifications / emails for managers of the team only on creation of a workflow + this.workflowService.createNotifications(context, constants.notificationTypes.WORKFLOWCREATED); + // 11. full complete return + return res.status(201).json({ + success: true, + workflow: detailedWorkflow, }); } catch (err) { console.error(err.message); diff --git a/src/resources/workflow/workflow.repository.js b/src/resources/workflow/workflow.repository.js index 79364b1b..f0716714 100644 --- a/src/resources/workflow/workflow.repository.js +++ b/src/resources/workflow/workflow.repository.js @@ -7,14 +7,28 @@ export default class WorkflowRepository extends Repository { this.workflowModel = WorkflowModel; } - // async getAccessRequestsByUser(userId, query) { - // if (!userId) return []; - - // return DataRequestModel.find({ - // $and: [{ ...query }, { $or: [{ userId }, { authorIds: userId }] }], - // }) - // .select('-jsonSchema -questionAnswers -files') - // .populate('datasets mainApplicant') - // .lean(); - // } + getWorkflowsByPublisher(id) { + return WorkflowModel.find({ + publisher: 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' }, + }, + ]).lean(); + } } diff --git a/src/resources/workflow/workflow.route.js b/src/resources/workflow/workflow.route.js index ca67746b..a9ee6045 100644 --- a/src/resources/workflow/workflow.route.js +++ b/src/resources/workflow/workflow.route.js @@ -1,5 +1,6 @@ import express from 'express'; import passport from 'passport'; + import { logger } from '../utilities/logger'; import WorkflowController from './workflow.controller'; import { workflowService } from './dependency'; diff --git a/src/resources/workflow/workflow.service.js b/src/resources/workflow/workflow.service.js index 7a3363b1..28add0c7 100644 --- a/src/resources/workflow/workflow.service.js +++ b/src/resources/workflow/workflow.service.js @@ -4,15 +4,127 @@ import emailGenerator from '../utilities/emailGenerator.util'; import notificationBuilder from '../utilities/notificationBuilder'; import moment from 'moment'; -import _ from 'lodash'; +import { isEmpty, has } from 'lodash'; export default class WorkflowService { constructor(workflowRepository) { this.workflowRepository = workflowRepository; } + async getWorkflowsByPublisher(id) { + const workflows = await this.workflowRepository.getWorkflowsByPublisher(id); + + const formattedWorkflows = [...workflows].map(workflow => { + let { active, _id, id, workflowName, version, applications = [], publisher } = workflow; + const formattedSteps = this.formatWorkflowSteps(workflow, 'displaySections'); + return { + active, + _id, + id, + workflowName, + version, + steps: formattedSteps, + appCount: applications.length, + canDelete: applications.length === 0, + canEdit: applications.length === 0, + publisher + }; + }); + + return formattedWorkflows; + } + + getWorkflowDetails(accessRecord, requestingUserId) { + if (!has(accessRecord, 'publisherObj.team.members')) return accessRecord; + + let { workflow = {} } = accessRecord; + + const activeStep = this.getActiveWorkflowStep(workflow); + accessRecord = this.getRemainingActioners(accessRecord, activeStep, requestingUserId); + + if (isEmpty(workflow)) return accessRecord; + + accessRecord.workflowName = workflow.workflowName; + accessRecord.workflowCompleted = this.getWorkflowCompleted(workflow); + + if (isEmpty(activeStep)) return accessRecord; + + const activeStepDetails = this.getActiveStepStatus(activeStep, requestingUserId); + accessRecord = { ...accessRecord, ...activeStepDetails }; + + accessRecord = this.setActiveStepReviewStatus(accessRecord); + + accessRecord.workflow.steps = this.formatWorkflowSteps(workflow, 'sections'); + + return accessRecord; + } + + formatWorkflowSteps(workflow, sectionsKey) { + // Set review section to display format + const { steps = [] } = workflow; + let formattedSteps = [...steps].reduce((arr, item) => { + let step = { + ...item, + [sectionsKey]: [...item.sections].map(section => constants.darPanelMapper[section]), + }; + arr.push(step); + return arr; + }, []); + return [...formattedSteps]; + } + + setActiveStepReviewStatus(accessRecord) { + const { workflow } = accessRecord; + if (!workflow) return ''; + + let activeStepIndex = workflow.steps.findIndex(step => { + return step.active === true; + }); + + if (activeStepIndex === -1) return ''; + + accessRecord.workflow.steps[activeStepIndex].reviewStatus = accessRecord.reviewStatus; + + return accessRecord; + } + + getRemainingActioners(accessRecord, activeStep = {}, requestingUserId) { + let { + workflow = {}, + applicationStatus, + publisherObj: { team }, + } = accessRecord; + + if ( + applicationStatus === constants.applicationStatuses.SUBMITTED || + (applicationStatus === constants.applicationStatuses.INREVIEW && isEmpty(workflow)) + ) { + accessRecord.remainingActioners = this.getReviewManagers(team, requestingUserId).join(', '); + } else if (!isEmpty(workflow) && isEmpty(activeStep) && applicationStatus === constants.applicationStatuses.INREVIEW) { + remainingActioners = this.getReviewManagers(team, requestingUserId).join(', '); + accessRecord.reviewStatus = 'Final decision required'; + } else { + accessRecord.remainingActioners = this.getRemainingReviewerNames(activeStep, team.users, requestingUserId); + } + + return accessRecord; + } + + getReviewManagers(team, requestingUserId) { + const { members = [], users = [] } = team; + const managers = members.filter(mem => { + return mem.roles.includes('manager'); + }); + return users + .filter(user => managers.some(manager => manager.memberid.toString() === user._id.toString())) + .map(user => { + const isCurrentUser = user._id.toString() === requestingUserId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)` : ``}`; + }); + } + async createNotifications(context, type = '') { - if (!_.isEmpty(type)) { + if (!isEmpty(type)) { // local variables set here let custodianManagers = [], managerUserIds = [], @@ -105,7 +217,7 @@ export default class WorkflowService { getWorkflowCompleted(workflow = {}) { let workflowCompleted = false; - if (!_.isEmpty(workflow)) { + if (!isEmpty(workflow)) { let { steps } = workflow; workflowCompleted = steps.every(step => step.completed); } @@ -114,7 +226,7 @@ export default class WorkflowService { getActiveWorkflowStep(workflow = {}) { let activeStep = {}; - if (!_.isEmpty(workflow)) { + if (!isEmpty(workflow)) { let { steps } = workflow; activeStep = steps.find(step => { return step.active; @@ -126,7 +238,7 @@ export default class WorkflowService { getStepReviewers(step = {}) { let stepReviewers = []; // Attempt to get step reviewers if workflow passed - if (!_.isEmpty(step)) { + if (!isEmpty(step)) { // Get active reviewers if (step) { ({ reviewers: stepReviewers } = step); @@ -135,8 +247,8 @@ export default class WorkflowService { return stepReviewers; } - getRemainingReviewers(Step = {}, users) { - let { reviewers = [], recommendations = [] } = Step; + getRemainingReviewers(step = {}, users) { + let { reviewers = [], recommendations = [] } = step; let remainingActioners = reviewers.filter( reviewer => !recommendations.some(rec => rec.reviewer.toString() === reviewer._id.toString()) ); @@ -145,16 +257,29 @@ export default class WorkflowService { return remainingActioners; } - getActiveStepStatus(activeStep, users = [], userId = '') { + getRemainingReviewerNames(step = {}, users, requestingUserId) { + if (isEmpty(step)) return ''; + let remainingActioners = this.getRemainingReviewers(step, users); + + if (isEmpty(remainingActioners)) return ''; + + let remainingReviewerNames = remainingActioners.map(user => { + let isCurrentUser = user._id.toString() === requestingUserId.toString(); + return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)` : ``}`; + }); + + return remainingReviewerNames.join(', '); + } + + getActiveStepStatus(activeStep, userId = '') { let reviewStatus = '', deadlinePassed = false, - remainingActioners = [], decisionMade = false, decisionComments = '', decisionApproved = false, decisionDate = '', decisionStatus = ''; - let { stepName, deadline, startDateTime, reviewers = [], recommendations = [], sections = [] } = activeStep; + 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) { @@ -165,13 +290,6 @@ export default class WorkflowService { } else { reviewStatus = `Deadline is today`; } - remainingActioners = reviewers.filter(reviewer => !recommendations.some(rec => rec.reviewer.toString() === reviewer._id.toString())); - remainingActioners = users - .filter(user => remainingActioners.some(actioner => actioner._id.toString() === user._id.toString())) - .map(user => { - let isCurrentUser = user._id.toString() === userId.toString(); - return `${user.firstname} ${user.lastname}${isCurrentUser ? ` (you)` : ``}`; - }); let isReviewer = reviewers.some(reviewer => reviewer._id.toString() === userId.toString()); let hasRecommended = recommendations.some(rec => rec.reviewer.toString() === userId.toString()); @@ -195,7 +313,6 @@ export default class WorkflowService { return { stepName, - remainingActioners: remainingActioners.join(', '), deadlinePassed, isReviewer, reviewStatus, @@ -211,7 +328,7 @@ export default class WorkflowService { getWorkflowStatus(application) { let workflowStatus = {}; let { workflow = {} } = application; - if (!_.isEmpty(workflow)) { + if (!isEmpty(workflow)) { let { workflowName, steps } = workflow; // Find the active step in steps let activeStep = this.getActiveWorkflowStep(workflow); @@ -255,7 +372,7 @@ export default class WorkflowService { 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)) { + if (!isEmpty(workflow)) { let { steps } = workflow; let activeStep = steps.find(step => { return step.active === true; @@ -265,7 +382,7 @@ export default class WorkflowService { reviewSections = [...activeStep.sections]; let { recommendations = [] } = activeStep; - if (!_.isEmpty(recommendations)) { + if (!isEmpty(recommendations)) { hasRecommended = recommendations.some(rec => rec.reviewer.toString() === userId.toString()); } } @@ -304,13 +421,13 @@ export default class WorkflowService { // Calculate duration for step if it is completed if (completed) { - if (!_.isEmpty(startDateTime.toString()) && !_.isEmpty(endDateTime.toString())) { + 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) { + if (!isEmpty(startDateTime.toString()) && stepDeadline != 0) { dateDeadline = moment(startDateTime).add(stepDeadline, 'days'); deadlineElapsed = moment().isAfter(dateDeadline, 'second'); @@ -322,13 +439,13 @@ export default class WorkflowService { } // Find reviewers of the current incomplete phase let accessRecordObj = accessRecord.toObject(); - if (_.has(accessRecordObj, 'publisherObj.team.users')) { + if (has(accessRecordObj, 'publisherObj.team.users')) { let { publisherObj: { team: { users = [] }, }, } = accessRecordObj; - remainingReviewers = getRemainingReviewers(steps[relatedStepIndex], users); + remainingReviewers = this.getRemainingReviewers(steps[relatedStepIndex], users); remainingReviewerUserIds = [...remainingReviewers].map(user => user.id); } } @@ -338,7 +455,7 @@ export default class WorkflowService { // If workflow completed nextStepName = 'No next step'; // Calculate total duration for workflow - if (steps[relatedStepIndex].completed && !_.isEmpty(dateReviewStart.toString())) { + if (steps[relatedStepIndex].completed && !isEmpty(dateReviewStart.toString())) { totalDuration = moment().diff(moment(dateReviewStart), 'days'); totalDuration = totalDuration === 0 ? `Same day` : duration === 1 ? `1 day` : `${duration} days`; } From 75681d3060228b71db4844889620e8f0431136e2 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 12 May 2021 17:23:22 +0100 Subject: [PATCH 07/81] Continued build --- src/resources/datarequest/datarequest.controller.js | 1 + src/resources/datarequest/datarequest.service.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index aaa08b76..c1464037 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -80,6 +80,7 @@ export default class DataRequestController extends Controller { const { params: { id }, } = req; + const { query } = req.params; const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; // 2. Find the matching record and include attached datasets records with publisher details diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 83014ee7..21399a24 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -28,7 +28,7 @@ export default class DataRequestService { getProjectName(accessRecord) { // Retrieve project name from about application section const { - aboutApplication: { projectName }, + aboutApplication: { projectName } = {}, } = accessRecord; if (projectName) { return projectName; From 0c11d6d6c13f70092ba681cf3b3cd4f4a9b7ad68 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 13 May 2021 09:44:48 +0100 Subject: [PATCH 08/81] Continued build of versioning --- .../amendment/__tests__/amendments.test.js | 6 +- .../amendment/amendment.controller.js | 4 +- .../amendment/amendment.service.js | 168 ++++++++++-------- .../datarequest/datarequest.controller.js | 38 ++-- .../datarequest/datarequest.repository.js | 7 +- .../datarequest/datarequest.service.js | 28 ++- 6 files changed, 158 insertions(+), 93 deletions(-) diff --git a/src/resources/datarequest/amendment/__tests__/amendments.test.js b/src/resources/datarequest/amendment/__tests__/amendments.test.js index abf347da..3febd82f 100755 --- a/src/resources/datarequest/amendment/__tests__/amendments.test.js +++ b/src/resources/datarequest/amendment/__tests__/amendments.test.js @@ -542,12 +542,12 @@ describe('doResubmission', () => { }); }); -describe('countUnsubmittedAmendments', () => { +describe('countAmendments', () => { test('given a data access record with unsubmitted amendments, the correct number of answered and unanswered amendments in returned', () => { // Arrange let data = _.cloneDeep(dataRequest[5]); // Act - const result = amendmentService.countUnsubmittedAmendments(data, constants.userTypes.APPLICANT); + const result = amendmentService.countAmendments(data, constants.userTypes.APPLICANT); // Assert expect(result.unansweredAmendments).toBe(2); expect(result.answeredAmendments).toBe(1); @@ -556,7 +556,7 @@ describe('countUnsubmittedAmendments', () => { // Arrange let data = _.cloneDeep(dataRequest[6]); // Act - const result = amendmentService.countUnsubmittedAmendments(data, constants.userTypes.APPLICANT); + const result = amendmentService.countAmendments(data, constants.userTypes.APPLICANT); // Assert expect(result.unansweredAmendments).toBe(0); expect(result.answeredAmendments).toBe(0); diff --git a/src/resources/datarequest/amendment/amendment.controller.js b/src/resources/datarequest/amendment/amendment.controller.js index 69015d11..e7979135 100644 --- a/src/resources/datarequest/amendment/amendment.controller.js +++ b/src/resources/datarequest/amendment/amendment.controller.js @@ -120,7 +120,7 @@ export default class AmendmentController extends Controller { activeParty ); // 12. Count the number of answered/unanswered amendments - const { answeredAmendments = 0, unansweredAmendments = 0 } = this.amendmentService.countUnsubmittedAmendments(accessRecord, userType); + const { answeredAmendments = 0, unansweredAmendments = 0 } = this.amendmentService.countAmendments(accessRecord, userType); return res.status(200).json({ success: true, accessRecord: { @@ -202,7 +202,7 @@ export default class AmendmentController extends Controller { }); } // 6. Check some amendments exist to be submitted to the applicant(s) - const { unansweredAmendments } = this.amendmentService.countUnsubmittedAmendments(accessRecord, constants.userTypes.CUSTODIAN); + const { unansweredAmendments } = this.amendmentService.countAmendments(accessRecord, constants.userTypes.CUSTODIAN); if (unansweredAmendments === 0) { return res.status(400).json({ status: 'failure', diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index 6c8fe42b..bac523ad 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -12,7 +12,7 @@ export default class AmendmentService { this.amendmentRepository = amendmentRepository; } - addAmendment (accessRecord, questionId, questionSetId, answer, reason, user, requested) { + addAmendment(accessRecord, questionId, questionSetId, answer, reason, user, requested) { // 1. Create new amendment object with key representing the questionId let amendment = { [`${questionId}`]: new AmendmentModel({ @@ -47,9 +47,9 @@ export default class AmendmentService { }; accessRecord.amendmentIterations = [...accessRecord.amendmentIterations, amendmentIteration]; } - }; - - updateAmendment (accessRecord, questionId, answer, user) { + } + + updateAmendment(accessRecord, questionId, answer, user) { // 1. Locate amendment in current iteration const currentIterationIndex = this.getLatestAmendmentIterationIndex(accessRecord); // 2. Return unmoodified record if invalid update @@ -86,9 +86,9 @@ export default class AmendmentService { }; // 5. Return updated access record return accessRecord; - }; - - removeAmendment (accessRecord, questionId) { + } + + removeAmendment(accessRecord, questionId) { // 1. Find the index of the latest amendment amendmentIteration of the DAR let index = this.getLatestAmendmentIterationIndex(accessRecord); // 2. Remove the key and associated object from the current iteration if it exists @@ -99,9 +99,9 @@ export default class AmendmentService { return _.isEmpty(amendmentIteration.questionAnswers); }); } - }; - - doesAmendmentExist (accessRecord, questionId) { + } + + doesAmendmentExist(accessRecord, questionId) { // 1. Get current amendment iteration const latestIteration = this.getCurrentAmendmentIteration(accessRecord.amendmentIterations); if (_.isNil(latestIteration) || _.isNil(latestIteration.questionAnswers)) { @@ -109,9 +109,9 @@ export default class AmendmentService { } // 2. Check if questionId has been added by Custodian for amendment return latestIteration.questionAnswers.hasOwnProperty(questionId); - }; - - handleApplicantAmendment (accessRecord, questionId, questionSetId, answer = '', user) { + } + + handleApplicantAmendment(accessRecord, questionId, questionSetId, answer = '', user) { // 1. Check if an amendment already exists for the question let isExisting = this.doesAmendmentExist(accessRecord, questionId); // 2. Update existing @@ -128,22 +128,22 @@ export default class AmendmentService { } else if (answer !== latestAnswer || !helperUtil.arraysEqual(answer, latestAnswer)) { performAdd = true; } - + if (performAdd) { // 6. Add new amendment otherwise this.addAmendment(accessRecord, questionId, questionSetId, answer, '', user, false); } } // 7. Update the amendment count - let { unansweredAmendments = 0, answeredAmendments = 0 } = this.countUnsubmittedAmendments(accessRecord, constants.userTypes.APPLICANT); + let { unansweredAmendments = 0, answeredAmendments = 0 } = this.countAmendments(accessRecord, constants.userTypes.APPLICANT); accessRecord.unansweredAmendments = unansweredAmendments; accessRecord.answeredAmendments = answeredAmendments; accessRecord.dirtySchema = true; // 8. Return updated access record return accessRecord; - }; - - getLatestAmendmentIterationIndex (accessRecord) { + } + + getLatestAmendmentIterationIndex(accessRecord) { // 1. Guard for incorrect type passed let { amendmentIterations = [] } = accessRecord; if (_.isEmpty(amendmentIterations)) { @@ -161,8 +161,8 @@ export default class AmendmentService { let date = new Date(iteration.dateCreated); return date.getTime() == mostRecentDate.getTime(); }); - }; - + } + getAmendmentIterationParty (accessRecord) { // 1. Look for an amendment iteration that is in flight // An empty date submitted with populated date returned indicates that the current correction iteration is now with the applicants @@ -174,8 +174,24 @@ export default class AmendmentService { return constants.userTypes.APPLICANT; } }; - - filterAmendments (accessRecord = {}, userType) { + + getAmendmentIterationPartyByVersion(accessRecord, versionAmendmentIterationIndex) { + // If a specific version has been requested, determine the last party active on that version + // An empty submission date with a valid return date (added by Custodians returning the form) indicates applicants are active + const requestedAmendmentIteration = accessRecord.amendmentIterations[versionAmendmentIterationIndex]; + if (requestedAmendmentIteration === _.last(accessRecord.amendmentIterations)) { + if (_.isUndefined(requestedAmendmentIteration.dateSubmitted) && !_.isUndefined(requestedAmendmentIteration.dateReturned)) { + return constants.userTypes.APPLICANT; + } else { + return constants.userTypes.CUSTODIAN; + } + } else { + // If a previous version has been requested, there is no active party + return; + } + } + + filterAmendments(accessRecord = {}, userType) { if (_.isEmpty(accessRecord)) { return {}; } @@ -197,9 +213,9 @@ export default class AmendmentService { } // 2. Return relevant iterations return amendmentIterations; - }; - - injectAmendments (accessRecord, userType, user) { + } + + injectAmendments(accessRecord, userType, user) { // 1. Get latest iteration created by Custodian if (accessRecord.amendmentIterations.length === 0) { return accessRecord; @@ -209,7 +225,7 @@ export default class AmendmentService { const { dateReturned } = latestIteration; // 2. Applicants should see previous amendment iteration requests until current iteration has been returned with new requests if ( - lastIndex > 0 && (userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) || + (lastIndex > 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) || (userType === constants.userTypes.CUSTODIAN && _.isNil(latestIteration.questionAnswers)) ) { latestIteration = accessRecord.amendmentIterations[lastIndex - 1]; @@ -217,8 +233,8 @@ export default class AmendmentService { return accessRecord; } // 3. Update schema if there is a new iteration - const { publisher = 'Custodian' } = accessRecord; - if(!_.isNil(latestIteration)) { + const { publisher = 'Custodian' } = accessRecord; + if (!_.isNil(latestIteration)) { accessRecord.jsonSchema = this.formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher); } // 4. Filter out amendments that have not yet been exposed to the opposite party @@ -227,11 +243,11 @@ export default class AmendmentService { accessRecord.questionAnswers = this.formatQuestionAnswers(accessRecord.questionAnswers, amendmentIterations); // 6. Return the updated access record return accessRecord; - }; - - formatSchema (jsonSchema, latestAmendmentIteration, userType, user, publisher) { + } + + formatSchema(jsonSchema, latestAmendmentIteration, userType, user, publisher) { const { questionAnswers = {}, dateSubmitted, dateReturned } = latestAmendmentIteration; - if(_.isEmpty(questionAnswers)) { + if (_.isEmpty(questionAnswers)) { return jsonSchema; } // Loop through each amendment @@ -254,9 +270,9 @@ export default class AmendmentService { ); } return jsonSchema; - }; - - injectQuestionAmendment (jsonSchema, questionId, amendment, userType, completed, iterationStatus, user, publisher) { + } + + injectQuestionAmendment(jsonSchema, questionId, amendment, userType, completed, iterationStatus, user, publisher) { const { questionSetId } = amendment; // 1. Find question set containing question const qsIndex = jsonSchema.questionSets.findIndex(qs => qs.questionSetId === questionSetId); @@ -278,9 +294,9 @@ export default class AmendmentService { jsonSchema.questionSets[qsIndex].questions = datarequestUtil.updateQuestion(questions, question); // 6. Return updated schema return jsonSchema; - }; - - injectNavigationAmendment (jsonSchema, questionSetId, userType, completed, iterationStatus) { + } + + injectNavigationAmendment(jsonSchema, questionSetId, userType, completed, iterationStatus) { // 1. Find question in schema const qpIndex = jsonSchema.questionPanels.findIndex(qp => qp.panelId === questionSetId); if (qpIndex === -1) { @@ -299,9 +315,9 @@ export default class AmendmentService { } // 4. Return schema return jsonSchema; - }; - - getLatestQuestionAnswer (accessRecord, questionId) { + } + + getLatestQuestionAnswer(accessRecord, questionId) { // 1. Include original submission of question answer let parsedQuestionAnswers = _.cloneDeep(accessRecord.questionAnswers); let initialSubmission = { @@ -333,15 +349,15 @@ export default class AmendmentService { } return arr; }, []); - + if (_.isEmpty(latestAnswers)) { return undefined; } else { return latestAnswers[0].answer; } - }; - - formatQuestionAnswers (questionAnswers, amendmentIterations) { + } + + formatQuestionAnswers(questionAnswers, amendmentIterations) { if (_.isNil(amendmentIterations) || _.isEmpty(amendmentIterations)) { return questionAnswers; } @@ -373,9 +389,9 @@ export default class AmendmentService { }, {}); // 6. Return combined object return { ...questionAnswers, ...formattedLatestAnswers }; - }; - - getCurrentAmendmentIteration (amendmentIterations) { + } + + getCurrentAmendmentIteration(amendmentIterations) { // 1. Guard for incorrect type passed if (_.isEmpty(amendmentIterations) || _.isNull(amendmentIterations) || _.isUndefined(amendmentIterations)) { return undefined; @@ -394,9 +410,9 @@ export default class AmendmentService { })[0]; // 4. Return the correct object return mostRecentObject; - }; - - removeIterationAnswers (accessRecord = {}, iteration) { + } + + removeIterationAnswers(accessRecord = {}, iteration) { // 1. Guard for invalid object passed if (!iteration || _.isEmpty(accessRecord)) { return undefined; @@ -408,9 +424,9 @@ export default class AmendmentService { }); // 4. Return answer stripped iteration object return iteration; - }; - - doResubmission (accessRecord, userId) { + } + + doResubmission(accessRecord, userId) { // 1. Find latest iteration and if not found, return access record unmodified as no resubmission should take place let index = this.getLatestAmendmentIterationIndex(accessRecord); if (index === -1) { @@ -425,13 +441,18 @@ export default class AmendmentService { }; // 3. Return updated access record for saving return accessRecord; - }; - - countUnsubmittedAmendments (accessRecord, userType) { + } + + countAmendments(accessRecord, userType, versionAmendmentIterationIndex = -1) { // 1. Find latest iteration and if not found, return 0 + let index; let unansweredAmendments = 0; let answeredAmendments = 0; - let index = this.getLatestAmendmentIterationIndex(accessRecord); + if (versionAmendmentIterationIndex === -1) { + index = this.getLatestAmendmentIterationIndex(accessRecord); + } else { + index = versionAmendmentIterationIndex; + } if ( index === -1 || _.isNil(accessRecord.amendmentIterations[index].questionAnswers) || @@ -449,9 +470,11 @@ export default class AmendmentService { }); // 3. Return counts return { unansweredAmendments, answeredAmendments }; - }; - - revertAmendmentAnswer (accessRecord, questionId, user) { + } + + countAmendments(accessRecord, userType, versionAmendmentIterationIndex) {} + + revertAmendmentAnswer(accessRecord, questionId, user) { // 1. Locate the latest amendment iteration let index = this.getLatestAmendmentIterationIndex(accessRecord); // 2. Verify the amendment was previously requested and a new answer exists @@ -469,11 +492,14 @@ export default class AmendmentService { answer: undefined, }), }; - accessRecord.amendmentIterations[index].questionAnswers = { ...accessRecord.amendmentIterations[index].questionAnswers, ...amendment }; + accessRecord.amendmentIterations[index].questionAnswers = { + ...accessRecord.amendmentIterations[index].questionAnswers, + ...amendment, + }; } - }; - - calculateAmendmentStatus (accessRecord, userType) { + } + + calculateAmendmentStatus(accessRecord, userType) { let amendmentStatus = ''; const lastAmendmentIteration = _.last(accessRecord.amendmentIterations); const { applicationStatus } = accessRecord; @@ -503,9 +529,9 @@ export default class AmendmentService { } } return amendmentStatus; - }; + } - async createNotifications (type, accessRecord) { + async createNotifications(type, accessRecord) { // Project details from about application let { aboutApplication = {}, questionAnswers } = accessRecord; let { projectName = 'No project name set' } = aboutApplication; @@ -535,7 +561,7 @@ export default class AmendmentService { return { firstname, lastname, email, id }; }); } - + switch (type) { case constants.notificationTypes.RETURNED: // 1. Create notifications @@ -546,7 +572,7 @@ export default class AmendmentService { 'data access request', accessRecord._id ); - + // Authors notification if (!_.isEmpty(authors)) { await notificationBuilder.triggerNotificationMessage( @@ -556,7 +582,7 @@ export default class AmendmentService { accessRecord._id ); } - + // 2. Send emails to relevant users emailRecipients = [accessRecord.mainApplicant, ...accessRecord.authors]; // Create object to pass through email data @@ -580,5 +606,5 @@ export default class AmendmentService { ); break; } - }; + } } diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index c1464037..b0d299c0 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -33,6 +33,7 @@ export default class DataRequestController extends Controller { this.amendmentService = amendmentService; } + //GET api/v1/data-access-request async getAccessRequestsByUser(req, res) { try { // Deconstruct the parameters passed @@ -74,20 +75,22 @@ export default class DataRequestController extends Controller { } } + //GET api/v1/data-access-request/:id async getAccessRequestById(req, res) { try { // 1. Get dataSetId from params const { params: { id }, } = req; - const { query } = req.params; + const { version: requestedVersion } = req.query; const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; // 2. Find the matching record and include attached datasets records with publisher details let accessRecord = await this.dataRequestService.getApplicationById(id); + const { isValidVersion, isLatestMinorVersion, versionActiveParty, versionAmendmentIterationIndex } = this.dataRequestService.getVersionDetails(accessRecord, requestedVersion); // 3. If no matching application found, return 404 - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); + if (!accessRecord || !isValidVersion) { + return res.status(404).json({ status: 'error', message: 'The application or the requested version could not be found.' }); } // 4. Check if requesting user is custodian member or applicant/contributor const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( @@ -95,14 +98,22 @@ export default class DataRequestController extends Controller { requestingUserId, requestingUserObjectId ); - if (!authorised) { + if (!authorised || versionActiveParty !== userType) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } // 5. Set edit mode for applicants who have not yet submitted - const { applicationStatus } = accessRecord; + const { applicationStatus, jsonSchema } = accessRecord; accessRecord.readOnly = this.dataRequestService.getApplicationIsReadOnly(userType, applicationStatus); - // 6. Count unsubmitted amendments - const countUnsubmittedAmendments = this.amendmentService.countUnsubmittedAmendments(accessRecord, userType); + + // 6. Count amendments for selected version + const countAmendments = this.amendmentService.countAmendments(accessRecord, userType, versionAmendmentIterationIndex); + + // 7. If the application is the latest minor version... + if(isLatestMinorVersion) { + + } + + // 7. Set the review mode if user is a custodian reviewing the current step let { inReviewMode, reviewSections, hasRecommended } = this.workflowService.getReviewStatus(accessRecord, requestingUserObjectId); // 8. Get the workflow/voting status @@ -117,17 +128,20 @@ export default class DataRequestController extends Controller { workflow.canOverrideStep = !workflow.isCompleted && isManager; } } + + + // 10. Update json schema and question answers with modifications since original submission accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); - // 11. Determine the current active party handling the form - let activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); + // 11. Determine the current active party handling the form, this may be undefined if the requested version is not the latest + const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); // 12. Append question actions depending on user type and application status let userRole = userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( - accessRecord.jsonSchema, + jsonSchema, userType, - accessRecord.applicationStatus, + applicationStatus, userRole, activeParty ); @@ -137,7 +151,7 @@ export default class DataRequestController extends Controller { data: { ...accessRecord, datasets: accessRecord.datasets, - ...countUnsubmittedAmendments, + ...countAmendments, userType, activeParty, projectId: accessRecord.projectId || helper.generateFriendlyId(accessRecord._id), diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index 8e608473..1b48853a 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -24,9 +24,14 @@ export default class DataRequestRepository extends Repository { }) .populate([ { path: 'mainApplicant', select: 'firstname lastname -id' }, + { + path: 'publisherObj', + populate: { + path: 'team', + }, + }, { path: 'datasets dataset authors', - populate: { path: 'publisher', populate: { path: 'team' } }, }, { path: 'workflow.steps.reviewers', select: 'firstname lastname' }, { path: 'files.owner', select: 'firstname lastname' }, diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 21399a24..1af0e4fc 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -25,20 +25,40 @@ export default class DataRequestService { return readOnly; } + getVersionDetails(accessRecord, requestedVersion) { + let isLatestVersion = true; + const { version: majorVersion } = accessRecord; + + let [fullMatch, requestedMajorVersion, requestedMinorVersion] = requestedVersion.match(/^(\d+)\.?(\d+)$/); + //let [ fullMatch, requestedMajorVersion, requestedMinorVersion ] = regexResult; + + if (!fullMatch || majorVersion.toString() !== requestedMajorVersion.toString()) { + return { isValidVersion: false }; + } + + if (requestedMinorVersion) { + // Convert minor version to index for amendment iterations + console.log('yes'); + return { isValidVersion: true }; + } + + return { isLatestVersion, isValidVersion }; + } + getProjectName(accessRecord) { // Retrieve project name from about application section - const { - aboutApplication: { projectName } = {}, - } = accessRecord; + const { aboutApplication: { projectName } = {} } = accessRecord; if (projectName) { return projectName; - } else { + } else if (accessRecord.datasets.length > 0) { // Build default project name from publisher and dataset name const { datasetfields: { publisher }, name, } = accessRecord.datasets[0]; return `${publisher} - ${name}`; + } else { + return 'No project name'; } } From ef213b588d79f0bb4d9b6473dd17c1bb24f4b389 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 13 May 2021 10:59:08 +0100 Subject: [PATCH 09/81] Continued build --- src/resources/datarequest/datarequest.controller.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b0d299c0..3604d79a 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -108,6 +108,12 @@ export default class DataRequestController extends Controller { // 6. Count amendments for selected version const countAmendments = this.amendmentService.countAmendments(accessRecord, userType, versionAmendmentIterationIndex); + // 7. Determine the current active party handling the form, this may be undefined if the requested version is not the latest + const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord, versionAmendmentIterationIndex); + + + + // 7. If the application is the latest minor version... if(isLatestMinorVersion) { @@ -133,8 +139,7 @@ export default class DataRequestController extends Controller { // 10. Update json schema and question answers with modifications since original submission accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); - // 11. Determine the current active party handling the form, this may be undefined if the requested version is not the latest - const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); + // 12. Append question actions depending on user type and application status let userRole = userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; From 2af435570a96de4dbf5e933dc806a0ea15ed8c73 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 13 May 2021 11:19:45 +0100 Subject: [PATCH 10/81] Continued build --- .../datarequest/datarequest.controller.js | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 3604d79a..0a200ce9 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -111,29 +111,28 @@ export default class DataRequestController extends Controller { // 7. Determine the current active party handling the form, this may be undefined if the requested version is not the latest const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord, versionAmendmentIterationIndex); + // 8. Get the workflow status for the requested application version for the requesting user + let { inReviewMode, reviewSections, hasRecommended, isManager, workflow } = this.workflowService.getApplicationWorkflowStatusForUser(accessRecord, requestingUserObjectId, isLatestMinorVersion); - // 7. If the application is the latest minor version... - if(isLatestMinorVersion) { - - } + // 7. Set the review mode if user is a custodian reviewing the current step - let { inReviewMode, reviewSections, hasRecommended } = this.workflowService.getReviewStatus(accessRecord, requestingUserObjectId); + //let { inReviewMode, reviewSections, hasRecommended } = this.workflowService.getReviewStatus(accessRecord, requestingUserObjectId); // 8. Get the workflow/voting status - let workflow = this.workflowService.getWorkflowStatus(accessRecord); - let isManager = false; + //let workflow = this.workflowService.getWorkflowStatus(accessRecord); + //let isManager = false; // 9. Check if the current user can override the current step - if (_.has(accessRecord.datasets[0], 'publisher.team')) { - const { team } = accessRecord.datasets[0].publisher; - isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserObjectId); - // Set the workflow override capability if there is an active step and user is a manager - if (!_.isEmpty(workflow)) { - workflow.canOverrideStep = !workflow.isCompleted && isManager; - } - } + // if (_.has(accessRecord.datasets[0], 'publisher.team')) { + // const { team } = accessRecord.datasets[0].publisher; + // isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserObjectId); + // // Set the workflow override capability if there is an active step and user is a manager + // if (!_.isEmpty(workflow)) { + // workflow.canOverrideStep = !workflow.isCompleted && isManager; + // } + // } From 84b896921133938dbd5cbbb0b4a6fdc5dc71cd59 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 13 May 2021 16:10:50 +0100 Subject: [PATCH 11/81] Continued build --- .../amendment/amendment.controller.js | 8 ++- .../amendment/amendment.service.js | 62 ++++++++++++------ .../datarequest/datarequest.controller.js | 64 ++++++++----------- .../datarequest/datarequest.service.js | 5 +- .../datarequest/utils/datarequest.util.js | 5 +- src/resources/utilities/constants.util.js | 53 +++++++++------ src/resources/workflow/workflow.service.js | 20 +++++- 7 files changed, 131 insertions(+), 86 deletions(-) diff --git a/src/resources/datarequest/amendment/amendment.controller.js b/src/resources/datarequest/amendment/amendment.controller.js index e7979135..29e924c3 100644 --- a/src/resources/datarequest/amendment/amendment.controller.js +++ b/src/resources/datarequest/amendment/amendment.controller.js @@ -134,7 +134,8 @@ export default class AmendmentController extends Controller { } }); } catch (err) { - console.error(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); return res.status(500).json({ success: false, message: 'An error occurred updating the application amendment', @@ -221,14 +222,15 @@ export default class AmendmentController extends Controller { return res.status(500).json({ status: 'error', message: err.message }); } else { // 10. Send update request notifications - createNotifications(constants.notificationTypes.RETURNED, accessRecord); + this.amendmentService.createNotifications(constants.notificationTypes.RETURNED, accessRecord); return res.status(200).json({ success: true, }); } }); } catch (err) { - console.error(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); return res.status(500).json({ success: false, message: 'An error occurred attempting to submit the requested updates', diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index bac523ad..7a6bbeba 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -163,17 +163,21 @@ export default class AmendmentService { }); } - getAmendmentIterationParty (accessRecord) { - // 1. Look for an amendment iteration that is in flight - // An empty date submitted with populated date returned indicates that the current correction iteration is now with the applicants - let index = accessRecord.amendmentIterations.findIndex(v => _.isUndefined(v.dateSubmitted) && !_.isUndefined(v.dateReturned)); - // 2. Deduce the user type from the current iteration state - if (index === -1) { - return constants.userTypes.CUSTODIAN; + getAmendmentIterationParty(accessRecord, versionAmendmentIterationIndex = -1) { + if (versionAmendmentIterationIndex === -1) { + // 1. Look for an amendment iteration that is in flight + // An empty date submitted with populated date returned indicates that the current correction iteration is now with the applicants + let index = accessRecord.amendmentIterations.findIndex(v => _.isUndefined(v.dateSubmitted) && !_.isUndefined(v.dateReturned)); + // 2. Deduce the user type from the current iteration state + if (index === -1) { + return constants.userTypes.CUSTODIAN; + } else { + return constants.userTypes.APPLICANT; + } } else { - return constants.userTypes.APPLICANT; + return getAmendmentIterationPartyByVersion(accessRecord, versionAmendmentIterationIndex); } - }; + } getAmendmentIterationPartyByVersion(accessRecord, versionAmendmentIterationIndex) { // If a specific version has been requested, determine the last party active on that version @@ -191,12 +195,19 @@ export default class AmendmentService { } } - filterAmendments(accessRecord = {}, userType) { + filterAmendments(accessRecord = {}, userType, lastIterationIndex = -1) { + // 1. Guard for invalid access record if (_.isEmpty(accessRecord)) { return {}; } let { amendmentIterations = [] } = accessRecord; - // 1. Extract all relevant iteration objects and answers based on the user type + + // 2. Slice any superfluous amendment iterations if a previous version has been explicitly requested + if(lastIterationIndex !== -1) { + amendmentIterations = amendmentIterations.slice(0, lastIterationIndex); + } + + // 3. Extract all relevant iteration objects and answers based on the user type // Applicant should only see requested amendments that have been returned by the custodian if (userType === constants.userTypes.APPLICANT) { amendmentIterations = [...amendmentIterations].filter(iteration => { @@ -211,19 +222,26 @@ export default class AmendmentService { return iteration; }); } - // 2. Return relevant iterations + + // 4. Return relevant iterations return amendmentIterations; } - injectAmendments(accessRecord, userType, user) { + injectAmendments(accessRecord, userType, user, versionAmendmentIterationIndex = -1) { // 1. Get latest iteration created by Custodian if (accessRecord.amendmentIterations.length === 0) { return accessRecord; } - const lastIndex = _.findLastIndex(accessRecord.amendmentIterations); + + // 2. If a specific version has not be requested, fetch the latest (last) amendment iteration to include all changes to date + const lastIndex = + versionAmendmentIterationIndex === -1 ? _.findLastIndex(accessRecord.amendmentIterations) : versionAmendmentIterationIndex; + + // 3. Get the corresponding iteration/version for the required index let latestIteration = accessRecord.amendmentIterations[lastIndex]; const { dateReturned } = latestIteration; - // 2. Applicants should see previous amendment iteration requests until current iteration has been returned with new requests + + // 4. Applicants should see previous amendment iteration requests until current iteration has been returned with new requests if ( (lastIndex > 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) || (userType === constants.userTypes.CUSTODIAN && _.isNil(latestIteration.questionAnswers)) @@ -232,16 +250,20 @@ export default class AmendmentService { } else if (lastIndex === 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) { return accessRecord; } - // 3. Update schema if there is a new iteration + + // 5. Update schema if there is a new iteration const { publisher = 'Custodian' } = accessRecord; if (!_.isNil(latestIteration)) { accessRecord.jsonSchema = this.formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher); } - // 4. Filter out amendments that have not yet been exposed to the opposite party - let amendmentIterations = this.filterAmendments(accessRecord, userType); - // 5. Update the question answers to reflect all the changes that have been made in later iterations + + // 6. Filter out amendments that have not yet been exposed to the opposite party + const amendmentIterations = this.filterAmendments(accessRecord, userType, lastIndex); + + // 7. Update the question answers to reflect all the changes that have been made in later iterations accessRecord.questionAnswers = this.formatQuestionAnswers(accessRecord.questionAnswers, amendmentIterations); - // 6. Return the updated access record + + // 8. Return the updated access record return accessRecord; } diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 0a200ce9..2fca2491 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -83,15 +83,24 @@ export default class DataRequestController extends Controller { params: { id }, } = req; const { version: requestedVersion } = req.query; + const requestingUser = req.user; const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; + // 2. Find the matching record and include attached datasets records with publisher details let accessRecord = await this.dataRequestService.getApplicationById(id); - const { isValidVersion, isLatestMinorVersion, versionActiveParty, versionAmendmentIterationIndex } = this.dataRequestService.getVersionDetails(accessRecord, requestedVersion); + const { + isValidVersion, + isLatestMinorVersion, + versionActiveParty, + versionAmendmentIterationIndex, + } = this.dataRequestService.getVersionDetails(accessRecord, requestedVersion); + // 3. If no matching application found, return 404 if (!accessRecord || !isValidVersion) { return res.status(404).json({ status: 'error', message: 'The application or the requested version could not be found.' }); } + // 4. Check if requesting user is custodian member or applicant/contributor const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( accessRecord, @@ -101,6 +110,7 @@ export default class DataRequestController extends Controller { if (!authorised || versionActiveParty !== userType) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } + // 5. Set edit mode for applicants who have not yet submitted const { applicationStatus, jsonSchema } = accessRecord; accessRecord.readOnly = this.dataRequestService.getApplicationIsReadOnly(userType, applicationStatus); @@ -112,44 +122,25 @@ export default class DataRequestController extends Controller { const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord, versionAmendmentIterationIndex); // 8. Get the workflow status for the requested application version for the requesting user - let { inReviewMode, reviewSections, hasRecommended, isManager, workflow } = this.workflowService.getApplicationWorkflowStatusForUser(accessRecord, requestingUserObjectId, isLatestMinorVersion); - - - + const { + inReviewMode, + reviewSections, + hasRecommended, + isManager, + workflow, + } = this.workflowService.getApplicationWorkflowStatusForUser(accessRecord, requestingUserObjectId); + + // 9. Get role type for requesting user, applicable for only Custodian users i.e. Manager/Reviewer role + const userRole = + userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; - + // 10. Update json schema and question answers with modifications since original submission up to requested version + accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionAmendmentIterationIndex); - // 7. Set the review mode if user is a custodian reviewing the current step - //let { inReviewMode, reviewSections, hasRecommended } = this.workflowService.getReviewStatus(accessRecord, requestingUserObjectId); - // 8. Get the workflow/voting status - //let workflow = this.workflowService.getWorkflowStatus(accessRecord); - //let isManager = false; - // 9. Check if the current user can override the current step - // if (_.has(accessRecord.datasets[0], 'publisher.team')) { - // const { team } = accessRecord.datasets[0].publisher; - // isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserObjectId); - // // Set the workflow override capability if there is an active step and user is a manager - // if (!_.isEmpty(workflow)) { - // workflow.canOverrideStep = !workflow.isCompleted && isManager; - // } - // } + // 11. Append question actions depending on user type and application status + accessRecord.jsonSchema = datarequestUtil.injectQuestionActions(jsonSchema, userType, applicationStatus, userRole, activeParty, isLatestMinorVersion); - - - // 10. Update json schema and question answers with modifications since original submission - accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); - - // 12. Append question actions depending on user type and application status - let userRole = - userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; - accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( - jsonSchema, - userType, - applicationStatus, - userRole, - activeParty - ); - // 13. Return application form + // 12. Return application form return res.status(200).json({ status: 'success', data: { @@ -164,6 +155,7 @@ export default class DataRequestController extends Controller { hasRecommended, workflow, files: accessRecord.files || [], + isLatestMinorVersion, }, }); } catch (err) { diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 1af0e4fc..754bcb82 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -26,11 +26,10 @@ export default class DataRequestService { } getVersionDetails(accessRecord, requestedVersion) { - let isLatestVersion = true; + let isLatestMinorVersion = true; const { version: majorVersion } = accessRecord; let [fullMatch, requestedMajorVersion, requestedMinorVersion] = requestedVersion.match(/^(\d+)\.?(\d+)$/); - //let [ fullMatch, requestedMajorVersion, requestedMinorVersion ] = regexResult; if (!fullMatch || majorVersion.toString() !== requestedMajorVersion.toString()) { return { isValidVersion: false }; @@ -42,7 +41,7 @@ export default class DataRequestService { return { isValidVersion: true }; } - return { isLatestVersion, isValidVersion }; + return { isLatestMinorVersion, isValidVersion, versionActiveParty, versionAmendmentIterationIndex }; } getProjectName(accessRecord) { diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index 88e012d2..f16b686c 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -7,11 +7,12 @@ import dynamicForm from '../../utilities/dynamicForms/dynamicForm.util'; const repeatedSectionRegex = /_[a-zA-Z|\d]{5}$/gm; -const injectQuestionActions = (jsonSchema, userType, applicationStatus, role = '', activeParty) => { +const injectQuestionActions = (jsonSchema, userType, applicationStatus, role = '', activeParty, isLatestMinorVersion = true) => { let formattedSchema = {}; + const version = isLatestMinorVersion ? 'latestVersion' : 'previousVersion'; if (userType === constants.userTypes.CUSTODIAN) { if (applicationStatus === constants.applicationStatuses.INREVIEW) { - formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus][activeParty] }; + formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus][activeParty][version] }; } else { formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus]}; } diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index aa5271c6..1c42ac5c 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -116,22 +116,33 @@ const _userQuestionActions = { }, ], inReview: { - custodian: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'requestAmendment', - icon: 'fas fa-exclamation-circle', - color: '#F0BB24', - toolTip: 'Request applicant updates answer', - order: 2, - }, - ], + custodian: { + latestVersion: [ + { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + { + key: 'requestAmendment', + icon: 'fas fa-exclamation-circle', + color: '#F0BB24', + toolTip: 'Request applicant updates answer', + order: 2, + }, + ], + previousVersion: [ + { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + ], + }, applicant: [ { key: 'guidance', @@ -341,8 +352,8 @@ const _applicationTypes = { INITIAL: 'Initial', AMENDED: 'Amendment', EXTENDED: 'Extension', - RENEWAL: 'Renewal' -} + RENEWAL: 'Renewal', +}; const _amendmentModes = { ADDED: 'added', @@ -408,8 +419,8 @@ const _mailchimpSubscriptionStatuses = { const _logTypes = { SYSTEM: 'System', - USER: 'User' -} + USER: 'User', +}; export default { userTypes: _userTypes, @@ -434,5 +445,5 @@ export default { hdrukEmail: _hdrukEmail, mailchimpSubscriptionStatuses: _mailchimpSubscriptionStatuses, datatsetStatuses: _datatsetStatuses, - logTypes: _logTypes + logTypes: _logTypes, }; diff --git a/src/resources/workflow/workflow.service.js b/src/resources/workflow/workflow.service.js index 28add0c7..c444783a 100644 --- a/src/resources/workflow/workflow.service.js +++ b/src/resources/workflow/workflow.service.js @@ -11,6 +11,24 @@ export default class WorkflowService { this.workflowRepository = workflowRepository; } + getApplicationWorkflowStatusForUser(accessRecord, requestingUserObjectId) { + // Set the review mode if user is a custodian reviewing the current step + let { inReviewMode, reviewSections, hasRecommended } = this.getReviewStatus(accessRecord, requestingUserObjectId); + // Get the workflow/voting status + let workflow = this.getWorkflowStatus(accessRecord); + let isManager = false; + // Check if the current user can override the current step + if (_.has(accessRecord, 'publisherObj.team')) { + const { team } = accessRecord.publisherObj; + isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserObjectId); + // Set the workflow override capability if there is an active step and user is a manager + if (!_.isEmpty(workflow)) { + workflow.canOverrideStep = !workflow.isCompleted && isManager; + } + } + return { inReviewMode, reviewSections, hasRecommended, isManager, workflow }; + } + async getWorkflowsByPublisher(id) { const workflows = await this.workflowRepository.getWorkflowsByPublisher(id); @@ -27,7 +45,7 @@ export default class WorkflowService { appCount: applications.length, canDelete: applications.length === 0, canEdit: applications.length === 0, - publisher + publisher, }; }); From 4acac7d0ef3163deeb6fa41b5bcdc07ac8ecf293 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 14 May 2021 11:34:50 +0100 Subject: [PATCH 12/81] Continued build for versioning --- .../amendment/amendment.service.js | 21 ++++++++--- .../datarequest/datarequest.controller.js | 36 +++++++++++-------- .../datarequest/datarequest.service.js | 33 +++++++++++------ src/resources/workflow/workflow.service.js | 4 +-- 4 files changed, 62 insertions(+), 32 deletions(-) diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index 7a6bbeba..a22acfee 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -169,7 +169,7 @@ export default class AmendmentService { // An empty date submitted with populated date returned indicates that the current correction iteration is now with the applicants let index = accessRecord.amendmentIterations.findIndex(v => _.isUndefined(v.dateSubmitted) && !_.isUndefined(v.dateReturned)); // 2. Deduce the user type from the current iteration state - if (index === -1) { + if (index === -1 && accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { return constants.userTypes.CUSTODIAN; } else { return constants.userTypes.APPLICANT; @@ -195,6 +195,21 @@ export default class AmendmentService { } } + getAmendmentIterationDetailsByVersion(accessRecord, majorVersion, minorVersion) { + const { amendmentIterations = [] } = accessRecord; + // Get amendment iteration index, initial version will be offset by 1 to find array index i.e. 1.0 = -1, 1.1 = 0, 1.2 = 1 etc. + // versions beyond 1 will have matching offset to array index as 2.0 includes amendments on first submission i.e. 2.0 = 0, 2.1 = 1, 2.2 = 2 etc. + const versionAmendmentIterationIndex = majorVersion === 1 ? minorVersion - 1 : minorVersion; + + // Get active party for selected index + const activeParty = this.getAmendmentIterationParty(accessRecord, versionAmendmentIterationIndex); + + // Check if selected version is latest + const isLatestMinorVersion = amendmentIterations[versionAmendmentIterationIndex] === _.last(amendmentIterations); + + return { versionAmendmentIterationIndex, activeParty, isLatestMinorVersion }; + } + filterAmendments(accessRecord = {}, userType, lastIterationIndex = -1) { // 1. Guard for invalid access record if (_.isEmpty(accessRecord)) { @@ -203,7 +218,7 @@ export default class AmendmentService { let { amendmentIterations = [] } = accessRecord; // 2. Slice any superfluous amendment iterations if a previous version has been explicitly requested - if(lastIterationIndex !== -1) { + if (lastIterationIndex !== -1) { amendmentIterations = amendmentIterations.slice(0, lastIterationIndex); } @@ -494,8 +509,6 @@ export default class AmendmentService { return { unansweredAmendments, answeredAmendments }; } - countAmendments(accessRecord, userType, versionAmendmentIterationIndex) {} - revertAmendmentAnswer(accessRecord, questionId, user) { // 1. Locate the latest amendment iteration let index = this.getLatestAmendmentIterationIndex(accessRecord); diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 2fca2491..b239325d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -89,38 +89,37 @@ export default class DataRequestController extends Controller { // 2. Find the matching record and include attached datasets records with publisher details let accessRecord = await this.dataRequestService.getApplicationById(id); - const { - isValidVersion, - isLatestMinorVersion, - versionActiveParty, - versionAmendmentIterationIndex, - } = this.dataRequestService.getVersionDetails(accessRecord, requestedVersion); - // 3. If no matching application found, return 404 + // 3. If no matching application found or invalid version requested, return 404 + const { isValidVersion, requestedMajorVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion(accessRecord, requestedVersion); if (!accessRecord || !isValidVersion) { return res.status(404).json({ status: 'error', message: 'The application or the requested version could not be found.' }); } - // 4. Check if requesting user is custodian member or applicant/contributor + // 4. Get requested amendment iteration details + const { + versionAmendmentIterationIndex, + activeParty, + isLatestMinorVersion, + } = this.amendmentService.getAmendmentIterationDetailsByVersion(accessRecord, requestedMajorVersion, requestedMinorVersion); + + // 5. Check if requesting user is custodian member or applicant/contributor const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( accessRecord, requestingUserId, requestingUserObjectId ); - if (!authorised || versionActiveParty !== userType) { + if (!authorised || activeParty !== userType) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - // 5. Set edit mode for applicants who have not yet submitted + // 6. Set edit mode for applicants who have not yet submitted const { applicationStatus, jsonSchema } = accessRecord; accessRecord.readOnly = this.dataRequestService.getApplicationIsReadOnly(userType, applicationStatus); - // 6. Count amendments for selected version + // 7. Count amendments for selected version const countAmendments = this.amendmentService.countAmendments(accessRecord, userType, versionAmendmentIterationIndex); - // 7. Determine the current active party handling the form, this may be undefined if the requested version is not the latest - const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord, versionAmendmentIterationIndex); - // 8. Get the workflow status for the requested application version for the requesting user const { inReviewMode, @@ -138,7 +137,14 @@ export default class DataRequestController extends Controller { accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionAmendmentIterationIndex); // 11. Append question actions depending on user type and application status - accessRecord.jsonSchema = datarequestUtil.injectQuestionActions(jsonSchema, userType, applicationStatus, userRole, activeParty, isLatestMinorVersion); + accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( + jsonSchema, + userType, + applicationStatus, + userRole, + activeParty, + isLatestMinorVersion + ); // 12. Return application form return res.status(200).json({ diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 754bcb82..3fa72585 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -4,6 +4,8 @@ import moment from 'moment'; import datarequestUtil from '../datarequest/utils/datarequest.util'; import constants from '../utilities/constants.util'; +import { amendmentService } from '../datarequest/amendment/dependency'; + export default class DataRequestService { constructor(dataRequestRepository) { this.dataRequestRepository = dataRequestRepository; @@ -25,23 +27,32 @@ export default class DataRequestService { return readOnly; } - getVersionDetails(accessRecord, requestedVersion) { - let isLatestMinorVersion = true; - const { version: majorVersion } = accessRecord; + validateRequestedVersion(accessRecord, requestedVersion) { + let isValidVersion = true; + + // 1. Return base major version for specified access record if no specific version requested + if (!requestedVersion && accessRecord) { + return { isValidVersion, requestedMajorVersion: accessRecord.version, requestedMinorVersion: 0 }; + } + // 2. Regex to validate and process the requested application version let [fullMatch, requestedMajorVersion, requestedMinorVersion] = requestedVersion.match(/^(\d+)\.?(\d+)$/); - if (!fullMatch || majorVersion.toString() !== requestedMajorVersion.toString()) { - return { isValidVersion: false }; - } + // 3. Catch invalid version requests + try { + let { version: majorVersion, amendmentIterations = [] } = accessRecord; + majorVersion = parseInt(majorVersion); + requestedMajorVersion = parseInt(requestedMajorVersion); + requestedMinorVersion = parseInt(requestedMinorVersion); - if (requestedMinorVersion) { - // Convert minor version to index for amendment iterations - console.log('yes'); - return { isValidVersion: true }; + if (!fullMatch || majorVersion !== requestedMajorVersion || requestedMinorVersion > amendmentIterations.length) { + isValidVersion = false; + } + } catch { + isValidVersion = false; } - return { isLatestMinorVersion, isValidVersion, versionActiveParty, versionAmendmentIterationIndex }; + return { isValidVersion, requestedMajorVersion, requestedMinorVersion }; } getProjectName(accessRecord) { diff --git a/src/resources/workflow/workflow.service.js b/src/resources/workflow/workflow.service.js index c444783a..9fc90f7d 100644 --- a/src/resources/workflow/workflow.service.js +++ b/src/resources/workflow/workflow.service.js @@ -18,11 +18,11 @@ export default class WorkflowService { let workflow = this.getWorkflowStatus(accessRecord); let isManager = false; // Check if the current user can override the current step - if (_.has(accessRecord, 'publisherObj.team')) { + if (has(accessRecord, 'publisherObj.team')) { const { team } = accessRecord.publisherObj; isManager = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserObjectId); // 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; } } From e06df346a56cba07bee56ca82371e9879f8dd705 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 14 May 2021 12:09:21 +0100 Subject: [PATCH 13/81] Continued build --- migrations/1620661052855-example-migration2.js | 15 --------------- .../datarequest/amendment/amendment.service.js | 2 +- src/resources/datarequest/datarequest.service.js | 6 ++++-- 3 files changed, 5 insertions(+), 18 deletions(-) delete mode 100644 migrations/1620661052855-example-migration2.js diff --git a/migrations/1620661052855-example-migration2.js b/migrations/1620661052855-example-migration2.js deleted file mode 100644 index 5f3bd684..00000000 --- a/migrations/1620661052855-example-migration2.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Make any changes you need to make to the database here - */ -async function up () { - // Write migration here -} - -/** - * Make any changes that UNDO the up function side effects here (if possible) - */ -async function down () { - // Write migration here -} - -module.exports = { up, down }; diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index a22acfee..c0b065c1 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -175,7 +175,7 @@ export default class AmendmentService { return constants.userTypes.APPLICANT; } } else { - return getAmendmentIterationPartyByVersion(accessRecord, versionAmendmentIterationIndex); + return this.getAmendmentIterationPartyByVersion(accessRecord, versionAmendmentIterationIndex); } } diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 3fa72585..16e6f110 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -29,14 +29,16 @@ export default class DataRequestService { validateRequestedVersion(accessRecord, requestedVersion) { let isValidVersion = true; - + // 1. Return base major version for specified access record if no specific version requested if (!requestedVersion && accessRecord) { return { isValidVersion, requestedMajorVersion: accessRecord.version, requestedMinorVersion: 0 }; } // 2. Regex to validate and process the requested application version - let [fullMatch, requestedMajorVersion, requestedMinorVersion] = requestedVersion.match(/^(\d+)\.?(\d+)$/); + let fullMatch, requestedMajorVersion, requestedMinorVersion; + const regexMatch = requestedVersion.match(/^(\d+)\.?(\d+)$/); + if(regexMatch) [fullMatch, requestedMajorVersion, requestedMinorVersion] = regexMatch; // 3. Catch invalid version requests try { From 1eb1652ac5c494d86dc29e40154ab0647ed51f39 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 14 May 2021 14:01:48 +0100 Subject: [PATCH 14/81] Continued build --- src/resources/datarequest/datarequest.service.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 16e6f110..cd01bee6 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -29,23 +29,27 @@ export default class DataRequestService { validateRequestedVersion(accessRecord, requestedVersion) { let isValidVersion = true; - + // 1. Return base major version for specified access record if no specific version requested if (!requestedVersion && accessRecord) { return { isValidVersion, requestedMajorVersion: accessRecord.version, requestedMinorVersion: 0 }; } - // 2. Regex to validate and process the requested application version + // 2. Regex to validate and process the requested application version (e.g. 1, 2, 1.0, 1.1, 2.1, 3.11) let fullMatch, requestedMajorVersion, requestedMinorVersion; - const regexMatch = requestedVersion.match(/^(\d+)\.?(\d+)$/); - if(regexMatch) [fullMatch, requestedMajorVersion, requestedMinorVersion] = regexMatch; + const regexMatch = requestedVersion.match(/^(\d+)$|^(\d+)\.?(\d+)$/); + if (regexMatch) { + fullMatch = regexMatch[0]; + requestedMajorVersion = regexMatch[1] || regexMatch[2]; + requestedMinorVersion = regexMatch[3] || regexMatch[2]; + } // 3. Catch invalid version requests try { let { version: majorVersion, amendmentIterations = [] } = accessRecord; majorVersion = parseInt(majorVersion); requestedMajorVersion = parseInt(requestedMajorVersion); - requestedMinorVersion = parseInt(requestedMinorVersion); + requestedMinorVersion = parseInt(requestedMinorVersion || 0); if (!fullMatch || majorVersion !== requestedMajorVersion || requestedMinorVersion > amendmentIterations.length) { isValidVersion = false; From 4eed8f7e5d063383068b8ba8921faefcaa61432c Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 14 May 2021 16:22:26 +0100 Subject: [PATCH 15/81] Continued versioning build --- .../1620558117918-applications_versioning.js | 6 +- .../datarequest/datarequest.controller.js | 206 +++++++----------- .../datarequest/datarequest.model.js | 2 +- .../datarequest/datarequest.repository.js | 23 ++ .../datarequest/datarequest.route.js | 22 +- .../datarequest/datarequest.service.js | 8 +- 6 files changed, 129 insertions(+), 138 deletions(-) diff --git a/migrations/1620558117918-applications_versioning.js b/migrations/1620558117918-applications_versioning.js index cf0bb320..0a09994d 100644 --- a/migrations/1620558117918-applications_versioning.js +++ b/migrations/1620558117918-applications_versioning.js @@ -19,7 +19,8 @@ async function up() { filter: { _id }, update: { applicationType: 'Initial', - version: 1, + majorVersion: 1, + version: undefined, versionTree, }, upsert: false, @@ -45,7 +46,8 @@ async function down() { filter: { _id }, update: { applicationType: undefined, - version: undefined, + majorVersion: undefined, + version: 1, versionTree: undefined, }, upsert: false, diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b239325d..7573354d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -87,11 +87,14 @@ export default class DataRequestController extends Controller { const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; - // 2. Find the matching record and include attached datasets records with publisher details + // 2. Find the matching record and include attached datasets records with publisher details and workflow details let accessRecord = await this.dataRequestService.getApplicationById(id); // 3. If no matching application found or invalid version requested, return 404 - const { isValidVersion, requestedMajorVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion(accessRecord, requestedVersion); + const { isValidVersion, requestedMajorVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( + accessRecord, + requestedVersion + ); if (!accessRecord || !isValidVersion) { return res.status(404).json({ status: 'error', message: 'The application or the requested version could not be found.' }); } @@ -173,6 +176,84 @@ export default class DataRequestController extends Controller { }); } } + + //POST api/v1/data-access-request/:id/clone + async cloneApplication(req, res) { + try { + // 1. Get the required request and body params + const { + params: { id }, + } = req; + const { datasetIds = [], datasetTitles = [], publisher = '', appIdToCloneInto = '' } = req.body; + + // 2. Retrieve DAR to clone from database + let appToClone = await this.dataRequestService.getApplicationToCloneById(id); + + if (!appToClone) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + + // 3. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToClone, req.user.id, req.user._id); + + // 4. Return unauthorised message if the requesting user is not an applicant + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 5. Update question answers with modifications since original submission + appToClone = this.amendmentService.injectAmendments(appToClone, constants.userTypes.APPLICANT, req.user); + + // 6. Set up new access record or load presubmission application as provided in request and save + let clonedAccessRecord = {}; + if (_.isEmpty(appIdToCloneInto)) { + clonedAccessRecord = await datarequestUtil.cloneIntoNewApplication(appToClone, { + userId: req.user.id, + datasetIds, + datasetTitles, + publisher, + }); + // Save new record + clonedAccessRecord = await DataRequestModel.create(clonedAccessRecord); + } else { + const appToCloneInto = await this.dataRequestService.getApplicationToCloneById(appIdToCloneInto); + // Ensure application to clone into was found + if (!appToCloneInto) { + return res.status(404).json({ status: 'error', message: 'Application to clone into not found.' }); + } + // Get permissions for application to clone into + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToCloneInto, req.user.id, req.user._id); + // Return unauthorised message if the requesting user is not authorised to the new application + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + clonedAccessRecord = await datarequestUtil.cloneIntoExistingApplication(appToClone, appToCloneInto); + + // Save into existing record + clonedAccessRecord = await DataRequestModel.findOneAndUpdate({ _id: appIdToCloneInto }, clonedAccessRecord, { new: true }); + } + // Create notifications + await module.exports.createNotifications( + constants.notificationTypes.APPLICATIONCLONED, + { newDatasetTitles: datasetTitles, newApplicationId: clonedAccessRecord._id.toString() }, + appToClone, + req.user + ); + + // Return successful response + return res.status(200).json({ + success: true, + accessRecord: clonedAccessRecord, + }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred cloning the existing application', + }); + } + } } module.exports = { @@ -1723,127 +1804,6 @@ module.exports = { } }, - //POST api/v1/data-access-request/:id/clone - cloneApplication: async (req, res) => { - try { - // 1. Get the required request and body params - const { - params: { id: appIdToClone }, - } = req; - const { datasetIds = [], datasetTitles = [], publisher = '', appIdToCloneInto = '' } = req.body; - - // 2. Retrieve DAR to clone from database - let appToClone = await DataRequestModel.findOne({ _id: appIdToClone }) - .populate([ - { - path: 'datasets dataset authors', - }, - { - path: 'mainApplicant', - }, - { - path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - ]) - .lean(); - if (!appToClone) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - - // 3. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToClone, req.user.id, req.user._id); - - // 4. Return unauthorised message if the requesting user is not an applicant - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - - // 5. Update question answers with modifications since original submission - appToClone = this.amendmentService.injectAmendments(appToClone, constants.userTypes.APPLICANT, req.user); - - // 6. Create callback function used to complete the save process - const saveCallBack = (err, doc) => { - if (err) { - console.error(err.message); - return res.status(500).json({ status: 'error', message: err.message }); - } - - // Create notifications - module.exports.createNotifications( - constants.notificationTypes.APPLICATIONCLONED, - { newDatasetTitles: datasetTitles, newApplicationId: doc._id.toString() }, - appToClone, - req.user - ); - - // Return successful response - return res.status(200).json({ - success: true, - accessRecord: doc, - }); - }; - - // 7. Set up new access record or load presubmission application as provided in request and save - let clonedAccessRecord = {}; - if (_.isEmpty(appIdToCloneInto)) { - clonedAccessRecord = await datarequestUtil.cloneIntoNewApplication(appToClone, { - userId: req.user.id, - datasetIds, - datasetTitles, - publisher, - }); - // Save new record - await DataRequestModel.create(clonedAccessRecord, saveCallBack); - } else { - let appToCloneInto = await DataRequestModel.findOne({ _id: appIdToCloneInto }) - .populate([ - { - path: 'datasets dataset authors', - }, - { - path: 'mainApplicant', - }, - { - path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - ]) - .lean(); - // Ensure application to clone into was found - if (!appToCloneInto) { - return res.status(404).json({ status: 'error', message: 'Application to clone into not found.' }); - } - // Get permissions for application to clone into - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToCloneInto, req.user.id, req.user._id); - // Return unauthorised message if the requesting user is not authorised to the new application - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - clonedAccessRecord = await datarequestUtil.cloneIntoExistingApplication(appToClone, appToCloneInto); - - // Save into existing record - await DataRequestModel.findOneAndUpdate({ _id: appIdToCloneInto }, clonedAccessRecord, { new: true }, saveCallBack); - } - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred cloning the existing application', - }); - } - }, - updateFileStatus: async (req, res) => { try { // 1. Get the required request params diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 11c953e4..ab6e50a2 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -5,7 +5,7 @@ import DataRequestClass from './datarequest.entity'; const DataRequestSchema = new Schema( { - version: { type: Number, default: 1}, + majorVersion: { type: Number, default: 1}, userId: Number, // Main applicant authorIds: [Number], dataSetId: String, diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index 1b48853a..a02ce105 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -32,10 +32,33 @@ export default class DataRequestRepository extends Repository { }, { path: 'datasets dataset authors', + populate: { path: 'publisher', populate: { path: 'team' } }, }, { path: 'workflow.steps.reviewers', select: 'firstname lastname' }, { path: 'files.owner', select: 'firstname lastname' }, ]) .lean(); } + + async getApplicationToCloneById(id) { + return DataRequestModel.findOne({ _id: id }) + .populate([ + { + path: 'datasets dataset authors', + }, + { + path: 'mainApplicant', + }, + { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users', + }, + }, + }, + ]) + .lean(); + } } diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 7ccfb56c..756f3903 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -45,6 +45,18 @@ router.get( (req, res) => dataRequestController.getAccessRequestById(req, res) ); +// @route POST api/v1/data-access-request/:id/clone +// @desc Clone an existing application forms answers into a new one potentially for a different custodian +// @access Private - Applicant +router.post( + '/:id/clone', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Cloning a Data Access Request application' }), + (req, res) => dataRequestController.cloneApplication(req, res) +); + + + // @route GET api/v1/data-access-request/dataset/:datasetId // @desc GET Access request for user // @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer @@ -199,16 +211,6 @@ router.post( datarequestController.performAction ); -// @route POST api/v1/data-access-request/:id/clone -// @desc Clone an existing application forms answers into a new one potentially for a different custodian -// @access Private - Applicant -router.post( - '/:id/clone', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Cloning a Data Access Request application' }), - datarequestController.cloneApplication -); - // @route POST api/v1/data-access-request/:id // @desc Submit request record // @access Private - Applicant (Gateway User) diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index cd01bee6..321b3157 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -19,6 +19,10 @@ export default class DataRequestService { return this.dataRequestRepository.getApplicationById(id); } + getApplicationToCloneById(id) { + return this.dataRequestRepository.getApplicationToCloneById(id); + } + getApplicationIsReadOnly(userType, applicationStatus) { let readOnly = true; if (userType === constants.userTypes.APPLICANT && applicationStatus === constants.applicationStatuses.INPROGRESS) { @@ -32,7 +36,7 @@ export default class DataRequestService { // 1. Return base major version for specified access record if no specific version requested if (!requestedVersion && accessRecord) { - return { isValidVersion, requestedMajorVersion: accessRecord.version, requestedMinorVersion: 0 }; + return { isValidVersion, requestedMajorVersion: accessRecord.majorVersion, requestedMinorVersion: 0 }; } // 2. Regex to validate and process the requested application version (e.g. 1, 2, 1.0, 1.1, 2.1, 3.11) @@ -46,7 +50,7 @@ export default class DataRequestService { // 3. Catch invalid version requests try { - let { version: majorVersion, amendmentIterations = [] } = accessRecord; + let { majorVersion, amendmentIterations = [] } = accessRecord; majorVersion = parseInt(majorVersion); requestedMajorVersion = parseInt(requestedMajorVersion); requestedMinorVersion = parseInt(requestedMinorVersion || 0); From e54a0c2e348c2f39b012db40961de33951cf23bc Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Sun, 16 May 2021 07:21:34 +0100 Subject: [PATCH 16/81] Continued versioning --- .../datarequest/datarequest.controller.js | 190 ++++++++---------- .../datarequest/datarequest.repository.js | 57 ++++-- .../datarequest/datarequest.route.js | 20 +- .../datarequest/datarequest.service.js | 4 + src/resources/publisher/publisher.service.js | 2 +- 5 files changed, 143 insertions(+), 130 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 7573354d..c4044f0d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -254,6 +254,91 @@ export default class DataRequestController extends Controller { }); } } + + //POST api/v1/data-access-request/:id + async submitAccessRequestById(req, res) { + try { + // 1. id is the _id object in mongoo.db not the generated id or dataset Id + let { + params: { id }, + } = req; + + // 2. Find the relevant data request application + let accessRecord = await this.dataRequestService.getApplicationToSubmitById(id); + + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + + // 3. Check user type and authentication to submit application + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord, req.user.id, req.user._id); + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) + if (_.isEmpty(accessRecord.datasets)) { + accessRecord.datasets = [accessRecord.dataset]; + } + + // 5. Perform either initial submission or resubmission depending on application status + if (accessRecord.applicationStatus === constants.applicationStatuses.INPROGRESS) { + accessRecord = module.exports.doInitialSubmission(accessRecord); + } else if ( + accessRecord.applicationStatus === constants.applicationStatuses.INREVIEW || + accessRecord.applicationStatus === constants.applicationStatuses.SUBMITTED + ) { + accessRecord = this.amendmentService.doResubmission(accessRecord.toObject(), req.user._id.toString()); + } + + // 6. Ensure a valid submission is taking place + if (_.isNil(accessRecord.submissionType)) { + return res.status(400).json({ + status: 'error', + message: 'Application cannot be submitted as it has reached a final decision status.', + }); + } + + // 7. Save changes to db + let savedAccessRecord = await DataRequestModel.replaceOne({ _id: id }, accessRecord); + + // 8. Send notifications and emails with amendments + savedAccessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); + await module.exports.createNotifications( + accessRecord.submissionType === constants.submissionTypes.INITIAL + ? constants.notificationTypes.SUBMITTED + : constants.notificationTypes.RESUBMITTED, + {}, + accessRecord, + req.user + ); + + // 9. Start workflow process in Camunda if publisher requires it and it is the first submission + if (savedAccessRecord.workflowEnabled && savedAccessRecord.submissionType === constants.submissionTypes.INITIAL) { + let { + publisherObj: { name: publisher }, + dateSubmitted, + } = accessRecord; + let bpmContext = { + dateSubmitted, + applicationStatus: constants.applicationStatuses.SUBMITTED, + publisher, + businessKey: id, + }; + bpmController.postStartPreReview(bpmContext); + } + + // 10. Return aplication and successful response + return res.status(200).json({ status: 'success', data: savedAccessRecord }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred submitting the application', + }); + } + } } module.exports = { @@ -1453,111 +1538,6 @@ module.exports = { } }, - //POST api/v1/data-access-request/:id - submitAccessRequestById: async (req, res) => { - try { - // 1. id is the _id object in mongoo.db not the generated id or dataset Id - let { - params: { id }, - } = req; - // 2. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ - { - path: 'datasets dataset', - populate: { - path: 'publisher', - populate: { - path: 'team', - populate: { - path: 'users', - populate: { - path: 'additionalInfo', - }, - }, - }, - }, - }, - { - path: 'mainApplicant authors', - populate: { - path: 'additionalInfo', - }, - }, - { - path: 'publisherObj', - }, - ]); - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - // 3. Check user type and authentication to submit application - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord, req.user.id, req.user._id); - if (!authorised) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) - if (_.isEmpty(accessRecord.datasets)) { - accessRecord.datasets = [accessRecord.dataset]; - } - // 5. Perform either initial submission or resubmission depending on application status - if (accessRecord.applicationStatus === constants.applicationStatuses.INPROGRESS) { - accessRecord = module.exports.doInitialSubmission(accessRecord); - } else if ( - accessRecord.applicationStatus === constants.applicationStatuses.INREVIEW || - accessRecord.applicationStatus === constants.applicationStatuses.SUBMITTED - ) { - accessRecord = this.amendmentService.doResubmission(accessRecord.toObject(), req.user._id.toString()); - } - // 6. Ensure a valid submission is taking place - if (_.isNil(accessRecord.submissionType)) { - return res.status(400).json({ - status: 'error', - message: 'Application cannot be submitted as it has reached a final decision status.', - }); - } - // 7. Save changes to db - await DataRequestModel.replaceOne({ _id: id }, accessRecord, async err => { - if (err) { - console.error(err.message); - return res.status(500).json({ - status: 'error', - message: 'An error occurred saving the changes', - }); - } else { - // 8. Send notifications and emails with amendments - accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); - await module.exports.createNotifications( - accessRecord.submissionType === constants.submissionTypes.INITIAL - ? constants.notificationTypes.SUBMITTED - : constants.notificationTypes.RESUBMITTED, - {}, - accessRecord, - req.user - ); - // 9. Start workflow process in Camunda if publisher requires it and it is the first submission - if (accessRecord.workflowEnabled && accessRecord.submissionType === constants.submissionTypes.INITIAL) { - let { - publisherObj: { name: publisher }, - dateSubmitted, - } = accessRecord; - let bpmContext = { - dateSubmitted, - applicationStatus: constants.applicationStatuses.SUBMITTED, - publisher, - businessKey: id, - }; - bpmController.postStartPreReview(bpmContext); - } - } - }); - // 10. Return aplication and successful response - return res.status(200).json({ status: 'success', data: accessRecord._doc }); - } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, - doInitialSubmission: accessRecord => { // 1. Update application to submitted status accessRecord.submissionType = constants.submissionTypes.INITIAL; diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index a02ce105..c42d0ae6 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -7,7 +7,7 @@ export default class DataRequestRepository extends Repository { this.dataRequestModel = DataRequestModel; } - async getAccessRequestsByUser(userId, query) { + getAccessRequestsByUser(userId, query) { if (!userId) return []; return DataRequestModel.find({ @@ -18,7 +18,7 @@ export default class DataRequestRepository extends Repository { .lean(); } - async getApplicationById(id) { + getApplicationById(id) { return DataRequestModel.findOne({ _id: id, }) @@ -40,25 +40,54 @@ export default class DataRequestRepository extends Repository { .lean(); } - async getApplicationToCloneById(id) { + getApplicationToCloneById(id) { return DataRequestModel.findOne({ _id: id }) - .populate([ + .populate([ + { + path: 'datasets dataset authors', + }, + { + path: 'mainApplicant', + }, + { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users', + }, + }, + }, + ]) + .lean(); + } + + getApplicationToSubmitById(id) { + return DataRequestModel.findOne({ _id: id }).populate([ { - path: 'datasets dataset authors', + path: 'datasets dataset', + populate: { + path: 'publisher', + populate: { + path: 'team', + populate: { + path: 'users', + populate: { + path: 'additionalInfo', + }, + }, + }, + }, }, { - path: 'mainApplicant', + path: 'mainApplicant authors', + populate: { + path: 'additionalInfo', + }, }, { path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, }, - ]) - .lean(); + ]); } } diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 756f3903..136b6d62 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -55,6 +55,16 @@ router.post( (req, res) => dataRequestController.cloneApplication(req, res) ); +// @route POST api/v1/data-access-request/:id +// @desc Submit request record +// @access Private - Applicant (Gateway User) +router.post( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Submitting a Data Access Request application' }), + (req, res) => dataRequestController.submitAccessRequestById(req, res) +); + // @route GET api/v1/data-access-request/dataset/:datasetId @@ -211,16 +221,6 @@ router.post( datarequestController.performAction ); -// @route POST api/v1/data-access-request/:id -// @desc Submit request record -// @access Private - Applicant (Gateway User) -router.post( - '/:id', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Submitting a Data Access Request application' }), - 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 diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 321b3157..4d26573e 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -23,6 +23,10 @@ export default class DataRequestService { return this.dataRequestRepository.getApplicationToCloneById(id); } + getApplicationToSubmitById(id) { + return this.dataRequestRepository.getApplicationToSubmitById(id); + } + getApplicationIsReadOnly(userType, applicationStatus) { let readOnly = true; if (userType === constants.userTypes.APPLICANT && applicationStatus === constants.applicationStatuses.INPROGRESS) { diff --git a/src/resources/publisher/publisher.service.js b/src/resources/publisher/publisher.service.js index dd482911..4a6d7678 100644 --- a/src/resources/publisher/publisher.service.js +++ b/src/resources/publisher/publisher.service.js @@ -37,7 +37,7 @@ export default class PublisherService { async getPublisherDataAccessRequests(id, requestingUserId, isManager) { const excludedApplicationStatuses = ['inProgress']; if (!isManager) { - applicationStatus.push('submitted'); + excludedApplicationStatuses.push('submitted'); } const query = { publisher: id, applicationStatus: { $nin: excludedApplicationStatuses } }; From bbf1aa5c09b573cda8032e6098a3d4456cba7a16 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Sun, 16 May 2021 22:44:13 +0100 Subject: [PATCH 17/81] Continued build in v2 --- .../datarequest/datarequest.controller.js | 2715 ++++++++--------- .../datarequest/datarequest.repository.js | 84 +- .../datarequest/datarequest.route.js | 192 +- .../datarequest/datarequest.service.js | 140 +- src/resources/publisher/publisher.service.js | 4 +- src/resources/utilities/constants.util.js | 87 +- .../utilities/emailGenerator.util.js | 2 +- src/resources/workflow/workflow.repository.js | 76 +- src/resources/workflow/workflow.service.js | 29 +- 9 files changed, 1725 insertions(+), 1604 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index c4044f0d..1975c909 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1,5 +1,4 @@ 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 { UserModel } from '../user/user.model'; @@ -12,7 +11,7 @@ import emailGenerator from '../utilities/emailGenerator.util'; import helper from '../utilities/helper.util'; import dynamicForm from '../utilities/dynamicForms/dynamicForm.util'; import constants from '../utilities/constants.util'; -import { processFile, getFile, fileStatus } from '../utilities/cloudStorage.util'; +import { getFile, fileStatus } from '../utilities/cloudStorage.util'; import _ from 'lodash'; import inputSanitizer from '../utilities/inputSanitizer'; import Controller from '../base/controller'; @@ -89,14 +88,17 @@ export default class DataRequestController extends Controller { // 2. Find the matching record and include attached datasets records with publisher details and workflow details let accessRecord = await this.dataRequestService.getApplicationById(id); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'The application could not be found.' }); + } - // 3. If no matching application found or invalid version requested, return 404 + // 3. If invalid version requested, return 404 const { isValidVersion, requestedMajorVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( accessRecord, requestedVersion ); - if (!accessRecord || !isValidVersion) { - return res.status(404).json({ status: 'error', message: 'The application or the requested version could not be found.' }); + if (!isValidVersion) { + return res.status(404).json({ status: 'error', message: 'The requested application version could not be found.' }); } // 4. Get requested amendment iteration details @@ -112,7 +114,7 @@ export default class DataRequestController extends Controller { requestingUserId, requestingUserObjectId ); - if (!authorised || activeParty !== userType) { + if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } @@ -187,7 +189,7 @@ export default class DataRequestController extends Controller { const { datasetIds = [], datasetTitles = [], publisher = '', appIdToCloneInto = '' } = req.body; // 2. Retrieve DAR to clone from database - let appToClone = await this.dataRequestService.getApplicationToCloneById(id); + let appToClone = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true }); if (!appToClone) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); @@ -214,9 +216,11 @@ export default class DataRequestController extends Controller { publisher, }); // Save new record - clonedAccessRecord = await DataRequestModel.create(clonedAccessRecord); + clonedAccessRecord = await DataRequestModel.create(clonedAccessRecord).catch(err => { + logger.logError(err, logCategory); + }); } else { - const appToCloneInto = await this.dataRequestService.getApplicationToCloneById(appIdToCloneInto); + const appToCloneInto = await this.dataRequestService.getApplicationWithTeamById(appIdToCloneInto, { lean: true }); // Ensure application to clone into was found if (!appToCloneInto) { return res.status(404).json({ status: 'error', message: 'Application to clone into not found.' }); @@ -230,10 +234,14 @@ export default class DataRequestController extends Controller { clonedAccessRecord = await datarequestUtil.cloneIntoExistingApplication(appToClone, appToCloneInto); // Save into existing record - clonedAccessRecord = await DataRequestModel.findOneAndUpdate({ _id: appIdToCloneInto }, clonedAccessRecord, { new: true }); + clonedAccessRecord = await DataRequestModel.findOneAndUpdate({ _id: appIdToCloneInto }, clonedAccessRecord, { new: true }).catch( + err => { + logger.logError(err, logCategory); + } + ); } // Create notifications - await module.exports.createNotifications( + await this.createNotifications( constants.notificationTypes.APPLICATIONCLONED, { newDatasetTitles: datasetTitles, newApplicationId: clonedAccessRecord._id.toString() }, appToClone, @@ -283,7 +291,7 @@ export default class DataRequestController extends Controller { // 5. Perform either initial submission or resubmission depending on application status if (accessRecord.applicationStatus === constants.applicationStatuses.INPROGRESS) { - accessRecord = module.exports.doInitialSubmission(accessRecord); + accessRecord = this.dataRequestService.doInitialSubmission(accessRecord); } else if ( accessRecord.applicationStatus === constants.applicationStatuses.INREVIEW || accessRecord.applicationStatus === constants.applicationStatuses.SUBMITTED @@ -300,11 +308,13 @@ export default class DataRequestController extends Controller { } // 7. Save changes to db - let savedAccessRecord = await DataRequestModel.replaceOne({ _id: id }, accessRecord); - + let savedAccessRecord = await this.dataRequestService.replaceApplicationById(id, accessRecord).catch(err => { + logger.logError(err, logCategory); + }); + // 8. Send notifications and emails with amendments savedAccessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); - await module.exports.createNotifications( + await this.createNotifications( accessRecord.submissionType === constants.submissionTypes.INITIAL ? constants.notificationTypes.SUBMITTED : constants.notificationTypes.RESUBMITTED, @@ -339,229 +349,9 @@ export default class DataRequestController extends Controller { }); } } -} - -module.exports = { - //GET api/v1/data-access-request/dataset/:datasetId - getAccessRequestByUserAndDataset: async (req, res) => { - let accessRecord, dataset; - let formType = constants.formTypes.Extended5Safe; - let data = {}; - try { - // 1. Get dataSetId from params - let { - params: { dataSetId }, - } = req; - // 2. Get the userId - let { id: userId, firstname, lastname } = req.user; - // 3. Find the matching record - accessRecord = await DataRequestModel.findOne({ - dataSetId, - userId, - applicationStatus: constants.applicationStatuses.INPROGRESS, - }).populate({ - path: 'mainApplicant', - select: 'firstname lastname -id -_id', - }); - // 4. Get dataset - dataset = await ToolModel.findOne({ datasetid: dataSetId }).populate('publisher'); - // 5. If no record create it and pass back - if (!accessRecord) { - if (!dataset) { - return res.status(500).json({ status: 'error', message: 'No dataset available.' }); - } - let { - datasetfields: { publisher = '' }, - } = dataset; - // 1. GET the template from the custodian - const accessRequestTemplate = await DataRequestSchemaModel.findOne({ - $or: [{ dataSetId }, { publisher }, { dataSetId: 'default' }], - status: 'active', - }).sort({ createdAt: -1 }); - - if (!accessRequestTemplate) { - return res.status(400).json({ - status: 'error', - message: 'No Data Access request schema.', - }); - } - // 2. Build up the accessModel for the user - let { jsonSchema, version, _id: schemaId, isCloneable = false } = accessRequestTemplate; - // 3. check for the type of form [enquiry - 5safes] - if (schemaId.toString() === constants.enquiryFormId) formType = constants.formTypes.Enquiry; - - // 4. create new DataRequestModel - let record = new DataRequestModel({ - version, - userId, - dataSetId, - datasetIds: [dataSetId], - datasetTitles: [dataset.name], - isCloneable, - jsonSchema, - schemaId, - publisher, - questionAnswers: {}, - aboutApplication: {}, - applicationStatus: constants.applicationStatuses.INPROGRESS, - formType, - }); - // 5. save record - const newApplication = await record.save(); - newApplication.projectId = helper.generateFriendlyId(newApplication._id); - await newApplication.save(); - - // 6. return record - data = { - ...newApplication._doc, - mainApplicant: { firstname, lastname }, - }; - } else { - data = { ...accessRecord.toObject() }; - } - // 7. Append question actions depending on user type and application status - data.jsonSchema = datarequestUtil.injectQuestionActions( - data.jsonSchema, - constants.userTypes.APPLICANT, - data.applicationStatus, - null, - constants.userTypes.APPLICANT - ); - // 8. Return payload - return res.status(200).json({ - status: 'success', - data: { - ...data, - dataset, - projectId: data.projectId || helper.generateFriendlyId(data._id), - userType: constants.userTypes.APPLICANT, - activeParty: constants.userTypes.APPLICANT, - inReviewMode: false, - reviewSections: [], - files: data.files || [], - }, - }); - } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, - - //GET api/v1/data-access-request/datasets/:datasetIds - getAccessRequestByUserAndMultipleDatasets: async (req, res) => { - let accessRecord; - let formType = constants.formTypes.Extended5Safe; - let data = {}; - let datasets = []; - try { - // 1. Get datasetIds from params - let { - params: { datasetIds }, - } = req; - let arrDatasetIds = datasetIds.split(','); - // 2. Get the userId - let { id: userId, firstname, lastname } = req.user; - // 3. Find the matching record - accessRecord = await DataRequestModel.findOne({ - datasetIds: { $all: arrDatasetIds }, - userId, - applicationStatus: constants.applicationStatuses.INPROGRESS, - }) - .populate([ - { - path: 'mainApplicant', - select: 'firstname lastname -id -_id', - }, - { path: 'files.owner', select: 'firstname lastname' }, - ]) - .sort({ createdAt: 1 }); - // 4. Get datasets - datasets = await ToolModel.find({ - datasetid: { $in: arrDatasetIds }, - }).populate('publisher'); - const arrDatasetNames = datasets.map(dataset => dataset.name); - // 5. If no record create it and pass back - if (!accessRecord) { - if (_.isEmpty(datasets)) { - return res.status(500).json({ status: 'error', message: 'No datasets available.' }); - } - let { - datasetfields: { publisher = '' }, - } = datasets[0]; - - // 1. GET the template from the custodian or take the default (Cannot have dataset specific question sets for multiple datasets) - const accessRequestTemplate = await DataRequestSchemaModel.findOne({ - $or: [{ publisher }, { dataSetId: 'default' }], - status: 'active', - }).sort({ createdAt: -1 }); - // 2. Ensure a question set was found - if (!accessRequestTemplate) { - return res.status(400).json({ - status: 'error', - message: 'No Data Access request schema.', - }); - } - // 3. Build up the accessModel for the user - let { jsonSchema, version, _id: schemaId, isCloneable = false } = accessRequestTemplate; - // 4. Check form is enquiry - if (schemaId.toString() === constants.enquiryFormId) formType = constants.formTypes.Enquiry; - // 5. Create new DataRequestModel - let record = new DataRequestModel({ - version, - userId, - datasetIds: arrDatasetIds, - datasetTitles: arrDatasetNames, - isCloneable, - jsonSchema, - schemaId, - publisher, - questionAnswers: {}, - aboutApplication: {}, - applicationStatus: constants.applicationStatuses.INPROGRESS, - formType, - }); - // 6. save record - const newApplication = await record.save(); - newApplication.projectId = helper.generateFriendlyId(newApplication._id); - await newApplication.save(); - // 7. return record - data = { - ...newApplication._doc, - mainApplicant: { firstname, lastname }, - }; - } else { - data = { ...accessRecord.toObject() }; - } - // 8. Append question actions depending on user type and application status - data.jsonSchema = datarequestUtil.injectQuestionActions( - data.jsonSchema, - constants.userTypes.APPLICANT, - data.applicationStatus, - null, - constants.userTypes.APPLICANT - ); - // 9. Return payload - return res.status(200).json({ - status: 'success', - data: { - ...data, - datasets, - projectId: data.projectId || helper.generateFriendlyId(data._id), - userType: constants.userTypes.APPLICANT, - activeParty: constants.userTypes.APPLICANT, - inReviewMode: false, - reviewSections: [], - files: data.files || [], - }, - }); - } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, //PATCH api/v1/data-access-request/:id - updateAccessRequestDataElement: async (req, res) => { + async updateAccessRequestDataElement(req, res) { try { // 1. Id is the _id object in mongoo.db not the generated id or dataset Id const { @@ -569,20 +359,20 @@ module.exports = { body: data, } = req; // 2. Destructure body and update only specific fields by building a segregated non-user specified update object - let updateObj = module.exports.buildUpdateObject({ + let updateObj = this.dataRequestService.buildUpdateObject({ ...data, user: req.user, }); // 3. Find data request by _id to determine current status - let accessRecord = await DataRequestModel.findOne({ - _id: id, - }); + let accessRecord = await this.dataRequestService.getApplicationToUpdateById(id); // 4. Check access record if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Data Access Request not found.' }); } // 5. Update record object - accessRecord = await module.exports.updateApplication(accessRecord, updateObj); + accessRecord = await this.dataRequestService.updateApplication(accessRecord, updateObj).catch(err => { + logger.logError(err, logCategory); + }); const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = accessRecord; if (dirtySchema) { @@ -596,561 +386,110 @@ module.exports = { jsonSchema: dirtySchema ? accessRecord.jsonSchema : undefined, }); } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the application', + }); } - }, - - buildUpdateObject: data => { - let updateObj = {}; - let { aboutApplication, questionAnswers, updatedQuestionId, user, jsonSchema = '' } = data; - if (aboutApplication) { - const { datasetIds, datasetTitles } = aboutApplication.selectedDatasets.reduce( - (newObj, dataset) => { - newObj.datasetIds = [...newObj.datasetIds, dataset.datasetId]; - newObj.datasetTitles = [...newObj.datasetTitles, dataset.name]; - return newObj; - }, - { datasetIds: [], datasetTitles: [] } - ); + } - updateObj = { aboutApplication, datasetIds, datasetTitles }; - } - if (questionAnswers) { - updateObj = { ...updateObj, questionAnswers, updatedQuestionId, user }; - } + // API DELETE api/v1/data-access-request/:id + async deleteDraftAccessRequest(req, res) { + try { + // 1. Get the required request and body params + const { + params: { id: appIdToDelete }, + } = req; - if (!_.isEmpty(jsonSchema)) { - updateObj = { ...updateObj, jsonSchema }; - } + // 2. Retrieve DAR to clone from database + const appToDelete = await this.dataRequestService.getApplicationWithTeamById(appIdToDelete, { lean: true }); - return updateObj; - }, + // 3. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToDelete, req.user.id, req.user._id); - updateApplication: async (accessRecord, updateObj) => { - // 1. Extract properties - let { applicationStatus, _id } = accessRecord; - let { updatedQuestionId = '', user } = updateObj; - // 2. If application is in progress, update initial question answers - if (applicationStatus === constants.applicationStatuses.INPROGRESS) { - await DataRequestModel.findByIdAndUpdate(_id, updateObj, { new: true }, err => { - if (err) { - console.error(err.message); - throw err; - } - }); - // 3. Else if application has already been submitted make amendment - } else if ( - applicationStatus === constants.applicationStatuses.INREVIEW || - applicationStatus === constants.applicationStatuses.SUBMITTED - ) { - if (_.isNil(updateObj.questionAnswers)) { - return accessRecord; - } - let updatedAnswer = updateObj.questionAnswers[updatedQuestionId]; - accessRecord = this.amendmentService.handleApplicantAmendment(accessRecord.toObject(), updatedQuestionId, '', updatedAnswer, user); - await DataRequestModel.replaceOne({ _id }, accessRecord, err => { - if (err) { - console.error(err.message); - throw err; - } - }); - } - return accessRecord; - }, - - //PUT api/v1/data-access-request/:id - updateAccessRequestById: async (req, res) => { - try { - // 1. Id is the _id object in MongoDb not the generated id or dataset Id - const { - params: { id }, - } = req; - // 2. Get the userId - let { _id, id: userId } = req.user; - let applicationStatus = '', - 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', - populate: { - path: 'team', - populate: { - path: 'users', - populate: { - path: 'additionalInfo', - }, - }, - }, - }, - }, - { - path: 'publisherObj', - populate: { - path: 'team', - }, - }, - { - path: 'workflow.steps.reviewers', - select: 'id email', - }, - ]); - - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) - if (_.isEmpty(accessRecord.datasets)) { - accessRecord.datasets = [accessRecord.dataset]; + // 4. Return unauthorised message if the requesting user is not an applicant + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - // 5. Check if the user is permitted to perform update to application - let isDirty = false, - statusChange = false, - contributorChange = false; - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), userId, _id); - - if (!authorised) { - return res.status(401).json({ - status: 'error', - message: 'Unauthorised to perform this update.', + // 5. If application is not in progress, actions cannot be performed + if (appToDelete.applicationStatus !== constants.applicationStatuses.INPROGRESS) { + return res.status(400).json({ + success: false, + message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', }); } - let { authorIds: currentAuthors } = accessRecord; - let newAuthors = []; - - // 6. Extract new application status and desc to save updates - if (userType === constants.userTypes.CUSTODIAN) { - // Only a custodian manager can set the final status of an application - authorised = false; - let team = {}; - if (_.isNull(accessRecord.publisherObj)) { - ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); - } else { - ({ team = {} } = accessRecord.publisherObj.toObject()); - } - - if (!_.isEmpty(team)) { - authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, _id); - } - - if (!authorised) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - // Extract params from body - ({ applicationStatus, applicationStatusDesc } = req.body); - const finalStatuses = [ - constants.applicationStatuses.SUBMITTED, - constants.applicationStatuses.APPROVED, - constants.applicationStatuses.REJECTED, - constants.applicationStatuses.APPROVEDWITHCONDITIONS, - constants.applicationStatuses.WITHDRAWN, - ]; - if (applicationStatus) { - accessRecord.applicationStatus = applicationStatus; - - if (finalStatuses.includes(applicationStatus)) { - accessRecord.dateFinalStatus = new Date(); - } - isDirty = true; - statusChange = true; + // 6. Delete application + await this.dataRequestService.deleteApplicationById(appIdToDelete).catch(err => { + logger.logError(err, logCategory); + }); - // 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) { - accessRecord.applicationStatusDesc = inputSanitizer.removeNonBreakingSpaces(applicationStatusDesc); - isDirty = true; - } - // If applicant, allow update to contributors/authors - } else if (userType === constants.userTypes.APPLICANT) { - // Extract new contributor/author IDs - if (req.body.authorIds) { - ({ authorIds: newAuthors } = req.body); + // 7. Create notifications + await this.createNotifications(constants.notificationTypes.APPLICATIONDELETED, {}, appToDelete, req.user); - // Perform comparison between new and existing authors to determine if an update is required - if (newAuthors && !helper.arraysEqual(newAuthors, currentAuthors)) { - accessRecord.authorIds = newAuthors; - isDirty = true; - contributorChange = true; - } - } - } - // 7. If a change has been made, notify custodian and main applicant - if (isDirty) { - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - return res.status(500).json({ status: 'error', message: err.message }); - } else { - // If save has succeeded - send notifications - // Send notifications to added/removed contributors - if (contributorChange) { - await module.exports.createNotifications( - constants.notificationTypes.CONTRIBUTORCHANGE, - { newAuthors, currentAuthors }, - accessRecord, - req.user - ); - } - if (statusChange) { - // Send notifications to custodian team, main applicant and contributors regarding status change - await module.exports.createNotifications( - constants.notificationTypes.STATUSCHANGE, - { applicationStatus, applicationStatusDesc }, - accessRecord, - req.user - ); - // 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', - data: accessRecord._doc, + success: true, }); } catch (err) { - console.error(err.message); - res.status(500).json({ - status: 'error', - message: 'An error occurred updating the application status', + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred deleting the existing application', }); } - }, + } - //PUT api/v1/data-access-request/:id/assignworkflow - assignWorkflow: async (req, res) => { + //POST api/v1/data-access-request/:id/upload + async uploadFiles(req, res) { try { - // 1. Get the required request params + // 1. Get DAR ID 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', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - }); + const requestingUserObjectId = req.user._id; + // 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 this.dataRequestService.getApplicationWithTeamById(id, { lean: false }); 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(constants.roleTypes.MANAGER, team.toObject(), userId); - } - // 5. Refuse access if not authorised + // 5. Check if requesting user is custodian member or applicant/contributor + let { authorised } = datarequestUtil.getUserPermissionsForApplication(accessRecord, req.user.id, req.user._id); + // 6. Check authorisation 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', - }); + // 7. Check files + if (_.isEmpty(files)) { + return res.status(400).json({ status: 'error', message: 'No files to upload' }); } - // 8. Check application is in-review - let { applicationStatus } = accessRecord; - if (applicationStatus !== constants.applicationStatuses.INREVIEW) { - return res.status(400).json({ - success: false, - message: 'The application status must be set to in review to assign a workflow', + // 8. Upload files + const mediaFiles = await this.dataRequestService + .uploadFiles(accessRecord, files, descriptions, ids, requestingUserObjectId) + .catch(err => { + logger.logError(err, logCategory); }); - } - // 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.message); - return res.status(400).json({ - success: false, - message: err.message, - }); - } 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 bpmContext = { - businessKey: id, - dataRequestStatus: constants.applicationStatuses.INREVIEW, - dataRequestUserId: userId.toString(), - dataRequestPublisher, - dataRequestStepName: workflowObj.steps[0].stepName, - notifyReviewerSLA: this.workflowService.calculateStepDeadlineReminderDate(workflowObj.steps[0]), - reviewerList, - }; - bpmController.postStartStepReview(bpmContext); - // 14. Gather context for notifications - const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflowObj, 0); - // 15. Create notifications to reviewers of the step that has been completed - module.exports.createNotifications(constants.notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); - // 16. Create our notifications to the custodian team managers if assigned a workflow to a DAR application - module.exports.createNotifications(constants.notificationTypes.WORKFLOWASSIGNED, emailContext, accessRecord, req.user); - // 16. Return workflow payload - return res.status(200).json({ - success: true, - }); - } - }); + // 9. return response + return res.status(200).json({ status: 'success', mediaFiles }); } catch (err) { - console.error(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); return res.status(500).json({ success: false, - message: 'An error occurred assigning the workflow', - }); - } - }, - - //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(constants.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 !== constants.applicationStatuses.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 = constants.applicationStatuses.INREVIEW; - accessRecord.dateReviewStart = new Date(); - // 7. Save update to access record - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } 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.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, - - //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 } = datarequestUtil.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 = []; - // 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++) { - // get description information - let description = descriptionArray ? descriptions[i] : descriptions; - // get uniqueId - let generatedId = idArray ? ids[i] : ids; - // remove - from uuidV4 - let uniqueId = generatedId.replace(/-/gim, ''); - // send to db - const response = await processFile(files[i], id, uniqueId); - // deconstruct response - let { status } = response; - // setup fileArr for mongoo - let newFile = { - status: status.trim(), - description: description.trim(), - fileId: uniqueId, - 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}] - 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; + message: 'An error occurred uploading the file to the application', }); - // 10. return response - return res.status(200).json({ status: 'success', mediaFiles }); - } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); } - }, + } //GET api/v1/data-access-request/:id/file/:fileId/status - getFileStatus: async (req, res) => { + async getFileStatus(req, res) { try { // 1. get params const { @@ -1158,7 +497,7 @@ module.exports = { } = req; // 2. get AccessRecord - let accessRecord = await DataRequestModel.findOne({ _id: id }); + const accessRecord = await this.dataRequestService.getFilesForApplicationById(id); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } @@ -1170,33 +509,35 @@ module.exports = { // 4. Return successful response return res.status(200).json({ status: accessRecord.files[fileIndex].status }); } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred attempting to retrieve the status of an uploaded file', + }); } - }, + } //GET api/v1/data-access-request/:id/file/:fileId - getFile: async (req, res) => { + async getFile(req, res) { try { - // 1. get params + // 1. Get params const { params: { id, fileId }, } = req; - // 2. get AccessRecord - let accessRecord = await DataRequestModel.findOne({ _id: id }); + // 2. Get AccessRecord + const accessRecord = await this.dataRequestService.getFilesForApplicationById(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; + // 3. Find the file in the files array from db + const mediaFile = + accessRecord.files.find(file => { + let { fileId: dbFileId } = file; return dbFileId === fileId; }) || {}; - // 5. no file return + // 4. No file return if (_.isEmpty(mediaFile)) { return res.status(400).json({ status: 'error', @@ -1204,310 +545,278 @@ module.exports = { }); } // 6. get the name of the file - let { name, fileId: dbFileId } = mediaFile._doc; + let { name, fileId: dbFileId } = mediaFile; // 7. get the file await getFile(name, dbFileId, id); // 8. send file back to user return res.status(200).sendFile(`${process.env.TMPDIR}${id}/${dbFileId}_${name}`); } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred attempting to retrieve an uploaded file', + }); } - }, + } - //PUT api/v1/data-access-request/:id/vote - updateAccessRequestReviewVote: async (req, res) => { + //POST api/v1/data-access-request/:id/updatefilestatus + async updateFileStatus(req, res) { try { // 1. Get the required request params const { - params: { id }, + params: { id, fileId }, } = 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', - populate: { - path: 'users', - }, - }, - }, - { - path: 'workflow.steps.reviewers', - select: 'firstname lastname id email', - }, - { - path: 'datasets dataset', - }, - { - path: 'mainApplicant', - }, - ]); + + const { status } = req.body; + + // 2. Find the relevant data request application + const accessRecord = this.dataRequestService.getFilesForApplicationById(id); + 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(constants.roleTypes.REVIEWER, team.toObject(), userId); - } - // 4. Refuse access if not authorised - if (!authorised) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + + //3. Check the status is valid + if ( + status !== fileStatus.UPLOADED && + status !== fileStatus.SCANNED && + status !== fileStatus.ERROR && + status !== fileStatus.QUARANTINED + ) { + return res.status(400).json({ status: 'error', message: 'File status not valid' }); } - // 5. Check application is in-review - let { applicationStatus } = accessRecord; - if (applicationStatus !== constants.applicationStatuses.INREVIEW) { - return res.status(400).json({ - success: false, - message: 'The application status must be set to in review to cast a vote', - }); + + //4. Get the file + const fileIndex = accessRecord.files.findIndex(file => file.fileId === fileId); + if (fileIndex === -1) return res.status(404).json({ status: 'error', message: 'File not found.' }); + + //5. Update the status + accessRecord.files[fileIndex].status = status; + + //6. Write back into mongo + await accessRecord.save().catch(err => { + logger.logError(err, logCategory); + }); + + return res.status(200).json({ + success: true, + }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred attempting to update the status of an uploaded file', + }); + } + } + + //PUT api/v1/data-access-request/:id/deletefile + async updateAccessRequestDeleteFile(req, res) { + try { + const { + params: { id }, + } = req; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + + // 1. Id of the file to delete + const { fileId } = req.body; + + // 2. Find the relevant data request application + const accessRecord = await this.dataRequestService.getFilesForApplicationById(id, { lean: false }); + + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); } - // 6. Ensure a workflow has been attached to this application - let { workflow } = accessRecord; - if (!workflow) { + + // 3. If application is not in progress, actions cannot be performed + if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { return res.status(400).json({ success: false, - message: 'There is no workflow attached to this application in order to cast a vote', + message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', }); } - // 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.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', - }); - } - //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), - createdDate: new Date(), - }; - // 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 = this.workflowService.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(); - } - // 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(); + + // 4. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord.toObject(), + requestingUserId, + requestingUserObjectId + ); + + // 5. Return unauthorised message if the requesting user is not an applicant + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - // 14. Update MongoDb record for DAR - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } else { - // 15. Create emails and notifications - let relevantStepIndex = 0, - relevantNotificationType = ''; - if (bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { - // Create notifications to reviewers of the next step that has been activated - relevantStepIndex = activeStepIndex + 1; - relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART; - } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { - // Create notifications to managers that the application is awaiting final approval - relevantStepIndex = activeStepIndex; - relevantNotificationType = constants.notificationTypes.FINALDECISIONREQUIRED; - } - // Continue only if notification required - if (!_.isEmpty(relevantNotificationType)) { - const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflow, relevantStepIndex); - module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user); - } - // 16. Call Camunda controller to update workflow process - bpmController.postCompleteReview(bpmContext); - } + + // 6. Remove the file from the application + const newFileList = accessRecord.files.filter(file => file.fileId !== fileId); + accessRecord.files = newFileList; + + // 7. write back into mongo + await accessRecord.save().catch(err => { + logger.logError(err, logCategory); }); - // 17. Return aplication and successful response - return res.status(200).json({ status: 'success', data: accessRecord._doc }); + + // 8. Return successful response + return res.status(200).json({ status: 'success' }); } catch (err) { console.error(err.message); res.status(500).json({ status: 'error', message: err.message }); } - }, + } - //PUT api/v1/data-access-request/:id/stepoverride - updateAccessRequestStepOverride: async (req, res) => { + //PUT api/v1/data-access-request/:id/assignworkflow + async assignWorkflow(req, res) { try { // 1. Get the required request params const { params: { id }, } = req; - let { _id: userId } = req.user; + const requestingUserObjectId = req.user._id; + const { 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: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - { - path: 'workflow.steps.reviewers', - select: 'firstname lastname id email', - }, - { - path: 'datasets dataset', - }, - { - path: 'mainApplicant', - }, - ]); + let accessRecord = await this.dataRequestService.getApplicationWithTeamById(id, { lean: false }); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } + // 3. Check permissions of user is manager of associated team let authorised = false; if (_.has(accessRecord.toObject(), 'publisherObj.team')) { let { team } = accessRecord.publisherObj; - authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team.toObject(), userId); + authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team.toObject(), requestingUserObjectId); } + // 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 !== constants.applicationStatuses.INREVIEW) { + + // 5. Check publisher allows workflows + const { workflowEnabled = false } = accessRecord.publisherObj; + if (!workflowEnabled) { return res.status(400).json({ success: false, - message: 'The application status must be set to in review', + message: 'This custodian has not enabled workflows', }); } - // 6. Check a workflow is assigned with valid steps - let { workflow = {} } = accessRecord; - let { steps = [] } = workflow; - if (_.isEmpty(workflow) || _.isEmpty(steps)) { + + // 6. Check no workflow already assigned + const { workflowId: currentWorkflowId = '' } = accessRecord; + if (!_.isEmpty(currentWorkflowId)) { return res.status(400).json({ success: false, - message: 'A valid workflow has not been attached to this application', + message: 'This application already has a workflow assigned', }); } - // 7. Get the attached active workflow step - let activeStepIndex = steps.findIndex(step => { - return step.active === true; - }); - if (activeStepIndex === -1) { + + // 7. Check application is in-review + const { applicationStatus } = accessRecord; + if (applicationStatus !== constants.applicationStatuses.INREVIEW) { return res.status(400).json({ success: false, - message: 'There is no active step to override for this workflow', + message: 'The application status must be set to in review to assign a 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 = this.workflowService.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(); + + // 8. Assign workflow and save changes to application + accessRecord = await this.workflowService.assignWorkflowToApplication(accessRecord, workflowId).catch(err => { + logger.logError(err, logCategory); + }); + + // 9. Start Camunda workflow process instance + this.workflowService.startWorkflow(accessRecord, requestingUserObjectId); + + // 10. Send notifications + const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord); + // Create notifications to reviewers of the step that has been completed + this.createNotifications(constants.notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); + // Create our notifications to the custodian team managers if assigned a workflow to a DAR application + this.createNotifications(constants.notificationTypes.WORKFLOWASSIGNED, emailContext, accessRecord, req.user); + + return res.status(200).json({ + success: true, + }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred assigning the workflow', + }); + } + } + + //POST api/v1/data-access-request/:id/notify + async notifyAccessRequestById(req, res) { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + // 2. Retrieve DAR from database + const accessRecord = await this.dataRequestService.getApplicationWithWorkflowById(id); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); } - // 11. Save changes to the DAR - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } else { - // 12. Gather context for notifications (active step) - let emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflow, activeStepIndex); - // 13. Create notifications to reviewers of the step that has been completed - module.exports.createNotifications(constants.notificationTypes.STEPOVERRIDE, emailContext, accessRecord, req.user); - // 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 = constants.notificationTypes.FINALDECISIONREQUIRED; - } else { - // Create notifications to reviewers of the next step that has been activated - relevantStepIndex = activeStepIndex + 1; - relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART; - } - // Get the email context only if required - if (relevantStepIndex !== activeStepIndex) { - emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflow, relevantStepIndex); - } - module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user); - // 15. Call Camunda controller to start manager review process - bpmController.postCompleteReview(bpmContext); - } + const { workflow } = accessRecord; + if (_.isEmpty(workflow)) { + return res.status(400).json({ + status: 'error', + message: 'There is no workflow attached to this application.', + }); + } + const activeStepIndex = workflow.steps.findIndex(step => { + return step.active === true; }); - // 16. Return aplication and successful response + // 3. Determine email context if deadline has elapsed or is approaching + const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, activeStepIndex); + // 4. Send emails based on deadline elapsed or approaching + if (emailContext.deadlineElapsed) { + this.createNotifications(constants.notificationTypes.DEADLINEPASSED, emailContext, accessRecord, req.user); + } else { + this.createNotifications(constants.notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user); + } return res.status(200).json({ status: 'success' }); } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred triggering notifications for workflow review deadlines', + }); } - }, + } - //PUT api/v1/data-access-request/:id/deletefile - updateAccessRequestDeleteFile: async (req, res) => { + //POST api/v1/data-access-request/:id/email + async mailDataAccessRequestInfoById(req, res) { try { + // 1. Get the required request params const { params: { id }, } = req; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + const requestingUser = req.user; - // 1. Id of the file to delete - let { fileId } = req.body; - - // 2. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }); + // 2. Retrieve DAR from database + const accessRecord = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true }); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } - // 4. Ensure single datasets are mapped correctly into array - if (_.isEmpty(accessRecord.datasets)) { - accessRecord.datasets = [accessRecord.dataset]; - } - - // 5. If application is not in progress, actions cannot be performed + // 3. If application is not in progress, actions cannot be performed if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { return res.status(400).json({ success: false, @@ -1515,461 +824,106 @@ module.exports = { }); } - // 6. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), req.user.id, req.user._id); - // 7. Return unauthorised message if the requesting user is not an applicant + // 4. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); + // 5. Return unauthorised message if the requesting user is not an applicant if (!authorised || userType !== constants.userTypes.APPLICANT) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - // 8. Remove the file from the application - const newFileList = accessRecord.files.filter(file => file.fileId !== fileId); - - accessRecord.files = newFileList; - - // 9. write back into mongo - await accessRecord.save(); + // 6. Send notification to the authorised user + this.createNotifications(constants.notificationTypes.INPROGRESS, {}, accessRecord, requestingUser); - // 10. Return successful response return res.status(200).json({ status: 'success' }); } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, - - doInitialSubmission: accessRecord => { - // 1. Update application to submitted status - accessRecord.submissionType = constants.submissionTypes.INITIAL; - accessRecord.applicationStatus = constants.applicationStatuses.SUBMITTED; - // 2. Check if workflow/5 Safes based application, set final status date if status will never change again - if (_.has(accessRecord.datasets[0].toObject(), 'publisher') && !_.isNull(accessRecord.datasets[0].publisher)) { - if (!accessRecord.datasets[0].publisher.workflowEnabled) { - accessRecord.dateFinalStatus = new Date(); - accessRecord.workflowEnabled = false; - } else { - accessRecord.workflowEnabled = true; - } - } - let dateSubmitted = new Date(); - accessRecord.dateSubmitted = dateSubmitted; - // 3. Return updated access record for saving - return accessRecord; - }, - - //POST api/v1/data-access-request/:id/email - mailDataAccessRequestInfoById: async (req, res) => { - try { - // 1. Get the required request params - const { - params: { id }, - } = req; - - // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ - { - path: 'datasets dataset', - }, - { - path: 'mainApplicant', - }, - ]); - - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - - // 3. Ensure single datasets are mapped correctly into array - if (_.isEmpty(accessRecord.datasets)) { - accessRecord.datasets = [accessRecord.dataset]; - } - - // 4. If application is not in progress, actions cannot be performed - if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { - return res.status(400).json({ - success: false, - message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', - }); - } - - // 5. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), req.user.id, req.user._id); - // 6. Return unauthorised message if the requesting user is not an applicant - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - - // 7. Send notification to the authorised user - module.exports.createNotifications(constants.notificationTypes.INPROGRESS, {}, accessRecord, req.user); - - return res.status(200).json({ status: 'success' }); - } catch (err) { - console.error(err.message); + // Return error response if something goes wrong + logger.logError(err, logCategory); return res.status(500).json({ success: false, - message: 'An error occurred', + message: 'An error occurred emailing the application', }); } - }, + } - //POST api/v1/data-access-request/:id/notify - notifyAccessRequestById: async (req, res) => { - // 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; - if (_.isEmpty(workflow)) { - return res.status(400).json({ - status: 'error', - message: 'There is no workflow attached to this application.', - }); + async createNotifications(type, context, accessRecord, user) { + // Project details from about application if 5 Safes + let { aboutApplication = {} } = accessRecord; + let { projectName = 'No project name set' } = aboutApplication; + let { projectId, _id, workflow = {}, dateSubmitted = '', jsonSchema, questionAnswers, createdAt } = accessRecord; + if (_.isEmpty(projectId)) { + projectId = _id; } - let activeStepIndex = workflow.steps.findIndex(step => { - return step.active === true; - }); - // 3. Determine email context if deadline has elapsed or is approaching - const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, workflow, activeStepIndex); - // 4. Send emails based on deadline elapsed or approaching - if (emailContext.deadlineElapsed) { - module.exports.createNotifications(constants.notificationTypes.DEADLINEPASSED, emailContext, accessRecord, req.user); - } else { - module.exports.createNotifications(constants.notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user); + let { pages, questionPanels, questionSets: questions } = jsonSchema; + // Publisher details from single dataset + let { + datasetfields: { contactPoint, publisher }, + } = accessRecord.datasets[0]; + let datasetTitles = accessRecord.datasets.map(dataset => dataset.name).join(', '); + // Main applicant (user obj) + let { firstname: appFirstName, lastname: appLastName, email: appEmail } = accessRecord.mainApplicant; + // Requesting user + let { firstname, lastname } = user; + // Instantiate default params + let custodianManagers = [], + custodianUserIds = [], + managerUserIds = [], + emailRecipients = [], + options = {}, + html = '', + attachmentContent = '', + filename = '', + jsonContent = {}, + authors = [], + attachments = []; + let applicants = datarequestUtil.extractApplicantNames(questionAnswers).join(', '); + // Fall back for single applicant on short application form + if (_.isEmpty(applicants)) { + applicants = `${appFirstName} ${appLastName}`; } - return res.status(200).json({ status: 'success' }); - }, - - //POST api/v1/data-access-request/:id/actions - performAction: async (req, res) => { - try { - // 1. Get the required request params - const { - params: { id }, - } = req; - let { questionId, questionSetId, questionIds = [], mode, separatorText = '' } = req.body; - if (_.isEmpty(questionId) || _.isEmpty(questionSetId)) { - return res.status(400).json({ - success: false, - message: 'You must supply the unique identifiers for the question to perform an action', - }); - } - // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ - { - path: 'datasets dataset', - }, - { - path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - ]); - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - // 3. If application is not in progress, actions cannot be performed - if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { - return res.status(400).json({ - success: false, - message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', - }); - } - // 4. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), req.user.id, req.user._id); - // 5. Return unauthorised message if the requesting user is not an applicant - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - // 6. Extract schema and answers - let { jsonSchema, questionAnswers } = _.cloneDeep(accessRecord); - // 7. Perform different action depending on mode passed - switch (mode) { - case constants.formActions.ADDREPEATABLESECTION: - let duplicateQuestionSet = dynamicForm.duplicateQuestionSet(questionSetId, jsonSchema); - jsonSchema = dynamicForm.insertQuestionSet(questionSetId, duplicateQuestionSet, jsonSchema); - break; - case constants.formActions.REMOVEREPEATABLESECTION: - jsonSchema = dynamicForm.removeQuestionSetReferences(questionSetId, questionId, jsonSchema); - questionAnswers = dynamicForm.removeQuestionSetAnswers(questionId, questionAnswers); - break; - case constants.formActions.ADDREPEATABLEQUESTIONS: - if (_.isEmpty(questionIds)) { - return res.status(400).json({ - success: false, - message: 'You must supply the question identifiers to duplicate when performing this action', - }); - } - let duplicateQuestions = dynamicForm.duplicateQuestions(questionSetId, questionIds, separatorText, jsonSchema); - jsonSchema = dynamicForm.insertQuestions(questionSetId, questionId, duplicateQuestions, jsonSchema); - break; - case constants.formActions.REMOVEREPEATABLEQUESTIONS: - if (_.isEmpty(questionIds)) { - return res.status(400).json({ - success: false, - message: 'You must supply the question identifiers to remove when performing this action', - }); - } - questionIds = [...questionIds, questionId]; - jsonSchema = dynamicForm.removeQuestionReferences(questionSetId, questionIds, jsonSchema); - questionAnswers = dynamicForm.removeQuestionAnswers(questionIds, questionAnswers); - break; - default: - return res.status(400).json({ - success: false, - message: 'You must supply a valid action to perform', - }); - } - // 8. Update record - accessRecord.jsonSchema = jsonSchema; - accessRecord.questionAnswers = questionAnswers; - // 9. Save changes to database - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - return res.status(500).json({ status: 'error', message: err.message }); - } else { - // 10. Append question actions for in progress applicant - jsonSchema = datarequestUtil.injectQuestionActions( - jsonSchema, - constants.userTypes.APPLICANT, // current user type - constants.applicationStatuses.INPROGRESS, - null, - constants.userTypes.APPLICANT // active party - ); - // 11. Return necessary object to reflect schema update - return res.status(200).json({ - success: true, - accessRecord: { - jsonSchema, - questionAnswers, - }, - }); - } - }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred updating the application amendment', + // Get authors/contributors (user obj) + if (!_.isEmpty(accessRecord.authors)) { + authors = accessRecord.authors.map(author => { + let { firstname, lastname, email, id } = author; + return { firstname, lastname, email, id }; }); } - }, + // Deconstruct workflow context if passed + let { + workflowName = '', + steps = [], + stepName = '', + reviewerNames = '', + reviewSections = '', + nextStepName = '', + stepReviewers = [], + stepReviewerUserIds = [], + currentDeadline = '', + remainingReviewers = [], + remainingReviewerUserIds = [], + dateDeadline, + } = context; - updateFileStatus: async (req, res) => { - try { - // 1. Get the required request params - const { - params: { id, fileId }, - } = req; + switch (type) { + case constants.notificationTypes.INPROGRESS: + await notificationBuilder.triggerNotificationMessage( + [user.id], + `An email with the data access request info for ${datasetTitles} has been sent to you`, + 'data access request', + accessRecord._id + ); - let { status } = req.body; - - // 2. Find the relevant data request application - let accessRecord = await DataRequestModel.findOne({ _id: id }); - - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - - //3. Check the status is valid - if ( - status !== fileStatus.UPLOADED && - status !== fileStatus.SCANNED && - status !== fileStatus.ERROR && - status !== fileStatus.QUARANTINED - ) { - return res.status(400).json({ status: 'error', message: 'File status not valid' }); - } - - //4. get the file - const fileIndex = accessRecord.files.findIndex(file => file.fileId === fileId); - if (fileIndex === -1) return res.status(404).json({ status: 'error', message: 'File not found.' }); - - //5. update the status - accessRecord.files[fileIndex].status = status; - - //6. write back into mongo - await accessRecord.save(); - - return res.status(200).json({ - success: true, - }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: err.message, - }); - } - }, - - // API DELETE api/v1/data-access-request/:id - deleteDraftAccessRequest: async (req, res) => { - try { - // 1. Get the required request and body params - const { - params: { id: appIdToDelete }, - } = req; - - // 2. Retrieve DAR to clone from database - let appToDelete = await DataRequestModel.findOne({ _id: appIdToDelete }).populate([ - { - path: 'datasets dataset authors', - }, - { - path: 'mainApplicant', - }, - { - path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - ]); - - // 3. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToDelete, req.user.id, req.user._id); - - // 4. Return unauthorised message if the requesting user is not an applicant - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - - // 5. If application is not in progress, actions cannot be performed - if (appToDelete.applicationStatus !== constants.applicationStatuses.INPROGRESS) { - return res.status(400).json({ - success: false, - message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', - }); - } - - // 6. Delete applicatioin - DataRequestModel.findOneAndDelete({ _id: appIdToDelete }, err => { - if (err) console.error(err.message); - }); - - // 7. Create notifications - await module.exports.createNotifications(constants.notificationTypes.APPLICATIONDELETED, {}, appToDelete, req.user); - - return res.status(200).json({ - success: true, - }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred deleting the existing application', - }); - } - }, - - createNotifications: async (type, context, accessRecord, user) => { - // Project details from about application if 5 Safes - let { aboutApplication = {} } = accessRecord; - let { projectName = 'No project name set' } = aboutApplication; - let { projectId, _id, workflow = {}, dateSubmitted = '', jsonSchema, questionAnswers, createdAt } = accessRecord; - if (_.isEmpty(projectId)) { - projectId = _id; - } - let { pages, questionPanels, questionSets: questions } = jsonSchema; - // Publisher details from single dataset - let { - datasetfields: { contactPoint, publisher }, - } = accessRecord.datasets[0]; - let datasetTitles = accessRecord.datasets.map(dataset => dataset.name).join(', '); - // Main applicant (user obj) - let { firstname: appFirstName, lastname: appLastName, email: appEmail } = accessRecord.mainApplicant; - // Requesting user - let { firstname, lastname } = user; - // Instantiate default params - let custodianManagers = [], - custodianUserIds = [], - managerUserIds = [], - emailRecipients = [], - options = {}, - html = '', - attachmentContent = '', - filename = '', - jsonContent = {}, - authors = [], - attachments = []; - let applicants = datarequestUtil.extractApplicantNames(questionAnswers).join(', '); - // Fall back for single applicant on short application form - if (_.isEmpty(applicants)) { - applicants = `${appFirstName} ${appLastName}`; - } - // Get authors/contributors (user obj) - if (!_.isEmpty(accessRecord.authors)) { - authors = accessRecord.authors.map(author => { - let { firstname, lastname, email, id } = author; - return { firstname, lastname, email, id }; - }); - } - // Deconstruct workflow context if passed - let { - workflowName = '', - steps = [], - stepName = '', - reviewerNames = '', - reviewSections = '', - nextStepName = '', - stepReviewers = [], - stepReviewerUserIds = [], - currentDeadline = '', - remainingReviewers = [], - remainingReviewerUserIds = [], - dateDeadline, - } = context; - - switch (type) { - case constants.notificationTypes.INPROGRESS: - await notificationBuilder.triggerNotificationMessage( - [user.id], - `An email with the data access request info for ${datasetTitles} has been sent to you`, - 'data access request', - accessRecord._id - ); - - options = { - userEmail: appEmail, - publisher, - datasetTitles, - userName: `${appFirstName} ${appLastName}`, - userType: 'applicant', - submissionType: constants.submissionTypes.INPROGRESS, - }; + options = { + userEmail: appEmail, + publisher, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + userType: 'applicant', + submissionType: constants.submissionTypes.INPROGRESS, + }; // Build email template ({ html, jsonContent } = await emailGenerator.generateEmail( @@ -2457,7 +1411,7 @@ module.exports = { break; case constants.notificationTypes.WORKFLOWASSIGNED: // 1. Get managers for publisher - custodianManagers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, constants.roleTypes.MANAGER); + custodianManagers = teamController.getTeamMembersByRole(accessRecord.publisherObj.team.toObject(), constants.roleTypes.MANAGER); // 2. Get managerIds for notifications managerUserIds = custodianManagers.map(user => user.id); // 3. deconstruct and set options for notifications and email @@ -2578,5 +1532,880 @@ module.exports = { ); break; } + } +} + +module.exports = { + //GET api/v1/data-access-request/dataset/:datasetId + getAccessRequestByUserAndDataset: async (req, res) => { + let accessRecord, dataset; + let formType = constants.formTypes.Extended5Safe; + let data = {}; + try { + // 1. Get dataSetId from params + let { + params: { dataSetId }, + } = req; + // 2. Get the userId + let { id: userId, firstname, lastname } = req.user; + // 3. Find the matching record + accessRecord = await DataRequestModel.findOne({ + dataSetId, + userId, + applicationStatus: constants.applicationStatuses.INPROGRESS, + }).populate({ + path: 'mainApplicant', + select: 'firstname lastname -id -_id', + }); + // 4. Get dataset + dataset = await ToolModel.findOne({ datasetid: dataSetId }).populate('publisher'); + // 5. If no record create it and pass back + if (!accessRecord) { + if (!dataset) { + return res.status(500).json({ status: 'error', message: 'No dataset available.' }); + } + let { + datasetfields: { publisher = '' }, + } = dataset; + // 1. GET the template from the custodian + const accessRequestTemplate = await DataRequestSchemaModel.findOne({ + $or: [{ dataSetId }, { publisher }, { dataSetId: 'default' }], + status: 'active', + }).sort({ createdAt: -1 }); + + if (!accessRequestTemplate) { + return res.status(400).json({ + status: 'error', + message: 'No Data Access request schema.', + }); + } + // 2. Build up the accessModel for the user + let { jsonSchema, version, _id: schemaId, isCloneable = false } = accessRequestTemplate; + // 3. check for the type of form [enquiry - 5safes] + if (schemaId.toString() === constants.enquiryFormId) formType = constants.formTypes.Enquiry; + + // 4. create new DataRequestModel + let record = new DataRequestModel({ + version, + userId, + dataSetId, + datasetIds: [dataSetId], + datasetTitles: [dataset.name], + isCloneable, + jsonSchema, + schemaId, + publisher, + questionAnswers: {}, + aboutApplication: {}, + applicationStatus: constants.applicationStatuses.INPROGRESS, + formType, + }); + // 5. save record + const newApplication = await record.save(); + newApplication.projectId = helper.generateFriendlyId(newApplication._id); + await newApplication.save(); + + // 6. return record + data = { + ...newApplication._doc, + mainApplicant: { firstname, lastname }, + }; + } else { + data = { ...accessRecord.toObject() }; + } + // 7. Append question actions depending on user type and application status + data.jsonSchema = datarequestUtil.injectQuestionActions( + data.jsonSchema, + constants.userTypes.APPLICANT, + data.applicationStatus, + null, + constants.userTypes.APPLICANT + ); + // 8. Return payload + return res.status(200).json({ + status: 'success', + data: { + ...data, + dataset, + projectId: data.projectId || helper.generateFriendlyId(data._id), + userType: constants.userTypes.APPLICANT, + activeParty: constants.userTypes.APPLICANT, + inReviewMode: false, + reviewSections: [], + files: data.files || [], + }, + }); + } catch (err) { + console.error(err.message); + res.status(500).json({ status: 'error', message: err.message }); + } + }, + + //GET api/v1/data-access-request/datasets/:datasetIds + getAccessRequestByUserAndMultipleDatasets: async (req, res) => { + let accessRecord; + let formType = constants.formTypes.Extended5Safe; + let data = {}; + let datasets = []; + try { + // 1. Get datasetIds from params + let { + params: { datasetIds }, + } = req; + let arrDatasetIds = datasetIds.split(','); + // 2. Get the userId + let { id: userId, firstname, lastname } = req.user; + // 3. Find the matching record + accessRecord = await DataRequestModel.findOne({ + datasetIds: { $all: arrDatasetIds }, + userId, + applicationStatus: constants.applicationStatuses.INPROGRESS, + }) + .populate([ + { + path: 'mainApplicant', + select: 'firstname lastname -id -_id', + }, + { path: 'files.owner', select: 'firstname lastname' }, + ]) + .sort({ createdAt: 1 }); + // 4. Get datasets + datasets = await ToolModel.find({ + datasetid: { $in: arrDatasetIds }, + }).populate('publisher'); + const arrDatasetNames = datasets.map(dataset => dataset.name); + // 5. If no record create it and pass back + if (!accessRecord) { + if (_.isEmpty(datasets)) { + return res.status(500).json({ status: 'error', message: 'No datasets available.' }); + } + let { + datasetfields: { publisher = '' }, + } = datasets[0]; + + // 1. GET the template from the custodian or take the default (Cannot have dataset specific question sets for multiple datasets) + const accessRequestTemplate = await DataRequestSchemaModel.findOne({ + $or: [{ publisher }, { dataSetId: 'default' }], + status: 'active', + }).sort({ createdAt: -1 }); + // 2. Ensure a question set was found + if (!accessRequestTemplate) { + return res.status(400).json({ + status: 'error', + message: 'No Data Access request schema.', + }); + } + // 3. Build up the accessModel for the user + let { jsonSchema, version, _id: schemaId, isCloneable = false } = accessRequestTemplate; + // 4. Check form is enquiry + if (schemaId.toString() === constants.enquiryFormId) formType = constants.formTypes.Enquiry; + // 5. Create new DataRequestModel + let record = new DataRequestModel({ + version, + userId, + datasetIds: arrDatasetIds, + datasetTitles: arrDatasetNames, + isCloneable, + jsonSchema, + schemaId, + publisher, + questionAnswers: {}, + aboutApplication: {}, + applicationStatus: constants.applicationStatuses.INPROGRESS, + formType, + }); + // 6. save record + const newApplication = await record.save(); + newApplication.projectId = helper.generateFriendlyId(newApplication._id); + await newApplication.save(); + // 7. return record + data = { + ...newApplication._doc, + mainApplicant: { firstname, lastname }, + }; + } else { + data = { ...accessRecord.toObject() }; + } + // 8. Append question actions depending on user type and application status + data.jsonSchema = datarequestUtil.injectQuestionActions( + data.jsonSchema, + constants.userTypes.APPLICANT, + data.applicationStatus, + null, + constants.userTypes.APPLICANT + ); + // 9. Return payload + return res.status(200).json({ + status: 'success', + data: { + ...data, + datasets, + projectId: data.projectId || helper.generateFriendlyId(data._id), + userType: constants.userTypes.APPLICANT, + activeParty: constants.userTypes.APPLICANT, + inReviewMode: false, + reviewSections: [], + files: data.files || [], + }, + }); + } catch (err) { + console.error(err.message); + res.status(500).json({ status: 'error', message: err.message }); + } + }, + + //PUT api/v1/data-access-request/:id + updateAccessRequestById: async (req, res) => { + try { + // 1. Id is the _id object in MongoDb not the generated id or dataset Id + const { + params: { id }, + } = req; + // 2. Get the userId + let { _id, id: userId } = req.user; + let applicationStatus = '', + 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', + populate: { + path: 'team', + populate: { + path: 'users', + populate: { + path: 'additionalInfo', + }, + }, + }, + }, + }, + { + path: 'publisherObj', + populate: { + path: 'team', + }, + }, + { + path: 'workflow.steps.reviewers', + select: 'id email', + }, + ]); + + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) + if (_.isEmpty(accessRecord.datasets)) { + accessRecord.datasets = [accessRecord.dataset]; + } + + // 5. Check if the user is permitted to perform update to application + let isDirty = false, + statusChange = false, + contributorChange = false; + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), userId, _id); + + if (!authorised) { + return res.status(401).json({ + status: 'error', + message: 'Unauthorised to perform this update.', + }); + } + + let { authorIds: currentAuthors } = accessRecord; + let newAuthors = []; + + // 6. Extract new application status and desc to save updates + if (userType === constants.userTypes.CUSTODIAN) { + // Only a custodian manager can set the final status of an application + authorised = false; + let team = {}; + if (_.isNull(accessRecord.publisherObj)) { + ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); + } else { + ({ team = {} } = accessRecord.publisherObj.toObject()); + } + + if (!_.isEmpty(team)) { + authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, _id); + } + + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + // Extract params from body + ({ applicationStatus, applicationStatusDesc } = req.body); + const finalStatuses = [ + constants.applicationStatuses.SUBMITTED, + constants.applicationStatuses.APPROVED, + constants.applicationStatuses.REJECTED, + constants.applicationStatuses.APPROVEDWITHCONDITIONS, + constants.applicationStatuses.WITHDRAWN, + ]; + if (applicationStatus) { + accessRecord.applicationStatus = applicationStatus; + + if (finalStatuses.includes(applicationStatus)) { + accessRecord.dateFinalStatus = new Date(); + } + 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) { + accessRecord.applicationStatusDesc = inputSanitizer.removeNonBreakingSpaces(applicationStatusDesc); + isDirty = true; + } + // If applicant, allow update to contributors/authors + } else if (userType === constants.userTypes.APPLICANT) { + // Extract new contributor/author IDs + if (req.body.authorIds) { + ({ authorIds: newAuthors } = req.body); + + // Perform comparison between new and existing authors to determine if an update is required + if (newAuthors && !helper.arraysEqual(newAuthors, currentAuthors)) { + accessRecord.authorIds = newAuthors; + isDirty = true; + contributorChange = true; + } + } + } + // 7. If a change has been made, notify custodian and main applicant + if (isDirty) { + await accessRecord.save(async err => { + if (err) { + console.error(err.message); + return res.status(500).json({ status: 'error', message: err.message }); + } else { + // If save has succeeded - send notifications + // Send notifications to added/removed contributors + if (contributorChange) { + await module.exports.createNotifications( + constants.notificationTypes.CONTRIBUTORCHANGE, + { newAuthors, currentAuthors }, + accessRecord, + req.user + ); + } + if (statusChange) { + // Send notifications to custodian team, main applicant and contributors regarding status change + await module.exports.createNotifications( + constants.notificationTypes.STATUSCHANGE, + { applicationStatus, applicationStatusDesc }, + accessRecord, + req.user + ); + // 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', + data: accessRecord._doc, + }); + } catch (err) { + console.error(err.message); + res.status(500).json({ + status: 'error', + message: 'An error occurred updating the application status', + }); + } + }, + + //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(constants.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 !== constants.applicationStatuses.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 = constants.applicationStatuses.INREVIEW; + accessRecord.dateReviewStart = new Date(); + // 7. Save update to access record + await accessRecord.save(async err => { + if (err) { + console.error(err.message); + res.status(500).json({ status: 'error', message: err.message }); + } 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.error(err.message); + res.status(500).json({ status: 'error', message: err.message }); + } + }, + + //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', + 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.' }); + } + // 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(constants.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 !== constants.applicationStatuses.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.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', + }); + } + //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), + createdDate: new Date(), + }; + // 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 = this.workflowService.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(); + } + // 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.message); + res.status(500).json({ status: 'error', message: err.message }); + } else { + // 15. Create emails and notifications + let relevantStepIndex = 0, + relevantNotificationType = ''; + if (bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { + // Create notifications to reviewers of the next step that has been activated + relevantStepIndex = activeStepIndex + 1; + relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART; + } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { + // Create notifications to managers that the application is awaiting final approval + relevantStepIndex = activeStepIndex; + relevantNotificationType = constants.notificationTypes.FINALDECISIONREQUIRED; + } + // Continue only if notification required + if (!_.isEmpty(relevantNotificationType)) { + const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, relevantStepIndex); + module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user); + } + // 16. Call Camunda controller to update workflow process + bpmController.postCompleteReview(bpmContext); + } + }); + // 17. Return aplication and successful response + return res.status(200).json({ status: 'success', data: accessRecord._doc }); + } catch (err) { + console.error(err.message); + res.status(500).json({ status: 'error', message: err.message }); + } + }, + + //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', + 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.' }); + } + // 3. Check permissions of user is manager of associated team + let authorised = false; + if (_.has(accessRecord.toObject(), 'publisherObj.team')) { + let { team } = accessRecord.publisherObj; + authorised = teamController.checkTeamPermissions(constants.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 !== constants.applicationStatuses.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 = this.workflowService.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.message); + res.status(500).json({ status: 'error', message: err.message }); + } else { + // 12. Gather context for notifications (active step) + let emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, activeStepIndex); + // 13. Create notifications to reviewers of the step that has been completed + module.exports.createNotifications(constants.notificationTypes.STEPOVERRIDE, emailContext, accessRecord, req.user); + // 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 = constants.notificationTypes.FINALDECISIONREQUIRED; + } else { + // Create notifications to reviewers of the next step that has been activated + relevantStepIndex = activeStepIndex + 1; + relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART; + } + // Get the email context only if required + if (relevantStepIndex !== activeStepIndex) { + emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, relevantStepIndex); + } + module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user); + // 15. Call Camunda controller to start manager review process + bpmController.postCompleteReview(bpmContext); + } + }); + // 16. Return aplication and successful response + return res.status(200).json({ status: 'success' }); + } catch (err) { + console.error(err.message); + res.status(500).json({ status: 'error', message: err.message }); + } + }, + + //POST api/v1/data-access-request/:id/actions + performAction: async (req, res) => { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + let { questionId, questionSetId, questionIds = [], mode, separatorText = '' } = req.body; + if (_.isEmpty(questionId) || _.isEmpty(questionSetId)) { + return res.status(400).json({ + success: false, + message: 'You must supply the unique identifiers for the question to perform an action', + }); + } + // 2. Retrieve DAR from database + let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ + { + path: 'datasets dataset', + }, + { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users', + }, + }, + }, + ]); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + // 3. If application is not in progress, actions cannot be performed + if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { + return res.status(400).json({ + success: false, + message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', + }); + } + // 4. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), req.user.id, req.user._id); + // 5. Return unauthorised message if the requesting user is not an applicant + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + // 6. Extract schema and answers + let { jsonSchema, questionAnswers } = _.cloneDeep(accessRecord); + // 7. Perform different action depending on mode passed + switch (mode) { + case constants.formActions.ADDREPEATABLESECTION: + let duplicateQuestionSet = dynamicForm.duplicateQuestionSet(questionSetId, jsonSchema); + jsonSchema = dynamicForm.insertQuestionSet(questionSetId, duplicateQuestionSet, jsonSchema); + break; + case constants.formActions.REMOVEREPEATABLESECTION: + jsonSchema = dynamicForm.removeQuestionSetReferences(questionSetId, questionId, jsonSchema); + questionAnswers = dynamicForm.removeQuestionSetAnswers(questionId, questionAnswers); + break; + case constants.formActions.ADDREPEATABLEQUESTIONS: + if (_.isEmpty(questionIds)) { + return res.status(400).json({ + success: false, + message: 'You must supply the question identifiers to duplicate when performing this action', + }); + } + let duplicateQuestions = dynamicForm.duplicateQuestions(questionSetId, questionIds, separatorText, jsonSchema); + jsonSchema = dynamicForm.insertQuestions(questionSetId, questionId, duplicateQuestions, jsonSchema); + break; + case constants.formActions.REMOVEREPEATABLEQUESTIONS: + if (_.isEmpty(questionIds)) { + return res.status(400).json({ + success: false, + message: 'You must supply the question identifiers to remove when performing this action', + }); + } + questionIds = [...questionIds, questionId]; + jsonSchema = dynamicForm.removeQuestionReferences(questionSetId, questionIds, jsonSchema); + questionAnswers = dynamicForm.removeQuestionAnswers(questionIds, questionAnswers); + break; + default: + return res.status(400).json({ + success: false, + message: 'You must supply a valid action to perform', + }); + } + // 8. Update record + accessRecord.jsonSchema = jsonSchema; + accessRecord.questionAnswers = questionAnswers; + // 9. Save changes to database + await accessRecord.save(async err => { + if (err) { + console.error(err.message); + return res.status(500).json({ status: 'error', message: err.message }); + } else { + // 10. Append question actions for in progress applicant + jsonSchema = datarequestUtil.injectQuestionActions( + jsonSchema, + constants.userTypes.APPLICANT, // current user type + constants.applicationStatuses.INPROGRESS, + null, + constants.userTypes.APPLICANT // active party + ); + // 11. Return necessary object to reflect schema update + return res.status(200).json({ + success: true, + accessRecord: { + jsonSchema, + questionAnswers, + }, + }); + } + }); + } catch (err) { + console.error(err.message); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the application amendment', + }); + } }, }; diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index c42d0ae6..ac200153 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -40,26 +40,48 @@ export default class DataRequestRepository extends Repository { .lean(); } - getApplicationToCloneById(id) { - return DataRequestModel.findOne({ _id: id }) - .populate([ - { - path: 'datasets dataset authors', - }, - { - path: 'mainApplicant', + getApplicationWithTeamById(id, options = {}) { + return DataRequestModel.findOne({ _id: id }, null, options).populate([ + { + path: 'datasets dataset authors', + }, + { + path: 'mainApplicant', + }, + { + path: 'publisherObj', + populate: { + path: 'team', + populate: { + path: 'users', + }, }, - { - path: 'publisherObj', + }, + ]); + } + + getApplicationWithWorkflowById(id, options = {}) { + return DataRequestModel.findOne({ _id: id }, null, options).populate([ + { + path: 'publisherObj', + populate: { + path: 'team', populate: { - path: 'team', - populate: { - path: 'users', - }, + path: 'users', }, }, - ]) - .lean(); + }, + { + path: 'workflow.steps.reviewers', + select: 'firstname lastname id email', + }, + { + path: 'datasets dataset', + }, + { + path: 'mainApplicant', + }, + ]); } getApplicationToSubmitById(id) { @@ -90,4 +112,34 @@ export default class DataRequestRepository extends Repository { }, ]); } + + getApplicationToUpdateById(id) { + return DataRequestModel.findOne({ + _id: id, + }).lean(); + } + + getFilesForApplicationById(id, options = {}) { + return DataRequestModel.findById(id, { files: 1, applicationStatus: 1, userId: 1, authorIds: 1 }, options); + } + + updateApplicationById(id, data, options = {}) { + return DataRequestModel.findByIdAndUpdate(id, data, { ...options }); + } + + replaceApplicationById(id, newDoc) { + return DataRequestModel.replaceOne({ _id: id }, newDoc); + } + + deleteApplicationById(id) { + return DataRequestModel.findOneAndDelete({ _id: id }); + } + + async saveFileUploadChanges(accessRecord) { + await accessRecord.save(); + return DataRequestModel.populate(accessRecord, { + path: 'files.owner', + select: 'firstname lastname id', + }); + } } diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 136b6d62..8df128ce 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -65,26 +65,45 @@ router.post( (req, res) => dataRequestController.submitAccessRequestById(req, res) ); +// @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) +router.patch( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Updating a single question answer in a Data Access Request application' }), + (req, res) => dataRequestController.updateAccessRequestDataElement(req, res) +); +// @route DELETE api/v1/data-access-request/:id +// @desc Delete an application in a presubmissioin +// @access Private - Applicant +router.delete( + '/:id', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Deleting a presubmission Data Access Request application' }), + (req, res) => dataRequestController.deleteDraftAccessRequest(req, res) +); -// @route GET api/v1/data-access-request/dataset/:datasetId -// @desc GET Access request for user -// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer -router.get( - '/dataset/:dataSetId', +// @route POST api/v1/data-access-request/:id/upload +// @desc POST application files to scan bucket +// @access Private - Applicant (Gateway User / Custodian Manager) +router.post( + '/:id/upload', passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via a dataset' }), - datarequestController.getAccessRequestByUserAndDataset + multerMid.array('assets'), + logger.logRequestMiddleware({ logCategory, action: 'Uploading a file to a Data Access Request application' }), + (req, res) => dataRequestController.uploadFiles(req, res) ); -// @route GET api/v1/data-access-request/datasets/:datasetIds -// @desc GET Access request with multiple datasets for user -// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer -router.get( - '/datasets/:datasetIds', +// @route PUT api/v1/data-access-request/:id/assignworkflow +// @desc Update access request workflow +// @access Private - Custodian Manager +router.put( + '/:id/assignworkflow', passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via multiple datasets' }), - datarequestController.getAccessRequestByUserAndMultipleDatasets + logger.logRequestMiddleware({ logCategory, action: 'Assigning a workflow to a Data Access Request application' }), + (req, res) => dataRequestController.assignWorkflow(req, res) ); // @route GET api/v1/data-access-request/:id/file/:fileId @@ -97,7 +116,7 @@ router.get( }), passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Requested an uploaded file from a Data Access Request application' }), - datarequestController.getFile + (req, res) => dataRequestController.getFile(req, res) ); // @route GET api/v1/data-access-request/:id/file/:fileId/status @@ -107,17 +126,76 @@ router.get( '/:id/file/:fileId/status', passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Requested the status of an uploaded file to a Data Access Request application' }), - datarequestController.getFileStatus + (req, res) => dataRequestController.getFileStatus(req, res) ); -// @route PATCH api/v1/data-access-request/:id -// @desc Update application passing single object to update database entry with specified key + +// @route PUT api/v1/data-access-request/:id/deletefile +// @desc Update access request deleting a file by Id // @access Private - Applicant (Gateway User) -router.patch( - '/:id', +router.put( + '/:id/deletefile', passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Updating a single question answer in a Data Access Request application' }), - datarequestController.updateAccessRequestDataElement + logger.logRequestMiddleware({ logCategory, action: 'Deleting an uploaded file from a Data Access Request application' }), + (req, res) => dataRequestController.updateAccessRequestDeleteFile(req, res) +); + +// @route POST api/v1/data-access-request/:id/updatefilestatus +// @desc Update the status of a file. +// @access Private +router.post( + '/:id/file/:fileId/status', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Updating the status of an uploaded file to a Data Access Request application' }), + (req, res) => dataRequestController.updateFileStatus(req, res) +); + +// @route POST api/v1/data-access-request/:id/email +// @desc Mail a Data Access Request information in presubmission +// @access Private - Applicant +router.post( + '/:id/email', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Emailing a presubmission Data Access Request application to the requesting user' }), + (req, res) => dataRequestController.mailDataAccessRequestInfoById(req, res) +); + +// @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/notify', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ + logCategory, + action: 'Notifying any outstanding or upcoming SLA breaches for review phases against a Data Access Request application', + }), + (req, res) => dataRequestController.notifyAccessRequestById(req, res) +); + + + + + + +// @route GET api/v1/data-access-request/dataset/:datasetId +// @desc GET Access request for user +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer +router.get( + '/dataset/:dataSetId', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via a dataset' }), + datarequestController.getAccessRequestByUserAndDataset +); + +// @route GET api/v1/data-access-request/datasets/:datasetIds +// @desc GET Access request with multiple datasets for user +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer +router.get( + '/datasets/:datasetIds', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via multiple datasets' }), + datarequestController.getAccessRequestByUserAndMultipleDatasets ); // @route PUT api/v1/data-access-request/:id @@ -130,16 +208,6 @@ router.put( datarequestController.updateAccessRequestById ); -// @route PUT api/v1/data-access-request/:id/assignworkflow -// @desc Update access request workflow -// @access Private - Custodian Manager -router.put( - '/:id/assignworkflow', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Assigning a workflow to a Data Access Request application' }), - datarequestController.assignWorkflow -); - // @route PUT api/v1/data-access-request/:id/vote // @desc Update access request with user vote // @access Private - Custodian Reviewer/Manager @@ -170,27 +238,6 @@ router.put( datarequestController.updateAccessRequestStepOverride ); -// @route PUT api/v1/data-access-request/:id/deletefile -// @desc Update access request deleting a file by Id -// @access Private - Applicant (Gateway User) -router.put( - '/:id/deletefile', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Deleting an uploaded file from a Data Access Request application' }), - datarequestController.updateAccessRequestDeleteFile -); - -// @route POST api/v1/data-access-request/:id/upload -// @desc POST application files to scan bucket -// @access Private - Applicant (Gateway User / Custodian Manager) -router.post( - '/:id/upload', - passport.authenticate('jwt'), - multerMid.array('assets'), - logger.logRequestMiddleware({ logCategory, action: 'Uploading a file to a Data Access Request application' }), - datarequestController.uploadFiles -); - // @route POST api/v1/data-access-request/:id/amendments // @desc Create or remove amendments from DAR // @access Private - Custodian Reviewer/Manager @@ -221,47 +268,8 @@ router.post( datarequestController.performAction ); -// @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/notify', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ - logCategory, - action: 'Notifying any outstanding or upcoming SLA breaches for review phases against a Data Access Request application', - }), - datarequestController.notifyAccessRequestById -); -// @route POST api/v1/data-access-request/:id/updatefilestatus -// @desc Update the status of a file. -// @access Private -router.post( - '/:id/file/:fileId/status', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Updating the status of an uploaded file to a Data Access Request application' }), - datarequestController.updateFileStatus -); -// @route POST api/v1/data-access-request/:id/email -// @desc Mail a Data Access Request information in presubmission -// @access Private - Applicant -router.post( - '/:id/email', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Emailing a presubmission Data Access Request application to the requesting user' }), - datarequestController.mailDataAccessRequestInfoById -); -// @route DELETE api/v1/data-access-request/:id -// @desc Delete an application in a presubmissioin -// @access Private - Applicant -router.delete( - '/:id', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Deleting a presubmission Data Access Request application' }), - datarequestController.deleteDraftAccessRequest -); module.exports = router; diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 4d26573e..b2736c7d 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -1,9 +1,9 @@ -import { isEmpty } from 'lodash'; +import { isEmpty, has, isNil } from 'lodash'; import moment from 'moment'; import datarequestUtil from '../datarequest/utils/datarequest.util'; import constants from '../utilities/constants.util'; - +import { processFile, getFile, fileStatus } from '../utilities/cloudStorage.util'; import { amendmentService } from '../datarequest/amendment/dependency'; export default class DataRequestService { @@ -19,14 +19,22 @@ export default class DataRequestService { return this.dataRequestRepository.getApplicationById(id); } - getApplicationToCloneById(id) { - return this.dataRequestRepository.getApplicationToCloneById(id); + getApplicationWithTeamById(id, options) { + return this.dataRequestRepository.getApplicationWithTeamById(id, options); + } + + getApplicationWithWorkflowById(id, options) { + return this.dataRequestRepository.getApplicationWithWorkflowById(id, options); } getApplicationToSubmitById(id) { return this.dataRequestRepository.getApplicationToSubmitById(id); } + getApplicationToUpdateById(id) { + return this.dataRequestRepository.getApplicationToUpdateById(id); + } + getApplicationIsReadOnly(userType, applicationStatus) { let readOnly = true; if (userType === constants.userTypes.APPLICANT && applicationStatus === constants.applicationStatuses.INPROGRESS) { @@ -35,6 +43,18 @@ export default class DataRequestService { return readOnly; } + getFilesForApplicationById(id, options) { + return this.dataRequestRepository.getFilesForApplicationById(id, options); + } + + deleteApplicationById(id) { + return this.dataRequestRepository.deleteApplicationById(id); + } + + replaceApplicationById(id, newAcessRecord) { + return this.dataRequestRepository.replaceApplicationById(id, newAcessRecord); + } + validateRequestedVersion(accessRecord, requestedVersion) { let isValidVersion = true; @@ -141,4 +161,116 @@ export default class DataRequestService { } return 0; } + + buildUpdateObject(data) { + let updateObj = {}; + let { aboutApplication, questionAnswers, updatedQuestionId, user, jsonSchema = '' } = data; + if (aboutApplication) { + const { datasetIds, datasetTitles } = aboutApplication.selectedDatasets.reduce( + (newObj, dataset) => { + newObj.datasetIds = [...newObj.datasetIds, dataset.datasetId]; + newObj.datasetTitles = [...newObj.datasetTitles, dataset.name]; + return newObj; + }, + { datasetIds: [], datasetTitles: [] } + ); + + updateObj = { aboutApplication, datasetIds, datasetTitles }; + } + if (questionAnswers) { + updateObj = { ...updateObj, questionAnswers, updatedQuestionId, user }; + } + + if (!isEmpty(jsonSchema)) { + updateObj = { ...updateObj, jsonSchema }; + } + + return updateObj; + } + + async updateApplication(accessRecord, updateObj) { + // 1. Extract properties + let { applicationStatus, _id } = accessRecord; + let { updatedQuestionId = '', user } = updateObj; + // 2. If application is in progress, update initial question answers + if (applicationStatus === constants.applicationStatuses.INPROGRESS) { + await this.dataRequestRepository.updateApplicationById(_id, updateObj, { new: true }); + // 3. Else if application has already been submitted make amendment + } else if ( + applicationStatus === constants.applicationStatuses.INREVIEW || + applicationStatus === constants.applicationStatuses.SUBMITTED + ) { + if (isNil(updateObj.questionAnswers)) { + return accessRecord; + } + let updatedAnswer = updateObj.questionAnswers[updatedQuestionId]; + accessRecord = amendmentService.handleApplicantAmendment(accessRecord, updatedQuestionId, '', updatedAnswer, user); + await this.dataRequestRepository.replaceApplicationById(_id, accessRecord); + } + return accessRecord; + } + + async uploadFiles(accessRecord, files, descriptions, ids, userId) { + let fileArr = []; + // Check and see if descriptions and ids are an array + let descriptionArray = Array.isArray(descriptions); + let idArray = Array.isArray(ids); + // Process the files for scanning + for (let i = 0; i < files.length; i++) { + // Get description information + let description = descriptionArray ? descriptions[i] : descriptions; + // Get uniqueId + let generatedId = idArray ? ids[i] : ids; + // Remove - from uuidV4 + let uniqueId = generatedId.replace(/-/gim, ''); + // Send to db + const response = await processFile(files[i], accessRecord._id, uniqueId); + // Deconstruct response + let { status } = response; + // Setup fileArr for mongoo + let newFile = { + status: status.trim(), + description: description.trim(), + fileId: uniqueId, + size: files[i].size, + name: files[i].originalname, + owner: userId, + 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); + } + // Write back into mongo [{userId, fileName, status: enum, size}] + let updatedRecord = await this.dataRequestRepository.saveFileUploadChanges(accessRecord); + + // Process access record into object + let record = updatedRecord._doc; + // Fetch files + let mediaFiles = record.files.map(f => { + return f._doc; + }); + + return mediaFiles; + } + + doInitialSubmission (accessRecord) { + // 1. Update application to submitted status + accessRecord.submissionType = constants.submissionTypes.INITIAL; + accessRecord.applicationStatus = constants.applicationStatuses.SUBMITTED; + // 2. Check if workflow/5 Safes based application, set final status date if status will never change again + if (has(accessRecord.toObject(), 'publisherObj')) { + if (!accessRecord.publisherObj.workflowEnabled) { + accessRecord.dateFinalStatus = new Date(); + accessRecord.workflowEnabled = false; + } else { + accessRecord.workflowEnabled = true; + } + } + const dateSubmitted = new Date(); + accessRecord.dateSubmitted = dateSubmitted; + // 3. Return updated access record for saving + return accessRecord; + } } diff --git a/src/resources/publisher/publisher.service.js b/src/resources/publisher/publisher.service.js index 4a6d7678..cfeae707 100644 --- a/src/resources/publisher/publisher.service.js +++ b/src/resources/publisher/publisher.service.js @@ -54,12 +54,12 @@ export default class PublisherService { const filteredApplications = [...applications].filter(app => { let { workflow = {} } = app; if (isEmpty(workflow)) { - return app; + return; } let { steps = [] } = workflow; if (isEmpty(steps)) { - return app; + return; } let activeStepIndex = findIndex(steps, function (step) { diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 1c42ac5c..365628cb 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -49,24 +49,46 @@ const _userQuestionActions = { }, ], inReview: { - custodian: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - ], - applicant: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - ], + custodian: { + latestVersion: [ + { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + ], + previousVersion: [ + { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + ], + }, + applicant: { + latestVersion: [ + { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + ], + previousVersion: [ + { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + ], + }, }, approved: [ { @@ -143,15 +165,26 @@ const _userQuestionActions = { }, ], }, - applicant: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - ], + applicant: { + latestVersion: [ + { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + ], + previousVersion: [ + { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + ], + }, }, approved: [ { diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 7d5ad1f0..a6817369 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -568,7 +568,7 @@ const _generateDARStatusChangedEmail = options => { const _generateDARClonedEmail = options => { let { id, projectId, projectName, datasetTitles, dateSubmitted, applicants, firstname, lastname } = options; - dateSubmitted = isNil(dateSubmitted) ? 'Not yet submitted' : moment(dateSubmitted).format('D MMM YYYY'); + dateSubmitted = isNil(dateSubmitted) || isEmpty(dateSubmitted) ? 'Not yet submitted' : moment(dateSubmitted).format('D MMM YYYY'); let body = `
{ + return { ...step, active: false }; + }); + workflowObj.steps[0].active = true; + workflowObj.steps[0].startDateTime = new Date(); + // Update application with attached workflow + accessRecord.workflowId = workflowId; + accessRecord.workflow = workflowObj; + await accessRecord.save(); + + return accessRecord; } } diff --git a/src/resources/workflow/workflow.service.js b/src/resources/workflow/workflow.service.js index 9fc90f7d..568f4c38 100644 --- a/src/resources/workflow/workflow.service.js +++ b/src/resources/workflow/workflow.service.js @@ -6,6 +6,8 @@ import notificationBuilder from '../utilities/notificationBuilder'; import moment from 'moment'; import { isEmpty, has } from 'lodash'; +const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); + export default class WorkflowService { constructor(workflowRepository) { this.workflowRepository = workflowRepository; @@ -52,6 +54,14 @@ export default class WorkflowService { return formattedWorkflows; } + async assignWorkflowToApplication(accessRecord, workflowId) { + return this.workflowRepository.assignWorkflowToApplication(accessRecord, workflowId); + } + + getWorkflowById(id){ + return this.workflowRepository.getWorkflowById(id); + } + getWorkflowDetails(accessRecord, requestingUserId) { if (!has(accessRecord, 'publisherObj.team.members')) return accessRecord; @@ -413,9 +423,9 @@ export default class WorkflowService { return { inReviewMode, reviewSections, hasRecommended }; } - getWorkflowEmailContext(accessRecord, workflow, relatedStepIndex) { + getWorkflowEmailContext(accessRecord, relatedStepIndex = 0) { // Extract workflow email variables - const { dateReviewStart = '' } = accessRecord; + const { dateReviewStart = '', workflow = {} } = accessRecord; const { workflowName, steps } = workflow; const { stepName, startDateTime = '', endDateTime = '', completed = false, deadline: stepDeadline = 0, reminderOffset = 0 } = steps[ relatedStepIndex @@ -510,4 +520,19 @@ export default class WorkflowService { remainingReviewerUserIds, }; } + + startWorkflow(accessRecord, requestingUserObjectId) { + const { publisherObj: { name: dataRequestPublisher }, _id, workflow } = accessRecord; + const reviewerList = workflow.steps[0].reviewers.map(reviewer => reviewer._id.toString()); + const bpmContext = { + businessKey: _id, + dataRequestStatus: constants.applicationStatuses.INREVIEW, + dataRequestUserId: requestingUserObjectId.toString(), + dataRequestPublisher, + dataRequestStepName: workflow.steps[0].stepName, + notifyReviewerSLA: this.calculateStepDeadlineReminderDate(workflow.steps[0]), + reviewerList, + }; + bpmController.postStartStepReview(bpmContext); + } } From 60a641ef3a4afac96f191aacf3d8953c9e8fab5d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 17 May 2021 14:11:42 +0100 Subject: [PATCH 18/81] Continued build --- .../datarequest/datarequest.controller.js | 239 +++++++++--------- .../datarequest/datarequest.route.js | 20 +- 2 files changed, 129 insertions(+), 130 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 1975c909..27356b1c 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -443,6 +443,125 @@ export default class DataRequestController extends Controller { } } + //POST api/v1/data-access-request/:id/actions + async performAction(req, res) { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + let { questionId, questionSetId, questionIds = [], mode, separatorText = '' } = req.body; + if (_.isEmpty(questionId) || _.isEmpty(questionSetId)) { + return res.status(400).json({ + success: false, + message: 'You must supply the unique identifiers for the question to perform an action', + }); + } + + // 2. Retrieve DAR from database + let accessRecord = await this.dataRequestService.getApplicationWithTeamById(id, { lean: false }); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + + // 3. If application is not in progress, actions cannot be performed + if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { + return res.status(400).json({ + success: false, + message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', + }); + } + // 4. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord.toObject(), + requestingUserId, + requestingUserObjectId + ); + + // 5. Return unauthorised message if the requesting user is not an applicant + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 6. Extract schema and answers + let { jsonSchema, questionAnswers } = _.cloneDeep(accessRecord); + + // 7. Perform different action depending on mode passed + switch (mode) { + case constants.formActions.ADDREPEATABLESECTION: + const duplicateQuestionSet = dynamicForm.duplicateQuestionSet(questionSetId, jsonSchema); + jsonSchema = dynamicForm.insertQuestionSet(questionSetId, duplicateQuestionSet, jsonSchema); + break; + case constants.formActions.REMOVEREPEATABLESECTION: + jsonSchema = dynamicForm.removeQuestionSetReferences(questionSetId, questionId, jsonSchema); + questionAnswers = dynamicForm.removeQuestionSetAnswers(questionId, questionAnswers); + break; + case constants.formActions.ADDREPEATABLEQUESTIONS: + if (_.isEmpty(questionIds)) { + return res.status(400).json({ + success: false, + message: 'You must supply the question identifiers to duplicate when performing this action', + }); + } + const duplicateQuestions = dynamicForm.duplicateQuestions(questionSetId, questionIds, separatorText, jsonSchema); + jsonSchema = dynamicForm.insertQuestions(questionSetId, questionId, duplicateQuestions, jsonSchema); + break; + case constants.formActions.REMOVEREPEATABLEQUESTIONS: + if (_.isEmpty(questionIds)) { + return res.status(400).json({ + success: false, + message: 'You must supply the question identifiers to remove when performing this action', + }); + } + questionIds = [...questionIds, questionId]; + jsonSchema = dynamicForm.removeQuestionReferences(questionSetId, questionIds, jsonSchema); + questionAnswers = dynamicForm.removeQuestionAnswers(questionIds, questionAnswers); + break; + default: + return res.status(400).json({ + success: false, + message: 'You must supply a valid action to perform', + }); + } + + // 8. Update record + accessRecord.jsonSchema = jsonSchema; + accessRecord.questionAnswers = questionAnswers; + + // 9. Save changes to database + await accessRecord.save().catch(err => { + logger.logError(err, logCategory); + }); + + // 10. Append question actions for in progress applicant + jsonSchema = datarequestUtil.injectQuestionActions( + jsonSchema, + constants.userTypes.APPLICANT, // current user type + constants.applicationStatuses.INPROGRESS, + null, + constants.userTypes.APPLICANT // active party + ); + + // 11. Return necessary object to reflect schema update + return res.status(200).json({ + success: true, + accessRecord: { + jsonSchema, + questionAnswers, + }, + }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the application amendment', + }); + } + } + //POST api/v1/data-access-request/:id/upload async uploadFiles(req, res) { try { @@ -2288,124 +2407,4 @@ module.exports = { res.status(500).json({ status: 'error', message: err.message }); } }, - - //POST api/v1/data-access-request/:id/actions - performAction: async (req, res) => { - try { - // 1. Get the required request params - const { - params: { id }, - } = req; - let { questionId, questionSetId, questionIds = [], mode, separatorText = '' } = req.body; - if (_.isEmpty(questionId) || _.isEmpty(questionSetId)) { - return res.status(400).json({ - success: false, - message: 'You must supply the unique identifiers for the question to perform an action', - }); - } - // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ - { - path: 'datasets dataset', - }, - { - path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - ]); - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - // 3. If application is not in progress, actions cannot be performed - if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { - return res.status(400).json({ - success: false, - message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', - }); - } - // 4. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), req.user.id, req.user._id); - // 5. Return unauthorised message if the requesting user is not an applicant - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - // 6. Extract schema and answers - let { jsonSchema, questionAnswers } = _.cloneDeep(accessRecord); - // 7. Perform different action depending on mode passed - switch (mode) { - case constants.formActions.ADDREPEATABLESECTION: - let duplicateQuestionSet = dynamicForm.duplicateQuestionSet(questionSetId, jsonSchema); - jsonSchema = dynamicForm.insertQuestionSet(questionSetId, duplicateQuestionSet, jsonSchema); - break; - case constants.formActions.REMOVEREPEATABLESECTION: - jsonSchema = dynamicForm.removeQuestionSetReferences(questionSetId, questionId, jsonSchema); - questionAnswers = dynamicForm.removeQuestionSetAnswers(questionId, questionAnswers); - break; - case constants.formActions.ADDREPEATABLEQUESTIONS: - if (_.isEmpty(questionIds)) { - return res.status(400).json({ - success: false, - message: 'You must supply the question identifiers to duplicate when performing this action', - }); - } - let duplicateQuestions = dynamicForm.duplicateQuestions(questionSetId, questionIds, separatorText, jsonSchema); - jsonSchema = dynamicForm.insertQuestions(questionSetId, questionId, duplicateQuestions, jsonSchema); - break; - case constants.formActions.REMOVEREPEATABLEQUESTIONS: - if (_.isEmpty(questionIds)) { - return res.status(400).json({ - success: false, - message: 'You must supply the question identifiers to remove when performing this action', - }); - } - questionIds = [...questionIds, questionId]; - jsonSchema = dynamicForm.removeQuestionReferences(questionSetId, questionIds, jsonSchema); - questionAnswers = dynamicForm.removeQuestionAnswers(questionIds, questionAnswers); - break; - default: - return res.status(400).json({ - success: false, - message: 'You must supply a valid action to perform', - }); - } - // 8. Update record - accessRecord.jsonSchema = jsonSchema; - accessRecord.questionAnswers = questionAnswers; - // 9. Save changes to database - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - return res.status(500).json({ status: 'error', message: err.message }); - } else { - // 10. Append question actions for in progress applicant - jsonSchema = datarequestUtil.injectQuestionActions( - jsonSchema, - constants.userTypes.APPLICANT, // current user type - constants.applicationStatuses.INPROGRESS, - null, - constants.userTypes.APPLICANT // active party - ); - // 11. Return necessary object to reflect schema update - return res.status(200).json({ - success: true, - accessRecord: { - jsonSchema, - questionAnswers, - }, - }); - } - }); - } catch (err) { - console.error(err.message); - return res.status(500).json({ - success: false, - message: 'An error occurred updating the application amendment', - }); - } - }, }; diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 8df128ce..fd243d5b 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -173,7 +173,15 @@ router.post( (req, res) => dataRequestController.notifyAccessRequestById(req, res) ); - +// @route POST api/v1/data-access-request/:id/actions +// @desc Perform an action on a presubmitted application form e.g. add/remove repeatable section +// @access Private - Applicant +router.post( + '/:id/actions', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Performing a user triggered action on a Data Access Request application' }), + (req, res) => dataRequestController.performAction(req, res) +); @@ -258,15 +266,7 @@ router.post( (req, res) => amendmentController.requestAmendments(req, res) ); -// @route POST api/v1/data-access-request/:id/actions -// @desc Perform an action on a presubmitted application form e.g. add/remove repeatable section -// @access Private - Applicant -router.post( - '/:id/actions', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Performing a user trggered action on a Data Access Request application' }), - datarequestController.performAction -); + From 08893a4d61607a7dd71e9f10240af754ae52ede4 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 17 May 2021 14:12:01 +0100 Subject: [PATCH 19/81] Continued build --- src/resources/datarequest/datarequest.route.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index fd243d5b..31b388af 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -129,7 +129,6 @@ router.get( (req, res) => dataRequestController.getFileStatus(req, res) ); - // @route PUT api/v1/data-access-request/:id/deletefile // @desc Update access request deleting a file by Id // @access Private - Applicant (Gateway User) From 13a4f1456be86004a29a9b4e79228080019a93cd Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 17 May 2021 16:56:05 +0100 Subject: [PATCH 20/81] Continued build --- .../datarequest/datarequest.controller.js | 1146 ++++++++--------- .../datarequest/datarequest.repository.js | 2 +- .../datarequest/datarequest.route.js | 68 +- .../datarequest/utils/datarequest.util.js | 2 +- 4 files changed, 587 insertions(+), 631 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 27356b1c..9b1795ed 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -395,7 +395,165 @@ export default class DataRequestController extends Controller { } } - // API DELETE api/v1/data-access-request/:id + //PUT api/v1/data-access-request/:id + async updateAccessRequestById(req, res) { + try { + // 1. Id is the _id object in MongoDb not the generated id or dataset Id + const { + params: { id }, + } = req; + // 2. Get the userId + const requestingUser = req.user; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + + let applicationStatus = '', + applicationStatusDesc = ''; + + // 3. Find the relevant data request application + let accessRecord = await this.dataRequestService.getApplicationWithWorkflowById(id, { lean: false }); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + + // 4. Check if the user is permitted to perform update to application + let isDirty = false, + statusChange = false, + contributorChange = false; + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord.toObject(), + requestingUserId, + requestingUserObjectId + ); + + if (!authorised) { + return res.status(401).json({ + status: 'error', + message: 'Unauthorised to perform this update.', + }); + } + + let { authorIds: currentAuthors } = accessRecord; + let newAuthors = []; + + // 5. Extract new application status and desc to save updates + if (userType === constants.userTypes.CUSTODIAN) { + // Only a custodian manager can set the final status of an application + authorised = false; + const { team = {} } = accessRecord.publisherObj.toObject(); + if (!_.isEmpty(team)) { + authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserObjectId); + } + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + // Extract params from body + ({ applicationStatus, applicationStatusDesc } = req.body); + const finalStatuses = [ + constants.applicationStatuses.SUBMITTED, + constants.applicationStatuses.APPROVED, + constants.applicationStatuses.REJECTED, + constants.applicationStatuses.APPROVEDWITHCONDITIONS, + constants.applicationStatuses.WITHDRAWN, + ]; + if (applicationStatus) { + accessRecord.applicationStatus = applicationStatus; + + if (finalStatuses.includes(applicationStatus)) { + accessRecord.dateFinalStatus = new Date(); + } + 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) { + accessRecord.applicationStatusDesc = inputSanitizer.removeNonBreakingSpaces(applicationStatusDesc); + isDirty = true; + } + // If applicant, allow update to contributors/authors + } else if (userType === constants.userTypes.APPLICANT) { + // Extract new contributor/author IDs + if (req.body.authorIds) { + ({ authorIds: newAuthors } = req.body); + + // Perform comparison between new and existing authors to determine if an update is required + if (newAuthors && !helper.arraysEqual(newAuthors, currentAuthors)) { + accessRecord.authorIds = newAuthors; + isDirty = true; + contributorChange = true; + } + } + } + // 6. If a change has been made, notify custodian and main applicant + if (isDirty) { + await accessRecord.save().catch(err => { + logger.logError(err, logCategory); + }); + + // If save has succeeded - send notifications + // Send notifications to added/removed contributors + if (contributorChange) { + await this.createNotifications( + constants.notificationTypes.CONTRIBUTORCHANGE, + { newAuthors, currentAuthors }, + accessRecord, + requestingUser + ); + } + if (statusChange) { + // Send notifications to custodian team, main applicant and contributors regarding status change + await this.createNotifications( + constants.notificationTypes.STATUSCHANGE, + { applicationStatus, applicationStatusDesc }, + accessRecord, + requestingUser + ); + // Ensure Camunda ends workflow processes given that manager has made final decision + let { name: dataRequestPublisher } = accessRecord.publisherObj; + let bpmContext = { + dataRequestStatus: applicationStatus, + dataRequestManagerId: requestingUserObjectId.toString(), + dataRequestPublisher, + managerApproved: true, + businessKey: id, + }; + bpmController.postManagerApproval(bpmContext); + } + } + // 7. Return application + return res.status(200).json({ + status: 'success', + data: accessRecord._doc, + }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the application', + }); + } + } + + //DELETE api/v1/data-access-request/:id async deleteDraftAccessRequest(req, res) { try { // 1. Get the required request and body params @@ -543,7 +701,7 @@ export default class DataRequestController extends Controller { null, constants.userTypes.APPLICANT // active party ); - + // 11. Return necessary object to reflect schema update return res.status(200).json({ success: true, @@ -876,90 +1034,430 @@ export default class DataRequestController extends Controller { } } - //POST api/v1/data-access-request/:id/notify - async notifyAccessRequestById(req, res) { + //PUT api/v1/data-access-request/:id/stepoverride + async updateAccessRequestStepOverride(req, res) { try { // 1. Get the required request params const { params: { id }, } = req; + const requestingUser = req.user; + const requestingUserObjectId = req.user._id; // 2. Retrieve DAR from database - const accessRecord = await this.dataRequestService.getApplicationWithWorkflowById(id); + let accessRecord = await this.dataRequestService.getApplicationWithWorkflowById(id, { lean: false }); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } - const { workflow } = accessRecord; - if (_.isEmpty(workflow)) { + + // 3. Check permissions of user is manager of associated team + let authorised = false; + if (_.has(accessRecord.toObject(), 'publisherObj.team')) { + const { team } = accessRecord.publisherObj; + authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team.toObject(), requestingUserObjectId); + } + + // 4. Refuse access if not authorised + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 5. Check application is in review state + const { applicationStatus } = accessRecord; + if (applicationStatus !== constants.applicationStatuses.INREVIEW) { return res.status(400).json({ - status: 'error', - message: 'There is no workflow attached to this application.', + success: false, + message: 'The application status must be set to in review', }); } - const activeStepIndex = workflow.steps.findIndex(step => { + + // 6. Check a workflow is assigned with valid steps + const { workflow = {} } = accessRecord; + const { 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 + const activeStepIndex = steps.findIndex(step => { return step.active === true; }); - // 3. Determine email context if deadline has elapsed or is approaching - const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, activeStepIndex); - // 4. Send emails based on deadline elapsed or approaching - if (emailContext.deadlineElapsed) { - this.createNotifications(constants.notificationTypes.DEADLINEPASSED, emailContext, accessRecord, req.user); + 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 + const bpmContext = this.workflowService.buildNextStep(requestingUserObjectId, 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().catch(err => { + logger.logError(err, logCategory); + }); + + // 12. Gather context for notifications (active step) + let emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, activeStepIndex); + + // 13. Create notifications to reviewers of the step that has been completed + this.createNotifications(constants.notificationTypes.STEPOVERRIDE, emailContext, accessRecord, requestingUser); + + // 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 = constants.notificationTypes.FINALDECISIONREQUIRED; } else { - this.createNotifications(constants.notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user); + // Create notifications to reviewers of the next step that has been activated + relevantStepIndex = activeStepIndex + 1; + relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART; + } + // Get the email context only if required + if (relevantStepIndex !== activeStepIndex) { + emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, relevantStepIndex); } + this.createNotifications(relevantNotificationType, emailContext, accessRecord, requestingUser); + + // 15. Call Camunda controller to start manager review process + bpmController.postCompleteReview(bpmContext); + + // 16. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { // Return error response if something goes wrong logger.logError(err, logCategory); return res.status(500).json({ success: false, - message: 'An error occurred triggering notifications for workflow review deadlines', + message: 'An error occurred assigning the workflow', }); } } - //POST api/v1/data-access-request/:id/email - async mailDataAccessRequestInfoById(req, res) { + //PUT api/v1/data-access-request/:id/vote + async updateAccessRequestReviewVote(req, res) { try { // 1. Get the required request params const { params: { id }, } = req; - const requestingUserId = parseInt(req.user.id); - const requestingUserObjectId = req.user._id; const requestingUser = req.user; + const requestingUserObjectId = req.user._id; + const { 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 - const accessRecord = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true }); - + let accessRecord = await this.dataRequestService.getApplicationWithWorkflowById(id, { lean: false }); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } - // 3. If application is not in progress, actions cannot be performed - if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { + // 3. Check permissions of user is reviewer of associated team + let authorised = false; + if (_.has(accessRecord.toObject(), 'publisherObj.team')) { + const { team } = accessRecord.publisherObj; + authorised = teamController.checkTeamPermissions(constants.roleTypes.REVIEWER, team.toObject(), requestingUserObjectId); + } + + // 4. Refuse access if not authorised + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 5. Check application is in-review + const { applicationStatus } = accessRecord; + if (applicationStatus !== constants.applicationStatuses.INREVIEW) { return res.status(400).json({ success: false, - message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', + message: 'The application status must be set to in review to cast a vote', }); } - // 4. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( - accessRecord, - requestingUserId, - requestingUserObjectId - ); - // 5. Return unauthorised message if the requesting user is not an applicant - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + // 6. Ensure a workflow has been attached to this application + const { 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', + }); } - // 6. Send notification to the authorised user - this.createNotifications(constants.notificationTypes.INPROGRESS, {}, accessRecord, requestingUser); - - return res.status(200).json({ status: 'success' }); - } catch (err) { - // Return error response if something goes wrong + // 7. Ensure the requesting user is expected to cast a vote + const { steps } = workflow; + const activeStepIndex = steps.findIndex(step => { + return step.active === true; + }); + if (!steps[activeStepIndex].reviewers.map(reviewer => reviewer._id.toString()).includes(requestingUserObjectId.toString())) { + 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 + const { recommendations = [] } = steps[activeStepIndex]; + if (recommendations) { + let found = recommendations.some(rec => { + return rec.reviewer.equals(requestingUserObjectId); + }); + if (found) { + return res.status(400).json({ + success: false, + message: 'You have already voted on this review phase', + }); + } + } + + // 9. Create new recommendation + const newRecommendation = { + approved, + comments, + reviewer: new mongoose.Types.ObjectId(requestingUserObjectId), + createdDate: new Date(), + }; + + // 10. Update access record with recommendation + accessRecord.workflow.steps[activeStepIndex].recommendations = [ + ...accessRecord.workflow.steps[activeStepIndex].recommendations, + newRecommendation, + ]; + + // 11. Workflow management - construct Camunda payloads + const bpmContext = this.workflowService.buildNextStep(requestingUserObjectId, 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(); + } + + // 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().catch(err => { + logger.logError(err, logCategory); + }); + + // 15. Create emails and notifications + let relevantStepIndex = 0, + relevantNotificationType = ''; + if (bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { + // Create notifications to reviewers of the next step that has been activated + relevantStepIndex = activeStepIndex + 1; + relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART; + } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { + // Create notifications to managers that the application is awaiting final approval + relevantStepIndex = activeStepIndex; + relevantNotificationType = constants.notificationTypes.FINALDECISIONREQUIRED; + } + // Continue only if notification required + if (!_.isEmpty(relevantNotificationType)) { + const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, relevantStepIndex); + this.createNotifications(relevantNotificationType, emailContext, accessRecord, requestingUser); + } + + // 16. Call Camunda controller to update workflow process + bpmController.postCompleteReview(bpmContext); + + // 17. Return aplication and successful response + return res.status(200).json({ status: 'success', data: accessRecord._doc }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred assigning the workflow', + }); + } + } + + //PUT api/v1/data-access-request/:id/startreview + async updateAccessRequestStartReview(req, res) { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + const requestingUserObjectId = req.user._id; + + // 2. Retrieve DAR from database + let accessRecord = await this.dataRequestService.getApplicationWithTeamById(id, { lean: false }); + 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')) { + const { team } = accessRecord.publisherObj; + authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team.toObject(), requestingUserObjectId); + } + + // 4. Refuse access if not authorised + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 5. Check application is in submitted state + const { applicationStatus } = accessRecord; + if (applicationStatus !== constants.applicationStatuses.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 = constants.applicationStatuses.INREVIEW; + accessRecord.dateReviewStart = new Date(); + + // 7. Save update to access record + await accessRecord.save().catch(err => { + logger.logError(err, logCategory); + }); + + // 8. Call Camunda controller to get pre-review process + const response = await bpmController.getProcess(id); + const { data = {} } = response; + if (!_.isEmpty(data)) { + const [obj] = data; + const { id: taskId } = obj; + const { + publisherObj: { name }, + } = accessRecord; + const bpmContext = { + taskId, + applicationStatus, + managerId: requestingUserObjectId.toString(), + publisher: name, + notifyManager: 'P999D', + }; + + // 9. Call Camunda controller to start manager review process + bpmController.postStartManagerReview(bpmContext); + } + + // 10. Return aplication and successful response + return res.status(200).json({ status: 'success' }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred assigning the workflow', + }); + } + } + + //POST api/v1/data-access-request/:id/notify + async notifyAccessRequestById(req, res) { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + // 2. Retrieve DAR from database + const accessRecord = await this.dataRequestService.getApplicationWithWorkflowById(id); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + const { workflow } = accessRecord; + if (_.isEmpty(workflow)) { + return res.status(400).json({ + status: 'error', + message: 'There is no workflow attached to this application.', + }); + } + const activeStepIndex = workflow.steps.findIndex(step => { + return step.active === true; + }); + // 3. Determine email context if deadline has elapsed or is approaching + const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, activeStepIndex); + // 4. Send emails based on deadline elapsed or approaching + if (emailContext.deadlineElapsed) { + this.createNotifications(constants.notificationTypes.DEADLINEPASSED, emailContext, accessRecord, req.user); + } else { + this.createNotifications(constants.notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user); + } + return res.status(200).json({ status: 'success' }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred triggering notifications for workflow review deadlines', + }); + } + } + + //POST api/v1/data-access-request/:id/email + async mailDataAccessRequestInfoById(req, res) { + try { + // 1. Get the required request params + const { + params: { id }, + } = req; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + const requestingUser = req.user; + + // 2. Retrieve DAR from database + const accessRecord = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true }); + + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + + // 3. If application is not in progress, actions cannot be performed + if (accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) { + return res.status(400).json({ + success: false, + message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', + }); + } + + // 4. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); + // 5. Return unauthorised message if the requesting user is not an applicant + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 6. Send notification to the authorised user + this.createNotifications(constants.notificationTypes.INPROGRESS, {}, accessRecord, requestingUser); + + return res.status(200).json({ status: 'success' }); + } catch (err) { + // Return error response if something goes wrong logger.logError(err, logCategory); return res.status(500).json({ success: false, @@ -1065,20 +1563,19 @@ export default class DataRequestController extends Controller { case constants.notificationTypes.STATUSCHANGE: // 1. Create notifications // Custodian manager and current step reviewer notifications - if (_.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, constants.roleTypes.MANAGER); - let activeStep = this.workflowService.getActiveWorkflowStep(workflow); - stepReviewers = this.workflowService.getStepReviewers(activeStep); - // Create custodian notification - 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}`, - 'data access request', - accessRecord._id - ); - } + // Retrieve all custodian manager user Ids and active step reviewers + custodianManagers = teamController.getTeamMembersByRole(accessRecord.publisherObj.team, constants.roleTypes.MANAGER); + let activeStep = this.workflowService.getActiveWorkflowStep(workflow); + stepReviewers = this.workflowService.getStepReviewers(activeStep); + // Create custodian notification + 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}`, + 'data access request', + accessRecord._id + ); + // Create applicant notification await notificationBuilder.triggerNotificationMessage( [accessRecord.userId], @@ -1320,7 +1817,7 @@ export default class DataRequestController extends Controller { // Find related user objects and filter out users who have not opted in to email communications let addedUsers = await UserModel.find({ id: { $in: addedAuthors }, - }).populate('additionalInfo'); + }); await notificationBuilder.triggerNotificationMessage( addedUsers.map(user => user.id), @@ -1343,7 +1840,7 @@ export default class DataRequestController extends Controller { // Find related user objects and filter out users who have not opted in to email communications let removedUsers = await UserModel.find({ id: { $in: removedAuthors }, - }).populate('additionalInfo'); + }); await notificationBuilder.triggerNotificationMessage( removedUsers.map(user => user.id), @@ -1872,539 +2369,4 @@ module.exports = { res.status(500).json({ status: 'error', message: err.message }); } }, - - //PUT api/v1/data-access-request/:id - updateAccessRequestById: async (req, res) => { - try { - // 1. Id is the _id object in MongoDb not the generated id or dataset Id - const { - params: { id }, - } = req; - // 2. Get the userId - let { _id, id: userId } = req.user; - let applicationStatus = '', - 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', - populate: { - path: 'team', - populate: { - path: 'users', - populate: { - path: 'additionalInfo', - }, - }, - }, - }, - }, - { - path: 'publisherObj', - populate: { - path: 'team', - }, - }, - { - path: 'workflow.steps.reviewers', - select: 'id email', - }, - ]); - - if (!accessRecord) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) - if (_.isEmpty(accessRecord.datasets)) { - accessRecord.datasets = [accessRecord.dataset]; - } - - // 5. Check if the user is permitted to perform update to application - let isDirty = false, - statusChange = false, - contributorChange = false; - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), userId, _id); - - if (!authorised) { - return res.status(401).json({ - status: 'error', - message: 'Unauthorised to perform this update.', - }); - } - - let { authorIds: currentAuthors } = accessRecord; - let newAuthors = []; - - // 6. Extract new application status and desc to save updates - if (userType === constants.userTypes.CUSTODIAN) { - // Only a custodian manager can set the final status of an application - authorised = false; - let team = {}; - if (_.isNull(accessRecord.publisherObj)) { - ({ team = {} } = accessRecord.datasets[0].publisher.toObject()); - } else { - ({ team = {} } = accessRecord.publisherObj.toObject()); - } - - if (!_.isEmpty(team)) { - authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, _id); - } - - if (!authorised) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - // Extract params from body - ({ applicationStatus, applicationStatusDesc } = req.body); - const finalStatuses = [ - constants.applicationStatuses.SUBMITTED, - constants.applicationStatuses.APPROVED, - constants.applicationStatuses.REJECTED, - constants.applicationStatuses.APPROVEDWITHCONDITIONS, - constants.applicationStatuses.WITHDRAWN, - ]; - if (applicationStatus) { - accessRecord.applicationStatus = applicationStatus; - - if (finalStatuses.includes(applicationStatus)) { - accessRecord.dateFinalStatus = new Date(); - } - 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) { - accessRecord.applicationStatusDesc = inputSanitizer.removeNonBreakingSpaces(applicationStatusDesc); - isDirty = true; - } - // If applicant, allow update to contributors/authors - } else if (userType === constants.userTypes.APPLICANT) { - // Extract new contributor/author IDs - if (req.body.authorIds) { - ({ authorIds: newAuthors } = req.body); - - // Perform comparison between new and existing authors to determine if an update is required - if (newAuthors && !helper.arraysEqual(newAuthors, currentAuthors)) { - accessRecord.authorIds = newAuthors; - isDirty = true; - contributorChange = true; - } - } - } - // 7. If a change has been made, notify custodian and main applicant - if (isDirty) { - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - return res.status(500).json({ status: 'error', message: err.message }); - } else { - // If save has succeeded - send notifications - // Send notifications to added/removed contributors - if (contributorChange) { - await module.exports.createNotifications( - constants.notificationTypes.CONTRIBUTORCHANGE, - { newAuthors, currentAuthors }, - accessRecord, - req.user - ); - } - if (statusChange) { - // Send notifications to custodian team, main applicant and contributors regarding status change - await module.exports.createNotifications( - constants.notificationTypes.STATUSCHANGE, - { applicationStatus, applicationStatusDesc }, - accessRecord, - req.user - ); - // 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', - data: accessRecord._doc, - }); - } catch (err) { - console.error(err.message); - res.status(500).json({ - status: 'error', - message: 'An error occurred updating the application status', - }); - } - }, - - //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(constants.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 !== constants.applicationStatuses.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 = constants.applicationStatuses.INREVIEW; - accessRecord.dateReviewStart = new Date(); - // 7. Save update to access record - await accessRecord.save(async err => { - if (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } 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.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, - - //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', - 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.' }); - } - // 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(constants.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 !== constants.applicationStatuses.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.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', - }); - } - //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), - createdDate: new Date(), - }; - // 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 = this.workflowService.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(); - } - // 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.message); - res.status(500).json({ status: 'error', message: err.message }); - } else { - // 15. Create emails and notifications - let relevantStepIndex = 0, - relevantNotificationType = ''; - if (bpmContext.stepComplete && !bpmContext.finalPhaseApproved) { - // Create notifications to reviewers of the next step that has been activated - relevantStepIndex = activeStepIndex + 1; - relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART; - } else if (bpmContext.stepComplete && bpmContext.finalPhaseApproved) { - // Create notifications to managers that the application is awaiting final approval - relevantStepIndex = activeStepIndex; - relevantNotificationType = constants.notificationTypes.FINALDECISIONREQUIRED; - } - // Continue only if notification required - if (!_.isEmpty(relevantNotificationType)) { - const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, relevantStepIndex); - module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user); - } - // 16. Call Camunda controller to update workflow process - bpmController.postCompleteReview(bpmContext); - } - }); - // 17. Return aplication and successful response - return res.status(200).json({ status: 'success', data: accessRecord._doc }); - } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, - - //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', - 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.' }); - } - // 3. Check permissions of user is manager of associated team - let authorised = false; - if (_.has(accessRecord.toObject(), 'publisherObj.team')) { - let { team } = accessRecord.publisherObj; - authorised = teamController.checkTeamPermissions(constants.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 !== constants.applicationStatuses.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 = this.workflowService.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.message); - res.status(500).json({ status: 'error', message: err.message }); - } else { - // 12. Gather context for notifications (active step) - let emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, activeStepIndex); - // 13. Create notifications to reviewers of the step that has been completed - module.exports.createNotifications(constants.notificationTypes.STEPOVERRIDE, emailContext, accessRecord, req.user); - // 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 = constants.notificationTypes.FINALDECISIONREQUIRED; - } else { - // Create notifications to reviewers of the next step that has been activated - relevantStepIndex = activeStepIndex + 1; - relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART; - } - // Get the email context only if required - if (relevantStepIndex !== activeStepIndex) { - emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, relevantStepIndex); - } - module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user); - // 15. Call Camunda controller to start manager review process - bpmController.postCompleteReview(bpmContext); - } - }); - // 16. Return aplication and successful response - return res.status(200).json({ status: 'success' }); - } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, }; diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index ac200153..f410599e 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -79,7 +79,7 @@ export default class DataRequestRepository extends Repository { path: 'datasets dataset', }, { - path: 'mainApplicant', + path: 'mainApplicant authors', }, ]); } diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 31b388af..70f17861 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -182,29 +182,6 @@ router.post( (req, res) => dataRequestController.performAction(req, res) ); - - - -// @route GET api/v1/data-access-request/dataset/:datasetId -// @desc GET Access request for user -// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer -router.get( - '/dataset/:dataSetId', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via a dataset' }), - datarequestController.getAccessRequestByUserAndDataset -); - -// @route GET api/v1/data-access-request/datasets/:datasetIds -// @desc GET Access request with multiple datasets for user -// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer -router.get( - '/datasets/:datasetIds', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via multiple datasets' }), - datarequestController.getAccessRequestByUserAndMultipleDatasets -); - // @route PUT api/v1/data-access-request/:id // @desc Update request record by Id for status changes // @access Private - Custodian Manager and Applicant (Gateway User) @@ -212,7 +189,17 @@ router.put( '/:id', passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Updating the status of a Data Access Request application' }), - datarequestController.updateAccessRequestById + (req, res) => dataRequestController.updateAccessRequestById(req, res) +); + +// @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'), + logger.logRequestMiddleware({ logCategory, action: 'Overriding a workflow phase for a Data Access Request application' }), + (req, res) => dataRequestController.updateAccessRequestStepOverride(req, res) ); // @route PUT api/v1/data-access-request/:id/vote @@ -222,7 +209,7 @@ router.put( '/:id/vote', passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Voting against a review phase for a Data Access Request application' }), - datarequestController.updateAccessRequestReviewVote + (req, res) => dataRequestController.updateAccessRequestReviewVote(req, res) ); // @route PUT api/v1/data-access-request/:id/startreview @@ -232,17 +219,7 @@ router.put( '/:id/startreview', passport.authenticate('jwt'), logger.logRequestMiddleware({ logCategory, action: 'Starting the review process for a Data Access Request application' }), - 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'), - logger.logRequestMiddleware({ logCategory, action: 'Overriding a workflow phase for a Data Access Request application' }), - datarequestController.updateAccessRequestStepOverride + (req, res) => dataRequestController.updateAccessRequestStartReview(req, res) ); // @route POST api/v1/data-access-request/:id/amendments @@ -268,7 +245,24 @@ router.post( +// @route GET api/v1/data-access-request/dataset/:datasetId +// @desc GET Access request for user +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer +router.get( + '/dataset/:dataSetId', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via a dataset' }), + datarequestController.getAccessRequestByUserAndDataset +); - +// @route GET api/v1/data-access-request/datasets/:datasetIds +// @desc GET Access request with multiple datasets for user +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer +router.get( + '/datasets/:datasetIds', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via multiple datasets' }), + datarequestController.getAccessRequestByUserAndMultipleDatasets +); module.exports = router; diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index f16b686c..ac245c0c 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -37,7 +37,7 @@ const getUserPermissionsForApplication = (application, userId, _id) => { } else if (has(application, 'publisherObj.team')) { isTeamMember = teamController.checkTeamPermissions('', application.publisherObj.team, _id); } - if (isTeamMember) { + if (isTeamMember && application.applicationStatus !== constants.applicationStatuses.INPROGRESS) { userType = constants.userTypes.CUSTODIAN; authorised = true; } From acbd91c68d6249b8ebf8c041117bc4f98b51a971 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 26 May 2021 09:32:07 +0100 Subject: [PATCH 21/81] Continued versioning build --- .../1620558117918-applications_versioning.js | 2 +- .../amendment/__tests__/amendments.test.js | 16 +- .../amendment/amendment.controller.js | 84 ++- .../amendment/amendment.service.js | 71 +-- .../datarequest/datarequest.controller.js | 522 +++++++----------- .../datarequest/datarequest.entity.js | 103 +++- .../datarequest/datarequest.model.js | 1 + .../datarequest/datarequest.repository.js | 71 +++ .../datarequest/datarequest.route.js | 37 +- .../datarequest/datarequest.schemas.model.js | 1 + .../datarequest/datarequest.schemas.route.js | 3 +- .../datarequest/datarequest.service.js | 98 +++- .../publisher/publisher.controller.js | 4 +- .../publisher/publisher.repository.js | 4 +- src/resources/team/team.controller.js | 1 + src/resources/team/team.model.js | 1 + src/resources/workflow/workflow.controller.js | 6 +- src/resources/workflow/workflow.repository.js | 3 +- src/resources/workflow/workflow.route.js | 3 +- src/resources/workflow/workflow.service.js | 6 +- 20 files changed, 560 insertions(+), 477 deletions(-) diff --git a/migrations/1620558117918-applications_versioning.js b/migrations/1620558117918-applications_versioning.js index 0a09994d..d0382243 100644 --- a/migrations/1620558117918-applications_versioning.js +++ b/migrations/1620558117918-applications_versioning.js @@ -19,7 +19,7 @@ async function up() { filter: { _id }, update: { applicationType: 'Initial', - majorVersion: 1, + majorVersion: 1.0, version: undefined, versionTree, }, diff --git a/src/resources/datarequest/amendment/__tests__/amendments.test.js b/src/resources/datarequest/amendment/__tests__/amendments.test.js index 3febd82f..9620dc49 100755 --- a/src/resources/datarequest/amendment/__tests__/amendments.test.js +++ b/src/resources/datarequest/amendment/__tests__/amendments.test.js @@ -1,4 +1,5 @@ import constants from '../../../utilities/constants.util'; +import DataRequestClass from '../../datarequest.entity'; import { amendmentService } from '../dependency'; import _ from 'lodash'; @@ -528,17 +529,18 @@ describe('doResubmission', () => { test('given a data access record is resubmitted with a valid amendment iteration, then the iteration is updated to submitted', () => { // Arrange let data = _.cloneDeep(dataRequest[4]); + let accessRecord = new DataRequestClass(data); // Act - data = amendmentService.doResubmission(data, users.applicant._id); + accessRecord = amendmentService.doResubmission(accessRecord, users.applicant._id); // Assert expect(dataRequest[4].amendmentIterations[2].dateSubmitted).toBeFalsy(); expect(dataRequest[4].amendmentIterations[2].submittedBy).toBeFalsy(); - expect(data.amendmentIterations[0]).toEqual(dataRequest[4].amendmentIterations[0]); - expect(data.amendmentIterations[0]).toEqual(dataRequest[4].amendmentIterations[0]); - expect(data.amendmentIterations[1]).toEqual(dataRequest[4].amendmentIterations[1]); - expect(data.amendmentIterations[1]).toEqual(dataRequest[4].amendmentIterations[1]); - expect(data.amendmentIterations[2]).toHaveProperty('dateSubmitted'); - expect(data.amendmentIterations[2].submittedBy).toBe(users.applicant._id); + expect(accessRecord.amendmentIterations[0]).toEqual(dataRequest[4].amendmentIterations[0]); + expect(accessRecord.amendmentIterations[0]).toEqual(dataRequest[4].amendmentIterations[0]); + expect(accessRecord.amendmentIterations[1]).toEqual(dataRequest[4].amendmentIterations[1]); + expect(accessRecord.amendmentIterations[1]).toEqual(dataRequest[4].amendmentIterations[1]); + expect(accessRecord.amendmentIterations[2]).toHaveProperty('dateSubmitted'); + expect(accessRecord.amendmentIterations[2].submittedBy).toBe(users.applicant._id); }); }); diff --git a/src/resources/datarequest/amendment/amendment.controller.js b/src/resources/datarequest/amendment/amendment.controller.js index 29e924c3..a1c0ac6d 100644 --- a/src/resources/datarequest/amendment/amendment.controller.js +++ b/src/resources/datarequest/amendment/amendment.controller.js @@ -1,18 +1,18 @@ -import { DataRequestModel } from '../datarequest.model'; +import _ from 'lodash'; + import constants from '../../utilities/constants.util'; import datarequestUtil from '../utils/datarequest.util'; import teamController from '../../team/team.controller'; import Controller from '../../base/controller'; - -import _ from 'lodash'; - import { logger } from '../../utilities/logger'; + const logCategory = 'Data Access Request'; export default class AmendmentController extends Controller { - constructor(amendmentService) { + constructor(amendmentService, dataRequestService) { super(amendmentService); this.amendmentService = amendmentService; + this.dataRequestService = dataRequestService; } async setAmendment(req, res) { @@ -21,6 +21,8 @@ export default class AmendmentController extends Controller { const { params: { id }, } = req; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; let { questionId, questionSetId, mode, reason, answer } = req.body; if (_.isEmpty(questionId) || _.isEmpty(questionSetId)) { return res.status(400).json({ @@ -28,24 +30,13 @@ export default class AmendmentController extends Controller { message: 'You must supply the unique identifiers for the question requiring amendment', }); } + // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([ - { - path: 'datasets dataset', - }, - { - path: 'publisherObj', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - ]); + const accessRecord = await this.dataRequestService.getApplicationWithTeamById(id); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } + // 3. If application is not in review or submitted, amendments cannot be made if ( accessRecord.applicationStatus !== constants.applicationStatuses.SUBMITTED && @@ -56,11 +47,14 @@ export default class AmendmentController extends Controller { message: 'This application is not within a reviewable state and amendments cannot be made or requested at this time.', }); } + // 4. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), req.user.id, req.user._id); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), requestingUserId, requestingUserObjectId); + // 5. Get the current iteration amendment party let validParty = false; - let activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); + const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); + // 6. Add/remove/revert amendment depending on mode if (authorised) { switch (mode) { @@ -90,10 +84,12 @@ export default class AmendmentController extends Controller { break; } } + // 7. Return unauthorised message if the user did not have sufficient access for action requested if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } + // 8. Return bad request if the opposite party is editing the application if (!validParty) { return res.status(400).json({ @@ -101,6 +97,7 @@ export default class AmendmentController extends Controller { message: 'You cannot make or request amendments to this application as the opposite party are currently responsible for it.', }); } + // 9. Save changes to database await accessRecord.save(async err => { if (err) { @@ -110,6 +107,7 @@ export default class AmendmentController extends Controller { // 10. Update json schema and question answers with modifications since original submission let accessRecordObj = accessRecord.toObject(); accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user); + // 11. Append question actions depending on user type and application status let userRole = activeParty === constants.userTypes.CUSTODIAN ? constants.roleTypes.MANAGER : ''; accessRecordObj.jsonSchema = datarequestUtil.injectQuestionActions( @@ -119,6 +117,7 @@ export default class AmendmentController extends Controller { userRole, activeParty ); + // 12. Count the number of answered/unanswered amendments const { answeredAmendments = 0, unansweredAmendments = 0 } = this.amendmentService.countAmendments(accessRecord, userType); return res.status(200).json({ @@ -149,51 +148,30 @@ export default class AmendmentController extends Controller { const { params: { id }, } = req; + const requestingUserObjectId = req.user._id; + // 2. Retrieve DAR from database - let accessRecord = await DataRequestModel.findOne({ _id: id }) - .select({ - _id: 1, - publisher: 1, - amendmentIterations: 1, - datasetIds: 1, - dataSetId: 1, - userId: 1, - authorIds: 1, - applicationStatus: 1, - aboutApplication: 1, - dateSubmitted: 1, - }) - .populate([ - { - path: 'datasets dataset mainApplicant authors', - }, - { - path: 'publisherObj', - select: '_id', - populate: { - path: 'team', - populate: { - path: 'users', - }, - }, - }, - ]); + let accessRecord = await this.dataRequestService.getApplicationForUpdateRequest(id); + if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } + // 3. Check permissions of user is manager of associated team let authorised = false; if (_.has(accessRecord.toObject(), 'publisherObj.team')) { const { team } = accessRecord.publisherObj; - authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team.toObject(), req.user._id); + authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team.toObject(), requestingUserObjectId); } if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } + // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications) if (_.isEmpty(accessRecord.datasets)) { accessRecord.datasets = [accessRecord.dataset]; } + // 5. Get the current iteration amendment party and return bad request if the opposite party is editing the application const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord); if (activeParty !== constants.userTypes.CUSTODIAN) { @@ -202,6 +180,7 @@ export default class AmendmentController extends Controller { message: 'You cannot make or request amendments to this application as the applicant(s) are amending the current version.', }); } + // 6. Check some amendments exist to be submitted to the applicant(s) const { unansweredAmendments } = this.amendmentService.countAmendments(accessRecord, constants.userTypes.CUSTODIAN); if (unansweredAmendments === 0) { @@ -210,17 +189,20 @@ export default class AmendmentController extends Controller { message: 'You cannot submit requested amendments as none have been requested in the current version', }); } + // 7. Find current amendment iteration index const index = this.amendmentService.getLatestAmendmentIterationIndex(accessRecord); // 8. Update amendment iteration status to returned, handing responsibility over to the applicant(s) accessRecord.amendmentIterations[index].dateReturned = new Date(); - accessRecord.amendmentIterations[index].returnedBy = req.user._id; + accessRecord.amendmentIterations[index].returnedBy = requestingUserObjectId; + // 9. Save changes to database await accessRecord.save(async err => { if (err) { console.error(err.message); return res.status(500).json({ status: 'error', message: err.message }); } else { + // 10. Send update request notifications this.amendmentService.createNotifications(constants.notificationTypes.RETURNED, accessRecord); return res.status(200).json({ diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index c0b065c1..cc608b9f 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -1,3 +1,5 @@ +import _ from 'lodash'; + import { AmendmentModel } from './amendment.model'; import constants from '../../utilities/constants.util'; import helperUtil from '../../utilities/helper.util'; @@ -5,8 +7,6 @@ import datarequestUtil from '../utils/datarequest.util'; import notificationBuilder from '../../utilities/notificationBuilder'; import emailGenerator from '../../utilities/emailGenerator.util'; -import _ from 'lodash'; - export default class AmendmentService { constructor(amendmentRepository) { this.amendmentRepository = amendmentRepository; @@ -163,8 +163,8 @@ export default class AmendmentService { }); } - getAmendmentIterationParty(accessRecord, versionAmendmentIterationIndex = -1) { - if (versionAmendmentIterationIndex === -1) { + getAmendmentIterationParty(accessRecord, versionIndex) { + if (!versionIndex) { // 1. Look for an amendment iteration that is in flight // An empty date submitted with populated date returned indicates that the current correction iteration is now with the applicants let index = accessRecord.amendmentIterations.findIndex(v => _.isUndefined(v.dateSubmitted) && !_.isUndefined(v.dateReturned)); @@ -175,14 +175,14 @@ export default class AmendmentService { return constants.userTypes.APPLICANT; } } else { - return this.getAmendmentIterationPartyByVersion(accessRecord, versionAmendmentIterationIndex); + return this.getAmendmentIterationPartyByVersion(accessRecord, versionIndex); } } - getAmendmentIterationPartyByVersion(accessRecord, versionAmendmentIterationIndex) { + getAmendmentIterationPartyByVersion(accessRecord, versionIndex) { // If a specific version has been requested, determine the last party active on that version // An empty submission date with a valid return date (added by Custodians returning the form) indicates applicants are active - const requestedAmendmentIteration = accessRecord.amendmentIterations[versionAmendmentIterationIndex]; + const requestedAmendmentIteration = accessRecord.amendmentIterations[versionIndex]; if (requestedAmendmentIteration === _.last(accessRecord.amendmentIterations)) { if (_.isUndefined(requestedAmendmentIteration.dateSubmitted) && !_.isUndefined(requestedAmendmentIteration.dateReturned)) { return constants.userTypes.APPLICANT; @@ -195,22 +195,24 @@ export default class AmendmentService { } } - getAmendmentIterationDetailsByVersion(accessRecord, majorVersion, minorVersion) { + getAmendmentIterationDetailsByVersion(accessRecord, minorVersion) { const { amendmentIterations = [] } = accessRecord; // Get amendment iteration index, initial version will be offset by 1 to find array index i.e. 1.0 = -1, 1.1 = 0, 1.2 = 1 etc. // versions beyond 1 will have matching offset to array index as 2.0 includes amendments on first submission i.e. 2.0 = 0, 2.1 = 1, 2.2 = 2 etc. - const versionAmendmentIterationIndex = majorVersion === 1 ? minorVersion - 1 : minorVersion; + //const versionIndex = majorVersion === 1 ? minorVersion - 1 : minorVersion; + // If no minor version updates are requested, + const versionIndex = minorVersion - 1; // Get active party for selected index - const activeParty = this.getAmendmentIterationParty(accessRecord, versionAmendmentIterationIndex); + const activeParty = this.getAmendmentIterationParty(accessRecord, versionIndex); // Check if selected version is latest - const isLatestMinorVersion = amendmentIterations[versionAmendmentIterationIndex] === _.last(amendmentIterations); + const isLatestMinorVersion = amendmentIterations[versionIndex] === _.last(amendmentIterations); - return { versionAmendmentIterationIndex, activeParty, isLatestMinorVersion }; + return { versionIndex, activeParty, isLatestMinorVersion }; } - filterAmendments(accessRecord = {}, userType, lastIterationIndex = -1) { + filterAmendments(accessRecord = {}, userType, lastIterationIndex) { // 1. Guard for invalid access record if (_.isEmpty(accessRecord)) { return {}; @@ -218,8 +220,8 @@ export default class AmendmentService { let { amendmentIterations = [] } = accessRecord; // 2. Slice any superfluous amendment iterations if a previous version has been explicitly requested - if (lastIterationIndex !== -1) { - amendmentIterations = amendmentIterations.slice(0, lastIterationIndex); + if (lastIterationIndex) { + amendmentIterations = amendmentIterations.slice(0, lastIterationIndex + 1); } // 3. Extract all relevant iteration objects and answers based on the user type @@ -242,27 +244,32 @@ export default class AmendmentService { return amendmentIterations; } - injectAmendments(accessRecord, userType, user, versionAmendmentIterationIndex = -1) { - // 1. Get latest iteration created by Custodian + injectAmendments(accessRecord, userType, user, versionIndex) { + let latestIteration; + + // 1. Ensure minor versions exist if (accessRecord.amendmentIterations.length === 0) { return accessRecord; } // 2. If a specific version has not be requested, fetch the latest (last) amendment iteration to include all changes to date - const lastIndex = - versionAmendmentIterationIndex === -1 ? _.findLastIndex(accessRecord.amendmentIterations) : versionAmendmentIterationIndex; + if (!versionIndex) { + versionIndex = _.findLastIndex(accessRecord.amendmentIterations); + latestIteration = accessRecord.amendmentIterations[versionIndex]; + } else { + latestIteration = accessRecord.amendmentIterations[versionIndex + 1] || accessRecord.amendmentIterations[versionIndex]; + } - // 3. Get the corresponding iteration/version for the required index - let latestIteration = accessRecord.amendmentIterations[lastIndex]; + // 3. Get requested updates for next version if it exists (must be created by custodians by requesting updates) const { dateReturned } = latestIteration; // 4. Applicants should see previous amendment iteration requests until current iteration has been returned with new requests if ( - (lastIndex > 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) || + (versionIndex > 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) || (userType === constants.userTypes.CUSTODIAN && _.isNil(latestIteration.questionAnswers)) ) { - latestIteration = accessRecord.amendmentIterations[lastIndex - 1]; - } else if (lastIndex === 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) { + latestIteration = accessRecord.amendmentIterations[versionIndex - 1]; + } else if (versionIndex === 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) { return accessRecord; } @@ -273,7 +280,7 @@ export default class AmendmentService { } // 6. Filter out amendments that have not yet been exposed to the opposite party - const amendmentIterations = this.filterAmendments(accessRecord, userType, lastIndex); + const amendmentIterations = this.filterAmendments(accessRecord, userType, versionIndex); // 7. Update the question answers to reflect all the changes that have been made in later iterations accessRecord.questionAnswers = this.formatQuestionAnswers(accessRecord.questionAnswers, amendmentIterations); @@ -471,24 +478,20 @@ export default class AmendmentService { } // 2. Mark submission type as a resubmission later used to determine notification generation accessRecord.submissionType = constants.submissionTypes.RESUBMISSION; - accessRecord.amendmentIterations[index] = { - ...accessRecord.amendmentIterations[index], - dateSubmitted: new Date(), - submittedBy: userId, - }; + accessRecord.submitAmendmentIteration(index, userId); // 3. Return updated access record for saving return accessRecord; } - countAmendments(accessRecord, userType, versionAmendmentIterationIndex = -1) { - // 1. Find latest iteration and if not found, return 0 + countAmendments(accessRecord, userType, versionIndex) { + // 1. Find either latest iteration or version to count amendments from let index; let unansweredAmendments = 0; let answeredAmendments = 0; - if (versionAmendmentIterationIndex === -1) { + if (!versionIndex) { index = this.getLatestAmendmentIterationIndex(accessRecord); } else { - index = versionAmendmentIterationIndex; + index = versionIndex; } if ( index === -1 || diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 9b1795ed..b5355318 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1,27 +1,21 @@ -import { DataRequestModel } from './datarequest.model'; -import { Data as ToolModel } from '../tool/data.model'; -import { DataRequestSchemaModel } from './datarequest.schemas.model'; -import { UserModel } from '../user/user.model'; +import _ from 'lodash'; +import moment from 'moment'; +import mongoose from 'mongoose'; import teamController from '../team/team.controller'; import datarequestUtil from './utils/datarequest.util'; import notificationBuilder from '../utilities/notificationBuilder'; - import emailGenerator from '../utilities/emailGenerator.util'; import helper from '../utilities/helper.util'; import dynamicForm from '../utilities/dynamicForms/dynamicForm.util'; import constants from '../utilities/constants.util'; import { getFile, fileStatus } from '../utilities/cloudStorage.util'; -import _ from 'lodash'; import inputSanitizer from '../utilities/inputSanitizer'; import Controller from '../base/controller'; - -import moment from 'moment'; -import mongoose from 'mongoose'; - import { logger } from '../utilities/logger'; -const logCategory = 'Data Access Request'; +import { UserModel } from '../user/user.model'; +const logCategory = 'Data Access Request'; const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); export default class DataRequestController extends Controller { @@ -32,6 +26,8 @@ export default class DataRequestController extends Controller { this.amendmentService = amendmentService; } + // ###### APPLICATION CRUD OPERATIONS ####### + //GET api/v1/data-access-request async getAccessRequestsByUser(req, res) { try { @@ -49,6 +45,7 @@ export default class DataRequestController extends Controller { accessRecord.projectName = this.dataRequestService.getProjectName(accessRecord); accessRecord.applicants = this.dataRequestService.getApplicantNames(accessRecord); accessRecord.decisionDuration = this.dataRequestService.getDecisionDuration(accessRecord); + accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree); accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.APPLICANT); return accessRecord; }) @@ -102,11 +99,10 @@ export default class DataRequestController extends Controller { } // 4. Get requested amendment iteration details - const { - versionAmendmentIterationIndex, - activeParty, - isLatestMinorVersion, - } = this.amendmentService.getAmendmentIterationDetailsByVersion(accessRecord, requestedMajorVersion, requestedMinorVersion); + const { versionIndex, activeParty, isLatestMinorVersion } = this.amendmentService.getAmendmentIterationDetailsByVersion( + accessRecord, + requestedMinorVersion + ); // 5. Check if requesting user is custodian member or applicant/contributor const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( @@ -119,11 +115,11 @@ export default class DataRequestController extends Controller { } // 6. Set edit mode for applicants who have not yet submitted - const { applicationStatus, jsonSchema } = accessRecord; + const { applicationStatus, jsonSchema, versionTree } = accessRecord; accessRecord.readOnly = this.dataRequestService.getApplicationIsReadOnly(userType, applicationStatus); // 7. Count amendments for selected version - const countAmendments = this.amendmentService.countAmendments(accessRecord, userType, versionAmendmentIterationIndex); + const countAmendments = this.amendmentService.countAmendments(accessRecord, userType, versionIndex); // 8. Get the workflow status for the requested application version for the requesting user const { @@ -139,7 +135,7 @@ export default class DataRequestController extends Controller { userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; // 10. Update json schema and question answers with modifications since original submission up to requested version - accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionAmendmentIterationIndex); + accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex); // 11. Append question actions depending on user type and application status accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( @@ -151,7 +147,13 @@ export default class DataRequestController extends Controller { isLatestMinorVersion ); - // 12. Return application form + // 12. Build version selector + const requestedFullVersion = `Version ${requestedMajorVersion}.${ + _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion + }`; + accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree); + + // 13. Return application form return res.status(200).json({ status: 'success', data: { @@ -167,6 +169,7 @@ export default class DataRequestController extends Controller { workflow, files: accessRecord.files || [], isLatestMinorVersion, + version: requestedFullVersion, }, }); } catch (err) { @@ -179,86 +182,91 @@ export default class DataRequestController extends Controller { } } - //POST api/v1/data-access-request/:id/clone - async cloneApplication(req, res) { + //GET api/v1/data-access-request/datasets/:datasetIds + async getAccessRequestByUserAndMultipleDatasets(req, res) { try { - // 1. Get the required request and body params + let data = {}; + // 1. Get datasetIds from params const { - params: { id }, + params: { datasetIds, dataSetId }, } = req; - const { datasetIds = [], datasetTitles = [], publisher = '', appIdToCloneInto = '' } = req.body; + const resolvedIds = datasetIds || dataSetId; + const arrDatasetIds = resolvedIds.split(','); - // 2. Retrieve DAR to clone from database - let appToClone = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true }); + // 2. Get the user details + const { id: requestingUserId, firstname, lastname } = req.user; - if (!appToClone) { - return res.status(404).json({ status: 'error', message: 'Application not found.' }); - } - - // 3. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToClone, req.user.id, req.user._id); + // 3. Find the matching record + let accessRecord = await this.dataRequestService.getApplicationByDatasets(arrDatasetIds, constants.applicationStatuses.INPROGRESS, requestingUserId); - // 4. Return unauthorised message if the requesting user is not an applicant - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + // 4. Get datasets + const datasets = await this.dataRequestService.getDatasetsForApplicationByIds(arrDatasetIds); + const arrDatasetNames = datasets.map(dataset => dataset.name); + + // 5. If in progress application found prepare to return data + if (accessRecord) { + data = { ...accessRecord }; } + else { + if (_.isEmpty(datasets)) { + return res.status(500).json({ status: 'error', message: 'No datasets available.' }); + } + const { + datasetfields: { publisher = '' }, + } = datasets[0]; - // 5. Update question answers with modifications since original submission - appToClone = this.amendmentService.injectAmendments(appToClone, constants.userTypes.APPLICANT, req.user); + // 1. GET the template from the custodian or take the default (Cannot have dataset specific question sets for multiple datasets) + accessRecord = await this.dataRequestService.buildApplicationForm(publisher, arrDatasetIds, arrDatasetNames, requestingUserId); - // 6. Set up new access record or load presubmission application as provided in request and save - let clonedAccessRecord = {}; - if (_.isEmpty(appIdToCloneInto)) { - clonedAccessRecord = await datarequestUtil.cloneIntoNewApplication(appToClone, { - userId: req.user.id, - datasetIds, - datasetTitles, - publisher, - }); - // Save new record - clonedAccessRecord = await DataRequestModel.create(clonedAccessRecord).catch(err => { + // 2. Ensure a question set was found + if (!accessRecord) { + return res.status(400).json({ + status: 'error', + message: 'Application form could not be created', + }); + } + + // 3. Create and save new application + const newApplication = await this.dataRequestService.createApplication(accessRecord).catch(err => { logger.logError(err, logCategory); }); - } else { - const appToCloneInto = await this.dataRequestService.getApplicationWithTeamById(appIdToCloneInto, { lean: true }); - // Ensure application to clone into was found - if (!appToCloneInto) { - return res.status(404).json({ status: 'error', message: 'Application to clone into not found.' }); - } - // Get permissions for application to clone into - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToCloneInto, req.user.id, req.user._id); - // Return unauthorised message if the requesting user is not authorised to the new application - if (!authorised || userType !== constants.userTypes.APPLICANT) { - return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); - } - clonedAccessRecord = await datarequestUtil.cloneIntoExistingApplication(appToClone, appToCloneInto); - // Save into existing record - clonedAccessRecord = await DataRequestModel.findOneAndUpdate({ _id: appIdToCloneInto }, clonedAccessRecord, { new: true }).catch( - err => { - logger.logError(err, logCategory); - } - ); + // 4. Set return data + data = { + ...newApplication._doc, + mainApplicant: { firstname, lastname }, + }; } - // Create notifications - await this.createNotifications( - constants.notificationTypes.APPLICATIONCLONED, - { newDatasetTitles: datasetTitles, newApplicationId: clonedAccessRecord._id.toString() }, - appToClone, - req.user + + // 6. Append question actions depending on user type and application status + data.jsonSchema = datarequestUtil.injectQuestionActions( + data.jsonSchema, + constants.userTypes.APPLICANT, + data.applicationStatus, + null, + constants.userTypes.APPLICANT ); - // Return successful response + // 7. Return payload return res.status(200).json({ - success: true, - accessRecord: clonedAccessRecord, + status: 'success', + data: { + ...data, + datasets, + projectId: data.projectId || helper.generateFriendlyId(data._id), + userType: constants.userTypes.APPLICANT, + activeParty: constants.userTypes.APPLICANT, + inReviewMode: false, + reviewSections: [], + files: data.files || [], + }, }); } catch (err) { // Return error response if something goes wrong logger.logError(err, logCategory); return res.status(500).json({ success: false, - message: 'An error occurred cloning the existing application', + message: 'An error occurred opening a data access request application for the requested dataset(s)', }); } } @@ -267,9 +275,12 @@ export default class DataRequestController extends Controller { async submitAccessRequestById(req, res) { try { // 1. id is the _id object in mongoo.db not the generated id or dataset Id - let { + const { params: { id }, } = req; + const requestingUser = req.user; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; // 2. Find the relevant data request application let accessRecord = await this.dataRequestService.getApplicationToSubmitById(id); @@ -279,7 +290,7 @@ export default class DataRequestController extends Controller { } // 3. Check user type and authentication to submit application - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord, req.user.id, req.user._id); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord, requestingUserId, requestingUserObjectId); if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } @@ -296,7 +307,8 @@ export default class DataRequestController extends Controller { accessRecord.applicationStatus === constants.applicationStatuses.INREVIEW || accessRecord.applicationStatus === constants.applicationStatuses.SUBMITTED ) { - accessRecord = this.amendmentService.doResubmission(accessRecord.toObject(), req.user._id.toString()); + accessRecord = this.amendmentService.doResubmission(accessRecord, requestingUserObjectId.toString()); + this.dataRequestService.syncRelatedApplications(accessRecord.versionTree); } // 6. Ensure a valid submission is taking place @@ -313,14 +325,14 @@ export default class DataRequestController extends Controller { }); // 8. Send notifications and emails with amendments - savedAccessRecord = this.amendmentService.injectAmendments(accessRecord, userType, req.user); + savedAccessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser); await this.createNotifications( accessRecord.submissionType === constants.submissionTypes.INITIAL ? constants.notificationTypes.SUBMITTED : constants.notificationTypes.RESUBMITTED, {}, accessRecord, - req.user + requestingUser ); // 9. Start workflow process in Camunda if publisher requires it and it is the first submission @@ -358,10 +370,11 @@ export default class DataRequestController extends Controller { params: { id }, body: data, } = req; + const requestingUser = req.user; // 2. Destructure body and update only specific fields by building a segregated non-user specified update object let updateObj = this.dataRequestService.buildUpdateObject({ ...data, - user: req.user, + user: requestingUser, }); // 3. Find data request by _id to determine current status let accessRecord = await this.dataRequestService.getApplicationToUpdateById(id); @@ -376,7 +389,7 @@ export default class DataRequestController extends Controller { const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = accessRecord; if (dirtySchema) { - accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, req.user); + accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser); } // 6. Return new data object return res.status(200).json({ @@ -560,12 +573,15 @@ export default class DataRequestController extends Controller { const { params: { id: appIdToDelete }, } = req; + const requestingUser = req.user; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; // 2. Retrieve DAR to clone from database const appToDelete = await this.dataRequestService.getApplicationWithTeamById(appIdToDelete, { lean: true }); // 3. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToDelete, req.user.id, req.user._id); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToDelete, requestingUserId, requestingUserObjectId); // 4. Return unauthorised message if the requesting user is not an applicant if (!authorised || userType !== constants.userTypes.APPLICANT) { @@ -586,7 +602,7 @@ export default class DataRequestController extends Controller { }); // 7. Create notifications - await this.createNotifications(constants.notificationTypes.APPLICATIONDELETED, {}, appToDelete, req.user); + await this.createNotifications(constants.notificationTypes.APPLICATIONDELETED, {}, appToDelete, requestingUser); return res.status(200).json({ success: true, @@ -601,6 +617,95 @@ export default class DataRequestController extends Controller { } } + // ###### ADDITIONAL FORM OPERATIONS ####### + + //POST api/v1/data-access-request/:id/clone + async cloneApplication(req, res) { + try { + // 1. Get the required request and body params + const { + params: { id }, + } = req; + const requestingUser = req.user; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + const { datasetIds = [], datasetTitles = [], publisher = '', appIdToCloneInto = '' } = req.body; + + // 2. Retrieve DAR to clone from database + let appToClone = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true }); + + if (!appToClone) { + return res.status(404).json({ status: 'error', message: 'Application not found.' }); + } + + // 3. Get the requesting users permission levels + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToClone, requestingUserId, requestingUserObjectId); + + // 4. Return unauthorised message if the requesting user is not an applicant + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 5. Update question answers with modifications since original submission + appToClone = this.amendmentService.injectAmendments(appToClone, constants.userTypes.APPLICANT, requestingUser); + + // 6. Set up new access record or load presubmission application as provided in request and save + let clonedAccessRecord = {}; + if (_.isEmpty(appIdToCloneInto)) { + clonedAccessRecord = await datarequestUtil.cloneIntoNewApplication(appToClone, { + userId: requestingUserId, + datasetIds, + datasetTitles, + publisher, + }); + // Save new record + clonedAccessRecord = await this.dataRequestService.createApplication(clonedAccessRecord).catch(err => { + logger.logError(err, logCategory); + }); + } else { + const appToCloneInto = await this.dataRequestService.getApplicationWithTeamById(appIdToCloneInto, { lean: true }); + // Ensure application to clone into was found + if (!appToCloneInto) { + return res.status(404).json({ status: 'error', message: 'Application to clone into not found.' }); + } + // Get permissions for application to clone into + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToCloneInto, requestingUserId, requestingUserObjectId); + // Return unauthorised message if the requesting user is not authorised to the new application + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + clonedAccessRecord = await datarequestUtil.cloneIntoExistingApplication(appToClone, appToCloneInto); + + // Save into existing record + clonedAccessRecord = await this.dataRequestService.updateApplicationById(appIdToCloneInto, clonedAccessRecord, { new: true }).catch( + err => { + logger.logError(err, logCategory); + } + ); + } + // Create notifications + await this.createNotifications( + constants.notificationTypes.APPLICATIONCLONED, + { newDatasetTitles: datasetTitles, newApplicationId: clonedAccessRecord._id.toString() }, + appToClone, + requestingUser + ); + + // Return successful response + return res.status(200).json({ + success: true, + accessRecord: clonedAccessRecord, + }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred cloning the existing application', + }); + } + } + //POST api/v1/data-access-request/:id/actions async performAction(req, res) { try { @@ -720,6 +825,8 @@ export default class DataRequestController extends Controller { } } + // ###### FILE UPLOAD ####### + //POST api/v1/data-access-request/:id/upload async uploadFiles(req, res) { try { @@ -727,6 +834,7 @@ export default class DataRequestController extends Controller { const { params: { id }, } = req; + const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; // 2. Get files let files = req.files; @@ -738,7 +846,7 @@ export default class DataRequestController extends Controller { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } // 5. Check if requesting user is custodian member or applicant/contributor - let { authorised } = datarequestUtil.getUserPermissionsForApplication(accessRecord, req.user.id, req.user._id); + let { authorised } = datarequestUtil.getUserPermissionsForApplication(accessRecord, requestingUserId, requestingUserObjectId); // 6. Check authorisation if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); @@ -945,6 +1053,8 @@ export default class DataRequestController extends Controller { } } + // ###### WORKFLOW ####### + //PUT api/v1/data-access-request/:id/assignworkflow async assignWorkflow(req, res) { try { @@ -952,6 +1062,7 @@ export default class DataRequestController extends Controller { const { params: { id }, } = req; + const requestingUser = req.user; const requestingUserObjectId = req.user._id; const { workflowId = '' } = req.body; if (_.isEmpty(workflowId)) { @@ -1017,9 +1128,9 @@ export default class DataRequestController extends Controller { // 10. Send notifications const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord); // Create notifications to reviewers of the step that has been completed - this.createNotifications(constants.notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, req.user); + this.createNotifications(constants.notificationTypes.REVIEWSTEPSTART, emailContext, accessRecord, requestingUser); // Create our notifications to the custodian team managers if assigned a workflow to a DAR application - this.createNotifications(constants.notificationTypes.WORKFLOWASSIGNED, emailContext, accessRecord, req.user); + this.createNotifications(constants.notificationTypes.WORKFLOWASSIGNED, emailContext, accessRecord, requestingUser); return res.status(200).json({ success: true, @@ -1357,7 +1468,7 @@ export default class DataRequestController extends Controller { publisher: name, notifyManager: 'P999D', }; - + // 9. Call Camunda controller to start manager review process bpmController.postStartManagerReview(bpmContext); } @@ -1381,6 +1492,7 @@ export default class DataRequestController extends Controller { const { params: { id }, } = req; + const requestingUser = req.user; // 2. Retrieve DAR from database const accessRecord = await this.dataRequestService.getApplicationWithWorkflowById(id); if (!accessRecord) { @@ -1400,9 +1512,9 @@ export default class DataRequestController extends Controller { const emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, activeStepIndex); // 4. Send emails based on deadline elapsed or approaching if (emailContext.deadlineElapsed) { - this.createNotifications(constants.notificationTypes.DEADLINEPASSED, emailContext, accessRecord, req.user); + this.createNotifications(constants.notificationTypes.DEADLINEPASSED, emailContext, accessRecord, requestingUser); } else { - this.createNotifications(constants.notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user); + this.createNotifications(constants.notificationTypes.DEADLINEWARNING, emailContext, accessRecord, requestingUser); } return res.status(200).json({ status: 'success' }); } catch (err) { @@ -1415,6 +1527,8 @@ export default class DataRequestController extends Controller { } } + // ###### EMAIL & NOTIFICATIONS ####### + //POST api/v1/data-access-request/:id/email async mailDataAccessRequestInfoById(req, res) { try { @@ -2149,224 +2263,4 @@ export default class DataRequestController extends Controller { break; } } -} - -module.exports = { - //GET api/v1/data-access-request/dataset/:datasetId - getAccessRequestByUserAndDataset: async (req, res) => { - let accessRecord, dataset; - let formType = constants.formTypes.Extended5Safe; - let data = {}; - try { - // 1. Get dataSetId from params - let { - params: { dataSetId }, - } = req; - // 2. Get the userId - let { id: userId, firstname, lastname } = req.user; - // 3. Find the matching record - accessRecord = await DataRequestModel.findOne({ - dataSetId, - userId, - applicationStatus: constants.applicationStatuses.INPROGRESS, - }).populate({ - path: 'mainApplicant', - select: 'firstname lastname -id -_id', - }); - // 4. Get dataset - dataset = await ToolModel.findOne({ datasetid: dataSetId }).populate('publisher'); - // 5. If no record create it and pass back - if (!accessRecord) { - if (!dataset) { - return res.status(500).json({ status: 'error', message: 'No dataset available.' }); - } - let { - datasetfields: { publisher = '' }, - } = dataset; - // 1. GET the template from the custodian - const accessRequestTemplate = await DataRequestSchemaModel.findOne({ - $or: [{ dataSetId }, { publisher }, { dataSetId: 'default' }], - status: 'active', - }).sort({ createdAt: -1 }); - - if (!accessRequestTemplate) { - return res.status(400).json({ - status: 'error', - message: 'No Data Access request schema.', - }); - } - // 2. Build up the accessModel for the user - let { jsonSchema, version, _id: schemaId, isCloneable = false } = accessRequestTemplate; - // 3. check for the type of form [enquiry - 5safes] - if (schemaId.toString() === constants.enquiryFormId) formType = constants.formTypes.Enquiry; - - // 4. create new DataRequestModel - let record = new DataRequestModel({ - version, - userId, - dataSetId, - datasetIds: [dataSetId], - datasetTitles: [dataset.name], - isCloneable, - jsonSchema, - schemaId, - publisher, - questionAnswers: {}, - aboutApplication: {}, - applicationStatus: constants.applicationStatuses.INPROGRESS, - formType, - }); - // 5. save record - const newApplication = await record.save(); - newApplication.projectId = helper.generateFriendlyId(newApplication._id); - await newApplication.save(); - - // 6. return record - data = { - ...newApplication._doc, - mainApplicant: { firstname, lastname }, - }; - } else { - data = { ...accessRecord.toObject() }; - } - // 7. Append question actions depending on user type and application status - data.jsonSchema = datarequestUtil.injectQuestionActions( - data.jsonSchema, - constants.userTypes.APPLICANT, - data.applicationStatus, - null, - constants.userTypes.APPLICANT - ); - // 8. Return payload - return res.status(200).json({ - status: 'success', - data: { - ...data, - dataset, - projectId: data.projectId || helper.generateFriendlyId(data._id), - userType: constants.userTypes.APPLICANT, - activeParty: constants.userTypes.APPLICANT, - inReviewMode: false, - reviewSections: [], - files: data.files || [], - }, - }); - } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, - - //GET api/v1/data-access-request/datasets/:datasetIds - getAccessRequestByUserAndMultipleDatasets: async (req, res) => { - let accessRecord; - let formType = constants.formTypes.Extended5Safe; - let data = {}; - let datasets = []; - try { - // 1. Get datasetIds from params - let { - params: { datasetIds }, - } = req; - let arrDatasetIds = datasetIds.split(','); - // 2. Get the userId - let { id: userId, firstname, lastname } = req.user; - // 3. Find the matching record - accessRecord = await DataRequestModel.findOne({ - datasetIds: { $all: arrDatasetIds }, - userId, - applicationStatus: constants.applicationStatuses.INPROGRESS, - }) - .populate([ - { - path: 'mainApplicant', - select: 'firstname lastname -id -_id', - }, - { path: 'files.owner', select: 'firstname lastname' }, - ]) - .sort({ createdAt: 1 }); - // 4. Get datasets - datasets = await ToolModel.find({ - datasetid: { $in: arrDatasetIds }, - }).populate('publisher'); - const arrDatasetNames = datasets.map(dataset => dataset.name); - // 5. If no record create it and pass back - if (!accessRecord) { - if (_.isEmpty(datasets)) { - return res.status(500).json({ status: 'error', message: 'No datasets available.' }); - } - let { - datasetfields: { publisher = '' }, - } = datasets[0]; - - // 1. GET the template from the custodian or take the default (Cannot have dataset specific question sets for multiple datasets) - const accessRequestTemplate = await DataRequestSchemaModel.findOne({ - $or: [{ publisher }, { dataSetId: 'default' }], - status: 'active', - }).sort({ createdAt: -1 }); - // 2. Ensure a question set was found - if (!accessRequestTemplate) { - return res.status(400).json({ - status: 'error', - message: 'No Data Access request schema.', - }); - } - // 3. Build up the accessModel for the user - let { jsonSchema, version, _id: schemaId, isCloneable = false } = accessRequestTemplate; - // 4. Check form is enquiry - if (schemaId.toString() === constants.enquiryFormId) formType = constants.formTypes.Enquiry; - // 5. Create new DataRequestModel - let record = new DataRequestModel({ - version, - userId, - datasetIds: arrDatasetIds, - datasetTitles: arrDatasetNames, - isCloneable, - jsonSchema, - schemaId, - publisher, - questionAnswers: {}, - aboutApplication: {}, - applicationStatus: constants.applicationStatuses.INPROGRESS, - formType, - }); - // 6. save record - const newApplication = await record.save(); - newApplication.projectId = helper.generateFriendlyId(newApplication._id); - await newApplication.save(); - // 7. return record - data = { - ...newApplication._doc, - mainApplicant: { firstname, lastname }, - }; - } else { - data = { ...accessRecord.toObject() }; - } - // 8. Append question actions depending on user type and application status - data.jsonSchema = datarequestUtil.injectQuestionActions( - data.jsonSchema, - constants.userTypes.APPLICANT, - data.applicationStatus, - null, - constants.userTypes.APPLICANT - ); - // 9. Return payload - return res.status(200).json({ - status: 'success', - data: { - ...data, - datasets, - projectId: data.projectId || helper.generateFriendlyId(data._id), - userType: constants.userTypes.APPLICANT, - activeParty: constants.userTypes.APPLICANT, - inReviewMode: false, - reviewSections: [], - files: data.files || [], - }, - }); - } catch (err) { - console.error(err.message); - res.status(500).json({ status: 'error', message: err.message }); - } - }, -}; +} \ No newline at end of file diff --git a/src/resources/datarequest/datarequest.entity.js b/src/resources/datarequest/datarequest.entity.js index 28c930c9..ac2f2331 100644 --- a/src/resources/datarequest/datarequest.entity.js +++ b/src/resources/datarequest/datarequest.entity.js @@ -1,5 +1,7 @@ +import { last } from 'lodash'; + import Entity from '../base/entity'; -import { isEmpty } from 'lodash'; +import constants from '../utilities/constants.util'; export default class DataRequestClass extends Entity { constructor(obj) { @@ -8,49 +10,98 @@ export default class DataRequestClass extends Entity { } /** - * Increment version + * Create a new major version e.g. 2.0, 3.0 * @description Increments the major version of this access record instance and assigns an updated version tree */ - incrementVersion = () => { - this.version++; + createMajorVersion(number) { + this.majorVersion = number; this.versionTree = buildVersionTree(this); - }; + } + + /** + * Creates a new minor version provided an amendment iteration has been submitted since the last invocation + * @description Builds a new version tree for this application instance accomodating any new amendment iterations as minor versions (updates) + */ + createMinorVersion() { + this.versionTree = buildVersionTree(this); + } + + /** + * Marks a specific amendment iteration as submitted + * @description Targets an amendment iteration based on the index passed, and updates the submission details to the current date/time and submission by the user provided + */ + submitAmendmentIteration(index, userId) { + this.amendmentIterations[index].dateSubmitted = new Date(); + this.amendmentIterations[index].submittedBy = userId; + + this.createMinorVersion(); + } } /** - * Builds and self assigns a version tree for this access record instance + * Builds and returns a version tree for an access record * @description Build a new version tree for an access record using the passed object's version property as the major version. * Therefore this must be incremented prior to calling this function if creating a new tree for a new major version. */ export const buildVersionTree = accessRecord => { // 1. Guard for invalid accessRecord if (!accessRecord) return {}; - // 2. Extract values required to build version tree, defaulting version to 1 - let { _id, version, versionTree = {}, amendmentIterations = [] } = accessRecord; - let versionKey = version ? version.toString() : '1'; + + // 2. Extract values required to build version tree, defaulting version to 1.0 + let { + _id: applicationId, + majorVersion, + versionTree = {}, + amendmentIterations = [], + applicationType = constants.applicationTypes.INITIAL, + } = accessRecord; + const versionKey = majorVersion ? majorVersion.toString() : '1'; + // 3. Reverse iterate through amendment iterations and construct minor versions let minorVersions = {}; - for (let i = amendmentIterations.length; i > 0; i--) { + for (var i = 0; i < amendmentIterations.length; i++) { + const isLatestMinorVersion = amendmentIterations[i] === last(amendmentIterations); + const { _id: iterationId } = amendmentIterations[i]; + const versionNumber = `${versionKey}.${i + 1}`; minorVersions = { ...minorVersions, - [`${versionKey}.${i}`]: _id, + [`${versionNumber}`]: { + applicationId, + iterationId, + displayTitle: `Version ${versionNumber}${isLatestMinorVersion ? ' (latest)' : ''}`, + detailedTitle: `Version ${versionNumber}${isLatestMinorVersion ? ' (latest)' : ''} | Update`, + link: `/data-access-request/${applicationId}?version=${versionNumber}`, + }, }; } + // 4. Create latest major version - let majorVersion = { [`${versionKey}`]: _id }; - // 5. Assemble version tree - if (isEmpty(versionTree)) { - versionTree = { - ...minorVersions, - ...majorVersion, - }; - } else { - versionTree = { - ...minorVersions, - ...majorVersion, - ...versionTree, - }; - } - // 6. Return tree + const hasMinorVersions = amendmentIterations.length > 0; + const isInitial = applicationType === constants.applicationTypes.INITIAL; + const detailedTitle = `Version ${versionKey}.0${!hasMinorVersions && !isInitial ? ' (latest)' : ''}${ + isInitial ? '' : ` | ${applicationType}` + }`; + const majorVersionObj = { + [`${versionKey}.0`]: { + applicationId, + displayTitle: `Version ${versionKey}.0${!hasMinorVersions && !isInitial ? ' (latest)' : ''}`, + detailedTitle, + link: `/data-access-request/${applicationId}?version=${versionKey}.0`, + applicationType + }, + }; + + // 5. Assemble updated version tree + Object.keys(versionTree).forEach(key => { + versionTree[key].displayTitle = versionTree[key].displayTitle.replace(' (latest)', ''); + versionTree[key].detailedTitle = versionTree[key].detailedTitle.replace(' (latest)', ''); + }); + const latestVersions = { ...majorVersionObj, ...minorVersions }; + Object.keys(latestVersions).forEach(key => { + if (!versionTree[key]) { + versionTree[key] = latestVersions[key]; + } + }); + return versionTree; }; diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index ab6e50a2..7cefd165 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -1,4 +1,5 @@ import { model, Schema } from 'mongoose'; + import { WorkflowSchema } from '../workflow/workflow.model'; import constants from '../utilities/constants.util'; import DataRequestClass from './datarequest.entity'; diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index f410599e..688d019c 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -1,5 +1,7 @@ import Repository from '../base/repository'; import { DataRequestModel } from './datarequest.model'; +import { DataRequestSchemaModel } from './datarequest.schemas.model'; +import { Data as ToolModel } from '../tool/data.model'; export default class DataRequestRepository extends Repository { constructor() { @@ -40,6 +42,23 @@ export default class DataRequestRepository extends Repository { .lean(); } + getApplicationByDatasets(datasetIds, applicationStatus, userId) { + return DataRequestModel.findOne({ + datasetIds: { $all: datasetIds }, + userId, + applicationStatus, + }) + .populate([ + { + path: 'mainApplicant', + select: 'firstname lastname -id -_id', + }, + { path: 'files.owner', select: 'firstname lastname' }, + ]) + .sort({ createdAt: 1 }) + .lean(); + } + getApplicationWithTeamById(id, options = {}) { return DataRequestModel.findOne({ _id: id }, null, options).populate([ { @@ -123,6 +142,50 @@ export default class DataRequestRepository extends Repository { return DataRequestModel.findById(id, { files: 1, applicationStatus: 1, userId: 1, authorIds: 1 }, options); } + getApplicationFormSchema(publisher) { + return DataRequestSchemaModel.findOne({ + $or: [{ publisher }, { dataSetId: 'default' }], + status: 'active', + }).sort({ createdAt: -1 }); + } + + getDatasetsForApplicationByIds(datasetIds) { + return ToolModel.find({ + datasetid: { $in: datasetIds }, + }).populate('publisher'); + } + + getApplicationForUpdateRequest(id) { + return DataRequestModel.findOne({ _id: id }) + .select({ + _id: 1, + publisher: 1, + amendmentIterations: 1, + datasetIds: 1, + dataSetId: 1, + userId: 1, + authorIds: 1, + applicationStatus: 1, + aboutApplication: 1, + dateSubmitted: 1, + }) + .populate([ + { + path: 'datasets dataset mainApplicant authors', + }, + { + path: 'publisherObj', + select: '_id', + populate: { + path: 'team', + populate: { + path: 'users', + }, + }, + }, + ]); + } + updateApplicationById(id, data, options = {}) { return DataRequestModel.findByIdAndUpdate(id, data, { ...options }); } @@ -135,6 +198,10 @@ export default class DataRequestRepository extends Repository { return DataRequestModel.findOneAndDelete({ _id: id }); } + createApplication(data) { + return DataRequestModel.create(data); + } + async saveFileUploadChanges(accessRecord) { await accessRecord.save(); return DataRequestModel.populate(accessRecord, { @@ -142,4 +209,8 @@ export default class DataRequestRepository extends Repository { select: 'firstname lastname id', }); } + + syncRelatedApplications(applicationIds, versionTree) { + return DataRequestModel.updateMany({ _id: { $in: applicationIds }}, { $set: { versionTree }}); + } } diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index 70f17861..c4d7e12e 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -3,12 +3,12 @@ import passport from 'passport'; import _ from 'lodash'; import multer from 'multer'; import { param } from 'express-validator'; + import { logger } from '../utilities/logger'; import DataRequestController from './datarequest.controller'; import AmendmentController from './amendment/amendment.controller'; import { dataRequestService, workflowService, amendmentService } from './dependency'; -const datarequestController = require('./datarequest.controller'); const fs = require('fs'); const path = './tmp'; const storage = multer.diskStorage({ @@ -22,7 +22,7 @@ const storage = multer.diskStorage({ const multerMid = multer({ storage: storage }); const logCategory = 'Data Access Request'; const dataRequestController = new DataRequestController(dataRequestService, workflowService, amendmentService); -const amendmentController = new AmendmentController(amendmentService); +const amendmentController = new AmendmentController(amendmentService, dataRequestService); const router = express.Router(); // @route GET api/v1/data-access-request @@ -45,6 +45,16 @@ router.get( (req, res) => dataRequestController.getAccessRequestById(req, res) ); +// @route GET api/v1/data-access-request/datasets/:datasetIds +// @desc GET Access request with multiple datasets for user +// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer +router.get( + '/datasets/:datasetIds', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via multiple datasets' }), + (req, res) => dataRequestController.getAccessRequestByUserAndMultipleDatasets(req, res) +); + // @route POST api/v1/data-access-request/:id/clone // @desc Clone an existing application forms answers into a new one potentially for a different custodian // @access Private - Applicant @@ -242,27 +252,4 @@ router.post( (req, res) => amendmentController.requestAmendments(req, res) ); - - - -// @route GET api/v1/data-access-request/dataset/:datasetId -// @desc GET Access request for user -// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer -router.get( - '/dataset/:dataSetId', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via a dataset' }), - datarequestController.getAccessRequestByUserAndDataset -); - -// @route GET api/v1/data-access-request/datasets/:datasetIds -// @desc GET Access request with multiple datasets for user -// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer -router.get( - '/datasets/:datasetIds', - passport.authenticate('jwt'), - logger.logRequestMiddleware({ logCategory, action: 'Opened a Data Access Request application via multiple datasets' }), - datarequestController.getAccessRequestByUserAndMultipleDatasets -); - module.exports = router; diff --git a/src/resources/datarequest/datarequest.schemas.model.js b/src/resources/datarequest/datarequest.schemas.model.js index 50f85776..0c314c6e 100644 --- a/src/resources/datarequest/datarequest.schemas.model.js +++ b/src/resources/datarequest/datarequest.schemas.model.js @@ -1,4 +1,5 @@ import { model, Schema } from 'mongoose'; + import constants from '../utilities/constants.util'; const DataRequestSchemas = new Schema({ diff --git a/src/resources/datarequest/datarequest.schemas.route.js b/src/resources/datarequest/datarequest.schemas.route.js index 01c51a48..f32a2c07 100644 --- a/src/resources/datarequest/datarequest.schemas.route.js +++ b/src/resources/datarequest/datarequest.schemas.route.js @@ -1,6 +1,7 @@ import express from 'express'; -import { DataRequestSchemaModel } from './datarequest.schemas.model'; import passport from 'passport'; + +import { DataRequestSchemaModel } from './datarequest.schemas.model'; import { utils } from '../auth'; import { ROLES } from '../user/user.roles'; diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index b2736c7d..bd11a39a 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -1,9 +1,10 @@ -import { isEmpty, has, isNil } from 'lodash'; -import moment from 'moment'; +import { isEmpty, has, isNil, orderBy } from 'lodash'; +import moment, { version } from 'moment'; +import helper from '../utilities/helper.util'; import datarequestUtil from '../datarequest/utils/datarequest.util'; import constants from '../utilities/constants.util'; -import { processFile, getFile, fileStatus } from '../utilities/cloudStorage.util'; +import { processFile, fileStatus } from '../utilities/cloudStorage.util'; import { amendmentService } from '../datarequest/amendment/dependency'; export default class DataRequestService { @@ -19,6 +20,10 @@ export default class DataRequestService { return this.dataRequestRepository.getApplicationById(id); } + getApplicationByDatasets(datasetIds, applicationStatus, userId) { + return this.dataRequestRepository.getApplicationByDatasets(datasetIds, applicationStatus, userId); + } + getApplicationWithTeamById(id, options) { return this.dataRequestRepository.getApplicationWithTeamById(id, options); } @@ -35,6 +40,10 @@ export default class DataRequestService { return this.dataRequestRepository.getApplicationToUpdateById(id); } + getApplicationForUpdateRequest(id) { + return this.dataRequestRepository.getApplicationForUpdateRequest(id); + } + getApplicationIsReadOnly(userType, applicationStatus) { let readOnly = true; if (userType === constants.userTypes.APPLICANT && applicationStatus === constants.applicationStatuses.INPROGRESS) { @@ -47,6 +56,10 @@ export default class DataRequestService { return this.dataRequestRepository.getFilesForApplicationById(id, options); } + getDatasetsForApplicationByIds(arrDatasetIds) { + return this.dataRequestRepository.getDatasetsForApplicationByIds(arrDatasetIds); + } + deleteApplicationById(id) { return this.dataRequestRepository.deleteApplicationById(id); } @@ -55,12 +68,45 @@ export default class DataRequestService { return this.dataRequestRepository.replaceApplicationById(id, newAcessRecord); } + async buildApplicationForm(publisher, datasetIds, datasetTitles, requestingUserId) { + // 1. Get schema to base application form on + const dataRequestSchema = await this.dataRequestRepository.getApplicationFormSchema(publisher); + + // 2. Build up the accessModel for the user + const { jsonSchema, _id: schemaId, isCloneable = false } = dataRequestSchema; + + // 3. Set form type + const formType = schemaId.toString === constants.enquiryFormId ? constants.formTypes.Enquiry : constants.formTypes.Extended5Safe; + + // 4. Create new DataRequestModel + return { + userId: requestingUserId, + datasetIds, + datasetTitles, + isCloneable, + jsonSchema, + schemaId, + publisher, + questionAnswers: {}, + aboutApplication: {}, + applicationStatus: constants.applicationStatuses.INPROGRESS, + formType, + }; + } + + async createApplication(data) { + const application = await this.dataRequestRepository.createApplication(data); + application.projectId = helper.generateFriendlyId(application._id); + application.createMajorVersion(1); + return application.save(); + } + validateRequestedVersion(accessRecord, requestedVersion) { let isValidVersion = true; // 1. Return base major version for specified access record if no specific version requested if (!requestedVersion && accessRecord) { - return { isValidVersion, requestedMajorVersion: accessRecord.majorVersion, requestedMinorVersion: 0 }; + return { isValidVersion, requestedMajorVersion: accessRecord.majorVersion, requestedMinorVersion: undefined }; } // 2. Regex to validate and process the requested application version (e.g. 1, 2, 1.0, 1.1, 2.1, 3.11) @@ -77,7 +123,11 @@ export default class DataRequestService { let { majorVersion, amendmentIterations = [] } = accessRecord; majorVersion = parseInt(majorVersion); requestedMajorVersion = parseInt(requestedMajorVersion); - requestedMinorVersion = parseInt(requestedMinorVersion || 0); + if (requestedMinorVersion) { + requestedMinorVersion = parseInt(requestedMinorVersion); + } else if (requestedMajorVersion && !requestedMinorVersion) { + requestedMinorVersion = 0; + } if (!fullMatch || majorVersion !== requestedMajorVersion || requestedMinorVersion > amendmentIterations.length) { isValidVersion = false; @@ -89,6 +139,26 @@ export default class DataRequestService { return { isValidVersion, requestedMajorVersion, requestedMinorVersion }; } + buildVersionHistory = versionTree => { + const unsortedVersions = Object.keys(versionTree).reduce((arr, versionKey) => { + const { applicationId: _id, link, displayTitle, detailedTitle } = versionTree[versionKey]; + + const version = { + number: versionKey, + _id, + link, + displayTitle, + detailedTitle, + }; + + arr = [...arr, version]; + + return arr; + }, []); + + return orderBy(unsortedVersions, ['number'], ['desc']); + }; + getProjectName(accessRecord) { // Retrieve project name from about application section const { aboutApplication: { projectName } = {} } = accessRecord; @@ -138,6 +208,10 @@ export default class DataRequestService { } } + updateApplicationById(id, data, options = {}) { + return this.dataRequestRepository.updateApplicationById(id, data, options); + } + calculateAvgDecisionTime(accessRecords = []) { // Guard for empty array passed if (isEmpty(accessRecords)) return 0; @@ -255,7 +329,7 @@ export default class DataRequestService { return mediaFiles; } - doInitialSubmission (accessRecord) { + doInitialSubmission(accessRecord) { // 1. Update application to submitted status accessRecord.submissionType = constants.submissionTypes.INITIAL; accessRecord.applicationStatus = constants.applicationStatuses.SUBMITTED; @@ -273,4 +347,16 @@ export default class DataRequestService { // 3. Return updated access record for saving return accessRecord; } + + syncRelatedApplications(versionTree) { + // 1. Extract all major version _ids denoted by an application type on each node in the version tree + const applicationIds = Object.keys(versionTree).reduce((arr, key) => { + if (versionTree[key].applicationType) { + arr.push(versionTree[key].applicationId); + } + return arr; + }, []); + // 2. Update all related applications + this.dataRequestRepository.syncRelatedApplications(applicationIds, versionTree); + } } diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index 3011880a..ff968f66 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -1,9 +1,10 @@ import _ from 'lodash'; + import constants from '../utilities/constants.util'; import teamController from '../team/team.controller'; import Controller from '../base/controller'; - import { logger } from '../utilities/logger'; + const logCategory = 'Publisher'; export default class PublisherController extends Controller { @@ -92,6 +93,7 @@ export default class PublisherController extends Controller { accessRecord.projectName = this.dataRequestService.getProjectName(accessRecord); accessRecord.applicants = this.dataRequestService.getApplicantNames(accessRecord); accessRecord.decisionDuration = this.dataRequestService.getDecisionDuration(accessRecord); + accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree); accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.CUSTODIAN); return accessRecord; }) diff --git a/src/resources/publisher/publisher.repository.js b/src/resources/publisher/publisher.repository.js index a5316037..038823c0 100644 --- a/src/resources/publisher/publisher.repository.js +++ b/src/resources/publisher/publisher.repository.js @@ -1,10 +1,10 @@ +import mongoose from 'mongoose'; + import Repository from '../base/repository'; import { PublisherModel } from './publisher.model'; import { Dataset } from '../dataset/dataset.model'; import { DataRequestModel } from '../datarequest/datarequest.model'; -import mongoose from 'mongoose'; - export default class PublisherRepository extends Repository { constructor() { super(PublisherModel); diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index b932fe51..a4e9c444 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -1,4 +1,5 @@ import _ from 'lodash'; + import { TeamModel } from './team.model'; import { UserModel } from '../user/user.model'; import emailGenerator from '../utilities/emailGenerator.util'; diff --git a/src/resources/team/team.model.js b/src/resources/team/team.model.js index 91a2f189..97f5b343 100644 --- a/src/resources/team/team.model.js +++ b/src/resources/team/team.model.js @@ -1,4 +1,5 @@ import { model, Schema } from 'mongoose'; + import constants from '../utilities/constants.util'; const TeamSchema = new Schema( diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index fabd9051..240c5814 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -1,3 +1,6 @@ +import _ from 'lodash'; +import Mongoose from 'mongoose'; + import { PublisherModel } from '../publisher/publisher.model'; import { DataRequestModel } from '../datarequest/datarequest.model'; import { WorkflowModel } from './workflow.model'; @@ -6,9 +9,6 @@ import helper from '../utilities/helper.util'; import constants from '../utilities/constants.util'; import Controller from '../base/controller'; -import _ from 'lodash'; -import Mongoose from 'mongoose'; - export default class WorkflowController extends Controller { constructor(workflowService) { super(workflowService); diff --git a/src/resources/workflow/workflow.repository.js b/src/resources/workflow/workflow.repository.js index 875ef9f9..4c2ca560 100644 --- a/src/resources/workflow/workflow.repository.js +++ b/src/resources/workflow/workflow.repository.js @@ -1,6 +1,7 @@ +import { cloneDeep } from 'lodash'; + import Repository from '../base/repository'; import { WorkflowModel } from './workflow.model'; -import { cloneDeep } from 'lodash'; export default class WorkflowRepository extends Repository { constructor() { diff --git a/src/resources/workflow/workflow.route.js b/src/resources/workflow/workflow.route.js index a9ee6045..e2dd08b3 100644 --- a/src/resources/workflow/workflow.route.js +++ b/src/resources/workflow/workflow.route.js @@ -5,9 +5,8 @@ import { logger } from '../utilities/logger'; import WorkflowController from './workflow.controller'; import { workflowService } from './dependency'; -const logCategory = 'Workflow'; const workflowController = new WorkflowController(workflowService); - +const logCategory = 'Workflow'; const router = express.Router(); // @route GET api/v1/workflows/:id diff --git a/src/resources/workflow/workflow.service.js b/src/resources/workflow/workflow.service.js index 568f4c38..6af0cfac 100644 --- a/src/resources/workflow/workflow.service.js +++ b/src/resources/workflow/workflow.service.js @@ -1,11 +1,11 @@ +import moment from 'moment'; +import { isEmpty, has } from 'lodash'; + import teamController from '../team/team.controller'; import constants from '../utilities/constants.util'; import emailGenerator from '../utilities/emailGenerator.util'; import notificationBuilder from '../utilities/notificationBuilder'; -import moment from 'moment'; -import { isEmpty, has } from 'lodash'; - const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); export default class WorkflowService { From 1b86a27997b45b55203e6ddde85ecc5e988149b4 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 27 May 2021 14:35:30 +0100 Subject: [PATCH 22/81] Updates for versioning and cloning --- src/config/server.js | 2 +- .../amendment/amendment.service.js | 21 ++++++------ .../datarequest/datarequest.controller.js | 34 ++++++++++++++----- .../datarequest/datarequest.repository.js | 2 +- .../datarequest/datarequest.service.js | 3 +- .../{ => schema}/datarequest.schemas.model.js | 2 +- .../{ => schema}/datarequest.schemas.route.js | 4 +-- .../datarequest/utils/datarequest.util.js | 6 ++-- 8 files changed, 46 insertions(+), 28 deletions(-) rename src/resources/datarequest/{ => schema}/datarequest.schemas.model.js (91%) rename src/resources/datarequest/{ => schema}/datarequest.schemas.route.js (95%) diff --git a/src/config/server.js b/src/config/server.js index 48026c72..53124c12 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -238,7 +238,7 @@ app.use('/api/v1/dataset-onboarding', require('../resources/dataset/datasetonboa app.use('/api/v1/datasets', require('../resources/dataset/v1/dataset.route')); app.use('/api/v2/datasets', require('../resources/dataset/v2/dataset.route')); -app.use('/api/v1/data-access-request/schema', require('../resources/datarequest/datarequest.schemas.route')); +app.use('/api/v1/data-access-request/schema', require('../resources/datarequest/schema/datarequest.schemas.route')); app.use('/api/v1/data-access-request', require('../resources/datarequest/datarequest.route')); app.use('/api/v1/collections', require('../resources/collections/collections.route')); diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index cc608b9f..904f9095 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -244,7 +244,7 @@ export default class AmendmentService { return amendmentIterations; } - injectAmendments(accessRecord, userType, user, versionIndex) { + injectAmendments(accessRecord, userType, user, versionIndex, isLatestVersion = true) { let latestIteration; // 1. Ensure minor versions exist @@ -276,7 +276,7 @@ export default class AmendmentService { // 5. Update schema if there is a new iteration const { publisher = 'Custodian' } = accessRecord; if (!_.isNil(latestIteration)) { - accessRecord.jsonSchema = this.formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher); + accessRecord.jsonSchema = this.formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher, isLatestVersion); } // 6. Filter out amendments that have not yet been exposed to the opposite party @@ -289,8 +289,8 @@ export default class AmendmentService { return accessRecord; } - formatSchema(jsonSchema, latestAmendmentIteration, userType, user, publisher) { - const { questionAnswers = {}, dateSubmitted, dateReturned } = latestAmendmentIteration; + formatSchema(jsonSchema, amendmentIteration, userType, user, publisher, latestVersion = true) { + const { questionAnswers = {}, dateSubmitted, dateReturned } = amendmentIteration; if (_.isEmpty(questionAnswers)) { return jsonSchema; } @@ -298,8 +298,8 @@ export default class AmendmentService { for (let questionId in questionAnswers) { const { questionSetId, answer } = questionAnswers[questionId]; // 1. Update parent/child navigation with flags for amendments - const amendmentCompleted = _.isNil(answer) ? 'incomplete' : 'completed'; - const iterationStatus = !_.isNil(dateSubmitted) ? 'submitted' : !_.isNil(dateReturned) ? 'returned' : 'inProgress'; + const amendmentCompleted = _.isNil(answer) || !latestVersion ? 'incomplete' : 'completed'; + const iterationStatus = !_.isNil(dateSubmitted) && latestVersion ? 'submitted' : !_.isNil(dateReturned) ? 'returned' : 'inProgress'; jsonSchema = this.injectNavigationAmendment(jsonSchema, questionSetId, userType, amendmentCompleted, iterationStatus); // 2. Update questions with alerts/actions jsonSchema = this.injectQuestionAmendment( @@ -310,13 +310,14 @@ export default class AmendmentService { amendmentCompleted, iterationStatus, user, - publisher + publisher, + latestVersion ); } return jsonSchema; } - injectQuestionAmendment(jsonSchema, questionId, amendment, userType, completed, iterationStatus, user, publisher) { + injectQuestionAmendment(jsonSchema, questionId, amendment, userType, completed, iterationStatus, user, publisher, latestVersion = true) { const { questionSetId } = amendment; // 1. Find question set containing question const qsIndex = jsonSchema.questionSets.findIndex(qs => qs.questionSetId === questionSetId); @@ -330,9 +331,9 @@ export default class AmendmentService { return jsonSchema; } // 3. Create question alert object to highlight amendment - const questionAlert = datarequestUtil.buildQuestionAlert(userType, iterationStatus, completed, amendment, user, publisher); + const questionAlert = datarequestUtil.buildQuestionAlert(userType, iterationStatus, completed, amendment, user, publisher, latestVersion); // 4. Update question to contain amendment state - const readOnly = userType === constants.userTypes.CUSTODIAN || iterationStatus === 'submitted'; + const readOnly = userType === constants.userTypes.CUSTODIAN || iterationStatus === 'submitted' || !latestVersion; question = datarequestUtil.setQuestionState(question, questionAlert, readOnly); // 5. Update jsonSchema with updated question jsonSchema.questionSets[qsIndex].questions = datarequestUtil.updateQuestion(questions, question); diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b5355318..b0370722 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -135,7 +135,7 @@ export default class DataRequestController extends Controller { userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; // 10. Update json schema and question answers with modifications since original submission up to requested version - accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex); + accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex, isLatestMinorVersion); // 11. Append question actions depending on user type and application status accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( @@ -151,7 +151,7 @@ export default class DataRequestController extends Controller { const requestedFullVersion = `Version ${requestedMajorVersion}.${ _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion }`; - accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree); + accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree); // 13. Return application form return res.status(200).json({ @@ -630,6 +630,7 @@ export default class DataRequestController extends Controller { const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; const { datasetIds = [], datasetTitles = [], publisher = '', appIdToCloneInto = '' } = req.body; + const { version: requestedVersion } = req.query; // 2. Retrieve DAR to clone from database let appToClone = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true }); @@ -638,18 +639,33 @@ export default class DataRequestController extends Controller { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } - // 3. Get the requesting users permission levels + // 3. If invalid version requested to clone, return 404 + const { isValidVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( + appToClone, + requestedVersion + ); + if (!isValidVersion) { + return res.status(404).json({ status: 'error', message: 'The requested application version could not be found.' }); + } + + // 4. Get requested amendment iteration details + const { versionIndex } = this.amendmentService.getAmendmentIterationDetailsByVersion( + appToClone, + requestedMinorVersion + ); + + // 5. Get the requesting users permission levels let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToClone, requestingUserId, requestingUserObjectId); - // 4. Return unauthorised message if the requesting user is not an applicant + // 6. Return unauthorised message if the requesting user is not an applicant if (!authorised || userType !== constants.userTypes.APPLICANT) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - // 5. Update question answers with modifications since original submission - appToClone = this.amendmentService.injectAmendments(appToClone, constants.userTypes.APPLICANT, requestingUser); + // 7. Update question answers with modifications since original submission + appToClone = this.amendmentService.injectAmendments(appToClone, constants.userTypes.APPLICANT, requestingUser, versionIndex); - // 6. Set up new access record or load presubmission application as provided in request and save + // 8. Set up new access record or load presubmission application as provided in request and save let clonedAccessRecord = {}; if (_.isEmpty(appIdToCloneInto)) { clonedAccessRecord = await datarequestUtil.cloneIntoNewApplication(appToClone, { @@ -683,7 +699,7 @@ export default class DataRequestController extends Controller { } ); } - // Create notifications + // 9. Create notifications await this.createNotifications( constants.notificationTypes.APPLICATIONCLONED, { newDatasetTitles: datasetTitles, newApplicationId: clonedAccessRecord._id.toString() }, @@ -691,7 +707,7 @@ export default class DataRequestController extends Controller { requestingUser ); - // Return successful response + // 10. Return successful response return res.status(200).json({ success: true, accessRecord: clonedAccessRecord, diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index 688d019c..0849b4f0 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -1,6 +1,6 @@ import Repository from '../base/repository'; import { DataRequestModel } from './datarequest.model'; -import { DataRequestSchemaModel } from './datarequest.schemas.model'; +import { DataRequestSchemaModel } from './schema/datarequest.schemas.model'; import { Data as ToolModel } from '../tool/data.model'; export default class DataRequestRepository extends Repository { diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index bd11a39a..bc10c1c6 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -98,7 +98,8 @@ export default class DataRequestService { const application = await this.dataRequestRepository.createApplication(data); application.projectId = helper.generateFriendlyId(application._id); application.createMajorVersion(1); - return application.save(); + await this.dataRequestRepository.updateApplicationById(application._id, application); + return application; } validateRequestedVersion(accessRecord, requestedVersion) { diff --git a/src/resources/datarequest/datarequest.schemas.model.js b/src/resources/datarequest/schema/datarequest.schemas.model.js similarity index 91% rename from src/resources/datarequest/datarequest.schemas.model.js rename to src/resources/datarequest/schema/datarequest.schemas.model.js index 0c314c6e..d1a5eab7 100644 --- a/src/resources/datarequest/datarequest.schemas.model.js +++ b/src/resources/datarequest/schema/datarequest.schemas.model.js @@ -1,6 +1,6 @@ import { model, Schema } from 'mongoose'; -import constants from '../utilities/constants.util'; +import constants from '../../utilities/constants.util'; const DataRequestSchemas = new Schema({ id: Number, diff --git a/src/resources/datarequest/datarequest.schemas.route.js b/src/resources/datarequest/schema/datarequest.schemas.route.js similarity index 95% rename from src/resources/datarequest/datarequest.schemas.route.js rename to src/resources/datarequest/schema/datarequest.schemas.route.js index f32a2c07..beec74dc 100644 --- a/src/resources/datarequest/datarequest.schemas.route.js +++ b/src/resources/datarequest/schema/datarequest.schemas.route.js @@ -2,8 +2,8 @@ import express from 'express'; import passport from 'passport'; import { DataRequestSchemaModel } from './datarequest.schemas.model'; -import { utils } from '../auth'; -import { ROLES } from '../user/user.roles'; +import { utils } from '../../auth'; +import { ROLES } from '../../user/user.roles'; const router = express.Router(); diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index ac245c0c..3b34cb53 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -2,7 +2,7 @@ import { has, isEmpty, isNil } from 'lodash'; import constants from '../../utilities/constants.util'; import teamController from '../../team/team.controller'; import moment from 'moment'; -import { DataRequestSchemaModel } from '../datarequest.schemas.model'; +import { DataRequestSchemaModel } from '../schema/datarequest.schemas.model'; import dynamicForm from '../../utilities/dynamicForms/dynamicForm.util'; const repeatedSectionRegex = /_[a-zA-Z|\d]{5}$/gm; @@ -171,7 +171,7 @@ const setQuestionState = (question, questionAlert, readOnly) => { return question; }; -const buildQuestionAlert = (userType, iterationStatus, completed, amendment, user, publisher) => { +const buildQuestionAlert = (userType, iterationStatus, completed, amendment, user, publisher, latestVersion = true) => { // 1. Use a try catch to prevent conditions where the combination of params lead to no question alert required try { // 2. Static mapping allows us to determine correct flag to show based on scenario (params) @@ -184,7 +184,7 @@ const buildQuestionAlert = (userType, iterationStatus, completed, amendment, use requestedBy = matchCurrentUser(user, requestedBy); updatedBy = matchCurrentUser(user, updatedBy); // 5. Update the generic question alerts to match the scenario - let relevantActioner = !isNil(updatedBy) ? updatedBy : userType === constants.userTypes.CUSTODIAN ? requestedBy : publisher; + const relevantActioner = !isNil(updatedBy) && latestVersion ? updatedBy : userType === constants.userTypes.CUSTODIAN ? requestedBy : publisher; questionAlert.text = questionAlert.text.replace('#NAME#', relevantActioner); questionAlert.text = questionAlert.text.replace( '#DATE#', From 045a638ca645bfe90c88a3c65eb89515dfd345bb Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 27 May 2021 14:44:26 +0100 Subject: [PATCH 23/81] Fixed metadata onboarding issue --- src/resources/dataset/datasetonboarding.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/dataset/datasetonboarding.controller.js b/src/resources/dataset/datasetonboarding.controller.js index 134f6fe9..0d643bdd 100644 --- a/src/resources/dataset/datasetonboarding.controller.js +++ b/src/resources/dataset/datasetonboarding.controller.js @@ -531,7 +531,7 @@ module.exports = { const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = dataset; if (dirtySchema) { accessRequestRecord.jsonSchema = JSON.parse(accessRequestRecord.jsonSchema); - accessRequestRecord = this.amendmentService.injectAmendments(accessRequestRecord, constants.userTypes.APPLICANT, req.user); + accessRequestRecord = amendmentService.injectAmendments(accessRequestRecord, constants.userTypes.APPLICANT, req.user); } let data = { status: 'success', From 8192a57e28ae4ff4db7f3eba4aacabe2e005502e Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 27 May 2021 14:45:40 +0100 Subject: [PATCH 24/81] Removed extra semicolon --- src/resources/stats/stats.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/stats/stats.controller.js b/src/resources/stats/stats.controller.js index 9f7f8b3d..15c2ed6f 100644 --- a/src/resources/stats/stats.controller.js +++ b/src/resources/stats/stats.controller.js @@ -14,7 +14,7 @@ export default class StatsController extends Controller { // Find the relevant snapshots let snapshots = await this.statsService.getSnapshots(req.query).catch(err => { logger.logError(err, logCategory); - });; + }); // Return the snapshots return res.status(200).json({ success: true, From 0f5d1c14fd13c5ea26d6043155223d3026a1bc43 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 27 May 2021 15:41:53 +0100 Subject: [PATCH 25/81] Updated amendment counting --- .../amendment/amendment.service.js | 43 +++++++++++++------ .../datarequest/datarequest.controller.js | 11 +++-- .../datarequest/utils/datarequest.util.js | 4 +- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index 904f9095..a4db113e 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -244,11 +244,11 @@ export default class AmendmentService { return amendmentIterations; } - injectAmendments(accessRecord, userType, user, versionIndex, isLatestVersion = true) { + injectAmendments(accessRecord, userType, user, versionIndex, includeCompleted = true) { let latestIteration; - // 1. Ensure minor versions exist - if (accessRecord.amendmentIterations.length === 0) { + // 1. Ensure minor versions exist and requested version index is valid + if (accessRecord.amendmentIterations.length === 0 || versionIndex < -1) { return accessRecord; } @@ -276,7 +276,7 @@ export default class AmendmentService { // 5. Update schema if there is a new iteration const { publisher = 'Custodian' } = accessRecord; if (!_.isNil(latestIteration)) { - accessRecord.jsonSchema = this.formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher, isLatestVersion); + accessRecord.jsonSchema = this.formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher, includeCompleted); } // 6. Filter out amendments that have not yet been exposed to the opposite party @@ -289,7 +289,7 @@ export default class AmendmentService { return accessRecord; } - formatSchema(jsonSchema, amendmentIteration, userType, user, publisher, latestVersion = true) { + formatSchema(jsonSchema, amendmentIteration, userType, user, publisher, includeCompleted = true) { const { questionAnswers = {}, dateSubmitted, dateReturned } = amendmentIteration; if (_.isEmpty(questionAnswers)) { return jsonSchema; @@ -298,8 +298,9 @@ export default class AmendmentService { for (let questionId in questionAnswers) { const { questionSetId, answer } = questionAnswers[questionId]; // 1. Update parent/child navigation with flags for amendments - const amendmentCompleted = _.isNil(answer) || !latestVersion ? 'incomplete' : 'completed'; - const iterationStatus = !_.isNil(dateSubmitted) && latestVersion ? 'submitted' : !_.isNil(dateReturned) ? 'returned' : 'inProgress'; + const amendmentCompleted = _.isNil(answer) || !includeCompleted ? 'incomplete' : 'completed'; + const iterationStatus = + !_.isNil(dateSubmitted) && includeCompleted ? 'submitted' : !_.isNil(dateReturned) ? 'returned' : 'inProgress'; jsonSchema = this.injectNavigationAmendment(jsonSchema, questionSetId, userType, amendmentCompleted, iterationStatus); // 2. Update questions with alerts/actions jsonSchema = this.injectQuestionAmendment( @@ -311,13 +312,23 @@ export default class AmendmentService { iterationStatus, user, publisher, - latestVersion + includeCompleted ); } return jsonSchema; } - injectQuestionAmendment(jsonSchema, questionId, amendment, userType, completed, iterationStatus, user, publisher, latestVersion = true) { + injectQuestionAmendment( + jsonSchema, + questionId, + amendment, + userType, + completed, + iterationStatus, + user, + publisher, + includeCompleted = true + ) { const { questionSetId } = amendment; // 1. Find question set containing question const qsIndex = jsonSchema.questionSets.findIndex(qs => qs.questionSetId === questionSetId); @@ -331,9 +342,17 @@ export default class AmendmentService { return jsonSchema; } // 3. Create question alert object to highlight amendment - const questionAlert = datarequestUtil.buildQuestionAlert(userType, iterationStatus, completed, amendment, user, publisher, latestVersion); + const questionAlert = datarequestUtil.buildQuestionAlert( + userType, + iterationStatus, + completed, + amendment, + user, + publisher, + includeCompleted + ); // 4. Update question to contain amendment state - const readOnly = userType === constants.userTypes.CUSTODIAN || iterationStatus === 'submitted' || !latestVersion; + const readOnly = userType === constants.userTypes.CUSTODIAN || iterationStatus === 'submitted' || !includeCompleted; question = datarequestUtil.setQuestionState(question, questionAlert, readOnly); // 5. Update jsonSchema with updated question jsonSchema.questionSets[qsIndex].questions = datarequestUtil.updateQuestion(questions, question); @@ -489,7 +508,7 @@ export default class AmendmentService { let index; let unansweredAmendments = 0; let answeredAmendments = 0; - if (!versionIndex) { + if (!versionIndex && versionIndex !== 0) { index = this.getLatestAmendmentIterationIndex(accessRecord); } else { index = versionIndex; diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index b0370722..adce35e3 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -134,10 +134,13 @@ export default class DataRequestController extends Controller { const userRole = userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; - // 10. Update json schema and question answers with modifications since original submission up to requested version + // 10. Inject completed update requests from previous version to the requested version e.g. 1.1 if 1.2 requested + accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex - 1, true); + + // 11. Inject updates from requested version e.g. 1.2 accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex, isLatestMinorVersion); - // 11. Append question actions depending on user type and application status + // 12. Append question actions depending on user type and application status accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( jsonSchema, userType, @@ -147,13 +150,13 @@ export default class DataRequestController extends Controller { isLatestMinorVersion ); - // 12. Build version selector + // 13. Build version selector const requestedFullVersion = `Version ${requestedMajorVersion}.${ _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion }`; accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree); - // 13. Return application form + // 14. Return application form return res.status(200).json({ status: 'success', data: { diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index 3b34cb53..2b49b8f9 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -171,7 +171,7 @@ const setQuestionState = (question, questionAlert, readOnly) => { return question; }; -const buildQuestionAlert = (userType, iterationStatus, completed, amendment, user, publisher, latestVersion = true) => { +const buildQuestionAlert = (userType, iterationStatus, completed, amendment, user, publisher, includeCompleted = true) => { // 1. Use a try catch to prevent conditions where the combination of params lead to no question alert required try { // 2. Static mapping allows us to determine correct flag to show based on scenario (params) @@ -184,7 +184,7 @@ const buildQuestionAlert = (userType, iterationStatus, completed, amendment, use requestedBy = matchCurrentUser(user, requestedBy); updatedBy = matchCurrentUser(user, updatedBy); // 5. Update the generic question alerts to match the scenario - const relevantActioner = !isNil(updatedBy) && latestVersion ? updatedBy : userType === constants.userTypes.CUSTODIAN ? requestedBy : publisher; + const relevantActioner = !isNil(updatedBy) && includeCompleted ? updatedBy : userType === constants.userTypes.CUSTODIAN ? requestedBy : publisher; questionAlert.text = questionAlert.text.replace('#NAME#', relevantActioner); questionAlert.text = questionAlert.text.replace( '#DATE#', From 1ff13674a81df51d913903a7b59fa5aeddd5db57 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 27 May 2021 15:56:31 +0100 Subject: [PATCH 26/81] Fixed amendment counts --- .../datarequest/amendment/amendment.service.js | 16 ++++++---------- .../datarequest/datarequest.controller.js | 4 ++-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index a4db113e..0307f99b 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -207,7 +207,7 @@ export default class AmendmentService { const activeParty = this.getAmendmentIterationParty(accessRecord, versionIndex); // Check if selected version is latest - const isLatestMinorVersion = amendmentIterations[versionIndex] === _.last(amendmentIterations); + const isLatestMinorVersion = amendmentIterations[versionIndex] === _.last(amendmentIterations) || isNaN(minorVersion); return { versionIndex, activeParty, isLatestMinorVersion }; } @@ -503,18 +503,14 @@ export default class AmendmentService { return accessRecord; } - countAmendments(accessRecord, userType, versionIndex) { - // 1. Find either latest iteration or version to count amendments from - let index; + countAmendments(accessRecord, userType, isLatestVersion = true) { + // 1. Find either latest iteration to count amendments from + const index = this.getLatestAmendmentIterationIndex(accessRecord); let unansweredAmendments = 0; let answeredAmendments = 0; - if (!versionIndex && versionIndex !== 0) { - index = this.getLatestAmendmentIterationIndex(accessRecord); - } else { - index = versionIndex; - } + if ( - index === -1 || + !isLatestVersion || index === -1 || _.isNil(accessRecord.amendmentIterations[index].questionAnswers) || (_.isNil(accessRecord.amendmentIterations[index].dateReturned) && userType == constants.userTypes.APPLICANT) ) { diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index adce35e3..8b0f24f9 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -118,8 +118,8 @@ export default class DataRequestController extends Controller { const { applicationStatus, jsonSchema, versionTree } = accessRecord; accessRecord.readOnly = this.dataRequestService.getApplicationIsReadOnly(userType, applicationStatus); - // 7. Count amendments for selected version - const countAmendments = this.amendmentService.countAmendments(accessRecord, userType, versionIndex); + // 7. Count amendments for the latest version - returns 0 immediately if not viewing latest version + const countAmendments = this.amendmentService.countAmendments(accessRecord, userType, isLatestMinorVersion); // 8. Get the workflow status for the requested application version for the requesting user const { From cf8630d0e955cccad45e31f8337c4976eed84c21 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Fri, 28 May 2021 12:04:14 +0100 Subject: [PATCH 27/81] Update to readme to force branch creation --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 100eeb11..8ef7f6e1 100644 --- a/README.md +++ b/README.md @@ -10,17 +10,21 @@ This is a NodeJS Express server, which provides the Back End API Server to the G To set up the API on your local do the following steps #### Step 1 + Clone the API repository. `git clone https://github.com/HDRUK/gateway-api` -#### Step 2 +#### Step 2 + Run the npm install + ``` npm install ``` #### Step 3 + Create a .env file in the root of the project with this content: ``` @@ -56,9 +60,11 @@ DISCOURSE_CATEGORY_PROJECTS_ID= DISCOURSE_CATEGORY_DATASETS_ID= DISCOURSE_CATEGORY_PAPERS_ID= DISCOURSE_SSO_SECRET= + ``` #### Step 4 + Start the API via command line. `node server.js` From 1067a2b84eda67573c8f3d5fe28ce05e5ffc92d8 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 28 May 2021 14:19:30 +0100 Subject: [PATCH 28/81] Fixed LGTM issues --- src/resources/datarequest/datarequest.repository.js | 4 ++-- src/resources/datarequest/datarequest.service.js | 8 ++++---- src/resources/workflow/workflow.controller.js | 2 +- src/resources/workflow/workflow.repository.js | 4 +--- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index 0849b4f0..4e1ad5b1 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -60,7 +60,7 @@ export default class DataRequestRepository extends Repository { } getApplicationWithTeamById(id, options = {}) { - return DataRequestModel.findOne({ _id: id }, null, options).populate([ + return DataRequestModel.findOne({ _id: id }, null, options).populate([ //lgtm [js/sql-injection] { path: 'datasets dataset authors', }, @@ -187,7 +187,7 @@ export default class DataRequestRepository extends Repository { } updateApplicationById(id, data, options = {}) { - return DataRequestModel.findByIdAndUpdate(id, data, { ...options }); + return DataRequestModel.findByIdAndUpdate(id, data, { ...options }); //lgtm [js/sql-injection] } replaceApplicationById(id, newDoc) { diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index bc10c1c6..d577cfcc 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -1,5 +1,5 @@ import { isEmpty, has, isNil, orderBy } from 'lodash'; -import moment, { version } from 'moment'; +import moment from 'moment'; import helper from '../utilities/helper.util'; import datarequestUtil from '../datarequest/utils/datarequest.util'; @@ -112,7 +112,7 @@ export default class DataRequestService { // 2. Regex to validate and process the requested application version (e.g. 1, 2, 1.0, 1.1, 2.1, 3.11) let fullMatch, requestedMajorVersion, requestedMinorVersion; - const regexMatch = requestedVersion.match(/^(\d+)$|^(\d+)\.?(\d+)$/); + const regexMatch = requestedVersion.match(/^(\d+)$|^(\d+)\.?(\d+)$/); // lgtm [js/polynomial-redos] if (regexMatch) { fullMatch = regexMatch[0]; requestedMajorVersion = regexMatch[1] || regexMatch[2]; @@ -126,7 +126,7 @@ export default class DataRequestService { requestedMajorVersion = parseInt(requestedMajorVersion); if (requestedMinorVersion) { requestedMinorVersion = parseInt(requestedMinorVersion); - } else if (requestedMajorVersion && !requestedMinorVersion) { + } else if (requestedMajorVersion) { requestedMinorVersion = 0; } @@ -285,7 +285,7 @@ export default class DataRequestService { return accessRecord; } - async uploadFiles(accessRecord, files, descriptions, ids, userId) { + async uploadFiles(accessRecord, files = [], descriptions, ids, userId) { let fileArr = []; // Check and see if descriptions and ids are an array let descriptionArray = Array.isArray(descriptions); diff --git a/src/resources/workflow/workflow.controller.js b/src/resources/workflow/workflow.controller.js index 240c5814..f479a4c3 100644 --- a/src/resources/workflow/workflow.controller.js +++ b/src/resources/workflow/workflow.controller.js @@ -97,7 +97,7 @@ export default class WorkflowController extends Controller { }); } // 2. Look up publisher and team - const publisherObj = await PublisherModel.findOne({ + const publisherObj = await PublisherModel.findOne({ //lgtm [js/sql-injection] _id: publisher, }).populate({ path: 'team members', diff --git a/src/resources/workflow/workflow.repository.js b/src/resources/workflow/workflow.repository.js index 4c2ca560..358fc61b 100644 --- a/src/resources/workflow/workflow.repository.js +++ b/src/resources/workflow/workflow.repository.js @@ -1,5 +1,3 @@ -import { cloneDeep } from 'lodash'; - import Repository from '../base/repository'; import { WorkflowModel } from './workflow.model'; @@ -40,7 +38,7 @@ export default class WorkflowRepository extends Repository { return WorkflowModel.findOne( { _id: id, - }, + }, //lgtm [js/sql-injection] null, options ) From 44932bddca3b916829760bfccf4435ef539b4cb2 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 1 Jun 2021 11:41:07 +0100 Subject: [PATCH 29/81] Fixed LGTM issue --- src/resources/datarequest/datarequest.service.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index d577cfcc..d35ceef9 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -290,6 +290,8 @@ export default class DataRequestService { // Check and see if descriptions and ids are an array let descriptionArray = Array.isArray(descriptions); let idArray = Array.isArray(ids); + if(!Array.isArray(files)) return []; + // Process the files for scanning for (let i = 0; i < files.length; i++) { // Get description information From d589df83ad6c96ce36c57b017e8626fb50f6c2c2 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 1 Jun 2021 11:50:56 +0100 Subject: [PATCH 30/81] Removed LGTM issue --- src/resources/datarequest/datarequest.service.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index d35ceef9..2bbc22ca 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -290,10 +290,9 @@ export default class DataRequestService { // Check and see if descriptions and ids are an array let descriptionArray = Array.isArray(descriptions); let idArray = Array.isArray(ids); - if(!Array.isArray(files)) return []; - + // Process the files for scanning - for (let i = 0; i < files.length; i++) { + for (let i = 0; i < files.length; i++) { //lgtm [js/type-confusion-through-parameter-tampering] // Get description information let description = descriptionArray ? descriptions[i] : descriptions; // Get uniqueId From d274e8bc69ba8daa13b1f61b8713707a27ddf4d2 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Tue, 1 Jun 2021 15:35:01 +0100 Subject: [PATCH 31/81] End point to set shared flag to true and also updates to publisher.service to return pre submission applications where the shared flag is set to true --- .../datarequest/datarequest.controller.js | 173 ++++++++++++++++-- .../datarequest/datarequest.model.js | 7 +- src/resources/publisher/publisher.service.js | 16 +- 3 files changed, 172 insertions(+), 24 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index a8bba5be..a53b5d59 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -200,17 +200,20 @@ export default class DataRequestController extends Controller { const { id: requestingUserId, firstname, lastname } = req.user; // 3. Find the matching record - let accessRecord = await this.dataRequestService.getApplicationByDatasets(arrDatasetIds, constants.applicationStatuses.INPROGRESS, requestingUserId); + let accessRecord = await this.dataRequestService.getApplicationByDatasets( + arrDatasetIds, + constants.applicationStatuses.INPROGRESS, + requestingUserId + ); // 4. Get datasets const datasets = await this.dataRequestService.getDatasetsForApplicationByIds(arrDatasetIds); const arrDatasetNames = datasets.map(dataset => dataset.name); - + // 5. If in progress application found prepare to return data if (accessRecord) { data = { ...accessRecord }; - } - else { + } else { if (_.isEmpty(datasets)) { return res.status(500).json({ status: 'error', message: 'No datasets available.' }); } @@ -293,7 +296,11 @@ export default class DataRequestController extends Controller { } // 3. Check user type and authentication to submit application - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord, requestingUserId, requestingUserObjectId); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } @@ -584,7 +591,11 @@ export default class DataRequestController extends Controller { const appToDelete = await this.dataRequestService.getApplicationWithTeamById(appIdToDelete, { lean: true }); // 3. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToDelete, requestingUserId, requestingUserObjectId); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + appToDelete, + requestingUserId, + requestingUserObjectId + ); // 4. Return unauthorised message if the requesting user is not an applicant if (!authorised || userType !== constants.userTypes.APPLICANT) { @@ -643,19 +654,13 @@ export default class DataRequestController extends Controller { } // 3. If invalid version requested to clone, return 404 - const { isValidVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( - appToClone, - requestedVersion - ); + const { isValidVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion(appToClone, requestedVersion); if (!isValidVersion) { return res.status(404).json({ status: 'error', message: 'The requested application version could not be found.' }); } // 4. Get requested amendment iteration details - const { versionIndex } = this.amendmentService.getAmendmentIterationDetailsByVersion( - appToClone, - requestedMinorVersion - ); + const { versionIndex } = this.amendmentService.getAmendmentIterationDetailsByVersion(appToClone, requestedMinorVersion); // 5. Get the requesting users permission levels let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToClone, requestingUserId, requestingUserObjectId); @@ -688,7 +693,11 @@ export default class DataRequestController extends Controller { return res.status(404).json({ status: 'error', message: 'Application to clone into not found.' }); } // Get permissions for application to clone into - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToCloneInto, requestingUserId, requestingUserObjectId); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + appToCloneInto, + requestingUserId, + requestingUserObjectId + ); // Return unauthorised message if the requesting user is not authorised to the new application if (!authorised || userType !== constants.userTypes.APPLICANT) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); @@ -696,11 +705,11 @@ export default class DataRequestController extends Controller { clonedAccessRecord = await datarequestUtil.cloneIntoExistingApplication(appToClone, appToCloneInto); // Save into existing record - clonedAccessRecord = await this.dataRequestService.updateApplicationById(appIdToCloneInto, clonedAccessRecord, { new: true }).catch( - err => { + clonedAccessRecord = await this.dataRequestService + .updateApplicationById(appIdToCloneInto, clonedAccessRecord, { new: true }) + .catch(err => { logger.logError(err, logCategory); - } - ); + }); } // 9. Create notifications await this.createNotifications( @@ -2282,4 +2291,128 @@ export default class DataRequestController extends Controller { break; } } -} \ No newline at end of file + + // ###### CONTEXTUAL MESSAGING & NOTES ####### + + //PUT api/v1/data-access-request/:id/share + async updateSharedDARFlag(req, res) { + try { + const { + params: { id }, + } = req; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + + let accessRecord = await this.dataRequestService.getApplicationById(id); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'The application could not be found.' }); + } + + const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + await this.dataRequestService.updateApplicationById(id, { isShared: true }).catch(err => { + logger.logError(err, logCategory); + }); + + return res.status(200).json({ + status: 'success', + }); + } catch (err) { + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the application', + }); + } + } + + //GET api/v1/data-access-request/:id/messages + async getMessages(req, res) { + try { + const { + params: { id }, + } = req; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + + let accessRecord = await this.dataRequestService.getApplicationById(id); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'The application could not be found.' }); + } + + const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + //Messages to be shown for both applicant and custodian + + //Applicant notes for only applicant + + //Custodian notes for only custodians + + return res.status(200).json({ + status: 'success', + }); + } catch (err) { + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the application', + }); + } + } + + //POST api/v1/data-access-request/:id/messages + async submitMessage(req, res) { + try { + const { + params: { id }, + } = req; + const { messageType, messageBody } = req.body; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + + let accessRecord = await this.dataRequestService.getApplicationById(id); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'The application could not be found.' }); + } + + const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); + if (!authorised) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + //Messages to be shown for both applicant and custodian + + //Applicant notes for only applicant + + //Custodian notes for only custodians + + return res.status(200).json({ + status: 'success', + }); + } catch (err) { + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred updating the application', + }); + } + } +} diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 7cefd165..506bd301 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -6,7 +6,7 @@ import DataRequestClass from './datarequest.entity'; const DataRequestSchema = new Schema( { - majorVersion: { type: Number, default: 1}, + majorVersion: { type: Number, default: 1 }, userId: Number, // Main applicant authorIds: [Number], dataSetId: String, @@ -24,7 +24,7 @@ const DataRequestSchema = new Schema( applicationType: { type: String, default: 'Initial', - enum: Object.values(constants.applicationTypes) + enum: Object.values(constants.applicationTypes), }, archived: { Boolean, @@ -88,7 +88,8 @@ const DataRequestSchema = new Schema( }, ], originId: { type: Schema.Types.ObjectId, ref: 'data_request' }, - versionTree: { type: Object, default: {} } + versionTree: { type: Object, default: {} }, + isShared: { Boolean, default: false }, }, { timestamps: true, diff --git a/src/resources/publisher/publisher.service.js b/src/resources/publisher/publisher.service.js index cfeae707..345e8222 100644 --- a/src/resources/publisher/publisher.service.js +++ b/src/resources/publisher/publisher.service.js @@ -35,7 +35,7 @@ export default class PublisherService { } async getPublisherDataAccessRequests(id, requestingUserId, isManager) { - const excludedApplicationStatuses = ['inProgress']; + const excludedApplicationStatuses = []; if (!isManager) { excludedApplicationStatuses.push('submitted'); } @@ -43,6 +43,8 @@ export default class PublisherService { let applications = await this.publisherRepository.getPublisherDataAccessRequests(query); + applications = this.filterInProgressApplications(applications); + if (!isManager) { applications = this.filterApplicationsForReviewer(applications, requestingUserId); } @@ -76,4 +78,16 @@ export default class PublisherService { return filteredApplications; } + + filterInProgressApplications(applications) { + const filteredApplications = [...applications].filter(app => { + if (app.applicationStatus !== 'inProgress') return app; + + if (app.isShared) return app; + + return; + }); + + return filteredApplications; + } } From 48f92794ded9a0043141f6e77043e5218b4c2f76 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 2 Jun 2021 15:22:06 +0100 Subject: [PATCH 32/81] Amend endpoint creation --- .../datarequest/datarequest.controller.js | 168 ++++++++++++++---- .../datarequest/datarequest.entity.js | 39 +++- .../datarequest/datarequest.repository.js | 20 ++- .../datarequest/datarequest.route.js | 10 ++ .../datarequest/datarequest.service.js | 78 +++++++- src/resources/utilities/constants.util.js | 1 + 6 files changed, 269 insertions(+), 47 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index a8bba5be..e7a65e3e 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -200,17 +200,20 @@ export default class DataRequestController extends Controller { const { id: requestingUserId, firstname, lastname } = req.user; // 3. Find the matching record - let accessRecord = await this.dataRequestService.getApplicationByDatasets(arrDatasetIds, constants.applicationStatuses.INPROGRESS, requestingUserId); + let accessRecord = await this.dataRequestService.getApplicationByDatasets( + arrDatasetIds, + constants.applicationStatuses.INPROGRESS, + requestingUserId + ); // 4. Get datasets const datasets = await this.dataRequestService.getDatasetsForApplicationByIds(arrDatasetIds); const arrDatasetNames = datasets.map(dataset => dataset.name); - + // 5. If in progress application found prepare to return data if (accessRecord) { data = { ...accessRecord }; - } - else { + } else { if (_.isEmpty(datasets)) { return res.status(500).json({ status: 'error', message: 'No datasets available.' }); } @@ -293,7 +296,11 @@ export default class DataRequestController extends Controller { } // 3. Check user type and authentication to submit application - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord, requestingUserId, requestingUserObjectId); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } @@ -580,11 +587,15 @@ export default class DataRequestController extends Controller { const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; - // 2. Retrieve DAR to clone from database + // 2. Retrieve DAR to delete from database const appToDelete = await this.dataRequestService.getApplicationWithTeamById(appIdToDelete, { lean: true }); // 3. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToDelete, requestingUserId, requestingUserObjectId); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + appToDelete, + requestingUserId, + requestingUserObjectId + ); // 4. Return unauthorised message if the requesting user is not an applicant if (!authorised || userType !== constants.userTypes.APPLICANT) { @@ -643,19 +654,13 @@ export default class DataRequestController extends Controller { } // 3. If invalid version requested to clone, return 404 - const { isValidVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( - appToClone, - requestedVersion - ); + const { isValidVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion(appToClone, requestedVersion); if (!isValidVersion) { return res.status(404).json({ status: 'error', message: 'The requested application version could not be found.' }); } // 4. Get requested amendment iteration details - const { versionIndex } = this.amendmentService.getAmendmentIterationDetailsByVersion( - appToClone, - requestedMinorVersion - ); + const { versionIndex } = this.amendmentService.getAmendmentIterationDetailsByVersion(appToClone, requestedMinorVersion); // 5. Get the requesting users permission levels let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToClone, requestingUserId, requestingUserObjectId); @@ -688,7 +693,11 @@ export default class DataRequestController extends Controller { return res.status(404).json({ status: 'error', message: 'Application to clone into not found.' }); } // Get permissions for application to clone into - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToCloneInto, requestingUserId, requestingUserObjectId); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + appToCloneInto, + requestingUserId, + requestingUserObjectId + ); // Return unauthorised message if the requesting user is not authorised to the new application if (!authorised || userType !== constants.userTypes.APPLICANT) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); @@ -696,11 +705,11 @@ export default class DataRequestController extends Controller { clonedAccessRecord = await datarequestUtil.cloneIntoExistingApplication(appToClone, appToCloneInto); // Save into existing record - clonedAccessRecord = await this.dataRequestService.updateApplicationById(appIdToCloneInto, clonedAccessRecord, { new: true }).catch( - err => { + clonedAccessRecord = await this.dataRequestService + .updateApplicationById(appIdToCloneInto, clonedAccessRecord, { new: true }) + .catch(err => { logger.logError(err, logCategory); - } - ); + }); } // 9. Create notifications await this.createNotifications( @@ -844,6 +853,95 @@ export default class DataRequestController extends Controller { } } + //POST api/v1/data-access-request/:id/amend + async createAmendment(req, res) { + try { + // 1. Get dataSetId from params + const { + params: { id }, + } = req; + const { version: requestedVersion } = req.query; + const requestingUser = req.user; + const requestingUserId = parseInt(req.user.id); + const requestingUserObjectId = req.user._id; + + // 2. Find the matching record and include attached datasets records with publisher details and workflow details + let accessRecord = await this.dataRequestService.getApplicationById(id); + if (!accessRecord) { + return res.status(404).json({ status: 'error', message: 'The application could not be found.' }); + } + + // 3. Check if requesting user is custodian member or applicant/contributor + const { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord, + requestingUserId, + requestingUserObjectId + ); + if (!authorised || userType !== constants.userTypes.APPLICANT) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } + + // 4. If invalid version requested, return 404 + const { isValidVersion, requestedMajorVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( + accessRecord, + requestedVersion + ); + if (!isValidVersion) { + return res.status(404).json({ status: 'error', message: 'The requested application version could not be found.' }); + } + + // 5. Check version is the latest version + const { isLatestMinorVersion } = this.amendmentService.getAmendmentIterationDetailsByVersion(accessRecord, requestedMinorVersion); + if (!isLatestMinorVersion) { + return res + .status(400) + .json({ status: 'error', message: 'This action can only be performed against the latest version of an approved application' }); + } + + // 6. Check application is in correct status + const { applicationStatus } = accessRecord; + if ( + applicationStatus !== constants.applicationStatuses.APPROVED && + applicationStatus !== constants.applicationStatuses.APPROVEDWITHCONDITIONS + ) { + return res + .status(400) + .json({ status: 'error', message: 'This action can only be performed against an application that has been approved' }); + } + + // 7. Update question answers with modifications since original submission (minor version updates) + accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser); + + // 8. Perform amend + newAccessRecord = await this.dataRequestService.createAmendment(newAccessRecord).catch(err => { + logger.logError(err, logCategory); + }); + + if (!newAccessRecord) { + return res.status(400).json({ status: 'error', message: 'Creating application amendment failed' }); + } + + // 9. Send notifications + await this.createNotifications(constants.notificationTypes.APPLICATIONAMENDED, {}, newAccessRecord, requestingUser); + + // 10. Return successful response and version details + return res.status(201).json({ + status: 'success', + data: { + _id: newAccessRecord._id, + newVersion: newAccessRecord.majorVersion, + }, + }); + } catch (err) { + // Return error response if something goes wrong + logger.logError(err, logCategory); + return res.status(500).json({ + success: false, + message: 'An error occurred opening this data access request application', + }); + } + } + // ###### FILE UPLOAD ####### //POST api/v1/data-access-request/:id/upload @@ -931,7 +1029,7 @@ export default class DataRequestController extends Controller { } = req; // 2. Get AccessRecord - const accessRecord = await this.dataRequestService.getFilesForApplicationById(id); + const accessRecord = await this.dataRequestService.getFilesForApplicationById(id, { lean: false }); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } @@ -950,10 +1048,11 @@ export default class DataRequestController extends Controller { } // 6. get the name of the file let { name, fileId: dbFileId } = mediaFile; - // 7. get the file - await getFile(name, dbFileId, id); + // 7. get the files based on the initial application id (version 1) + const initialApplicationId = accessRecord.getInitialApplicationId(); + await getFile(name, dbFileId, initialApplicationId); // 8. send file back to user - return res.status(200).sendFile(`${process.env.TMPDIR}${id}/${dbFileId}_${name}`); + return res.status(200).sendFile(`${process.env.TMPDIR}${initialApplicationId}/${dbFileId}_${name}`); } catch (err) { // Return error response if something goes wrong logger.logError(err, logCategory); @@ -991,15 +1090,8 @@ export default class DataRequestController extends Controller { return res.status(400).json({ status: 'error', message: 'File status not valid' }); } - //4. Get the file - const fileIndex = accessRecord.files.findIndex(file => file.fileId === fileId); - if (fileIndex === -1) return res.status(404).json({ status: 'error', message: 'File not found.' }); - - //5. Update the status - accessRecord.files[fileIndex].status = status; - - //6. Write back into mongo - await accessRecord.save().catch(err => { + //4. Update all versions of application using version tree + await this.dataRequestService.updateFileStatus(accessRecord, fileId).catch(err => { logger.logError(err, logCategory); }); @@ -2199,9 +2291,8 @@ export default class DataRequestController extends Controller { // 1. Create notifications await notificationBuilder.triggerNotificationMessage( [accessRecord.userId], - `Your Data Access Request for ${datasetTitles} was successfully duplicated into a new form for ${newDatasetTitles.join( - ',' - )}, which can now be edited`, + `Your Data Access Request for ${datasetTitles} was successfully duplicated + ${_.isEmpty(newDatasetTitles) ? `from an existing form, which can now be edited` : `into a new form for ${newDatasetTitles.join(',')}, which can now be edited`}`, 'data access request', newApplicationId ); @@ -2280,6 +2371,9 @@ export default class DataRequestController extends Controller { false ); break; + case constants.notificationTypes.APPLICATIONAMENDED: + // TODO application amended notifications + break; } } -} \ No newline at end of file +} diff --git a/src/resources/datarequest/datarequest.entity.js b/src/resources/datarequest/datarequest.entity.js index ac2f2331..984830d5 100644 --- a/src/resources/datarequest/datarequest.entity.js +++ b/src/resources/datarequest/datarequest.entity.js @@ -9,6 +9,43 @@ export default class DataRequestClass extends Entity { Object.assign(this, obj); } + /** + * Get application/major version Ids + * @description Extracts all unique major version Ids relating to this access record instance i.e. ids for 1.0, 2.0, 3.0 ignoring minor versions + */ + getRelatedVersionIds() { + const versionIds = []; + // 1. Iterate through all versions in the tree + for (const versionKey in this.versionTree) { + const { applicationId, iterationId } = versions[versionKey]; + // 2. If not unique or represents a minor version then ignore + if(versionIds.some(v => v === applicationId) || iterationId) continue; + // 3. If unique, push id to array for return + versionIds.push(applicationId); + } + // 4. Return unique array + return versionIds; + } + + getInitialApplicationId() { + return this.versionTree['1'].applicationId; + } + + /** + * Get next major version increment available e.g. 2.0, 3.0 + * @description Parses the access record instance version tree to find the next available major version + */ + findNextVersion() { + const versions = []; + + for (const version in this.versionTree) { + versions.push(parseInt(version)); + } + + versions.sort((a, b) => b - a); + return versions[0] + 1; + } + /** * Create a new major version e.g. 2.0, 3.0 * @description Increments the major version of this access record instance and assigns an updated version tree @@ -87,7 +124,7 @@ export const buildVersionTree = accessRecord => { displayTitle: `Version ${versionKey}.0${!hasMinorVersions && !isInitial ? ' (latest)' : ''}`, detailedTitle, link: `/data-access-request/${applicationId}?version=${versionKey}.0`, - applicationType + applicationType, }, }; diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index 4e1ad5b1..15deb157 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -2,6 +2,7 @@ import Repository from '../base/repository'; import { DataRequestModel } from './datarequest.model'; import { DataRequestSchemaModel } from './schema/datarequest.schemas.model'; import { Data as ToolModel } from '../tool/data.model'; +import { Next } from 'react-bootstrap/lib/Pagination'; export default class DataRequestRepository extends Repository { constructor() { @@ -60,7 +61,8 @@ export default class DataRequestRepository extends Repository { } getApplicationWithTeamById(id, options = {}) { - return DataRequestModel.findOne({ _id: id }, null, options).populate([ //lgtm [js/sql-injection] + return DataRequestModel.findOne({ _id: id }, null, options).populate([ + //lgtm [js/sql-injection] { path: 'datasets dataset authors', }, @@ -211,6 +213,20 @@ export default class DataRequestRepository extends Repository { } syncRelatedApplications(applicationIds, versionTree) { - return DataRequestModel.updateMany({ _id: { $in: applicationIds }}, { $set: { versionTree }}); + return DataRequestModel.updateMany({ _id: { $in: applicationIds } }, { $set: { versionTree } }); + } + + async updateFileStatus(versionIds, fileId, status) { + const majorVersions = DataRequestModel.find({ _id: { $in: [versionIds] } }).select({ files: 1 }); + + for(const version of majorVersions) { + const fileIndex = version.files.findIndex(file => file.fileId === fileId); + + if(fileIndex === -1) continue; + + version.files[fileIndex].status = status; + + await version.save(); + } } } diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index c4d7e12e..20079425 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -252,4 +252,14 @@ router.post( (req, res) => amendmentController.requestAmendments(req, res) ); +// @route POST api/v1/data-access-request/:id/amend +// @desc Trigger amendment action on a data access request application, creating a new major version unlocked for editing +// @access Private - Applicant +router.post( + '/:id/amend', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Triggering an amendment to a Data Access Request application' }), + (req, res) => dataRequestController.createAmendment(req, res) +); + module.exports = router; diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 2bbc22ca..5082a4ad 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -6,6 +6,7 @@ import datarequestUtil from '../datarequest/utils/datarequest.util'; import constants from '../utilities/constants.util'; import { processFile, fileStatus } from '../utilities/cloudStorage.util'; import { amendmentService } from '../datarequest/amendment/dependency'; +import { application } from 'express'; export default class DataRequestService { constructor(dataRequestRepository) { @@ -94,10 +95,17 @@ export default class DataRequestService { }; } - async createApplication(data) { + async createApplication(data, applicationType = constants.applicationTypes.INITIAL) { const application = await this.dataRequestRepository.createApplication(data); - application.projectId = helper.generateFriendlyId(application._id); - application.createMajorVersion(1); + + if (applicationType === constants.applicationTypes.INITIAL) { + application.projectId = helper.generateFriendlyId(application._id); + application.createMajorVersion(1); + } else { + const versionNumber = application.findNextVersion(); + application.createMajorVersion(versionNumber); + } + await this.dataRequestRepository.updateApplicationById(application._id, application); return application; } @@ -263,6 +271,52 @@ export default class DataRequestService { return updateObj; } + async createAmendment(accessRecord) { + // TODO persist messages + private notes between applications (copy) + const applicationType = constants.applicationTypes.AMENDED; + const applicationStatus = constants.applicationStatuses.INPROGRESS; + + const { + userId, + authorIds, + datasetIds, + datasetTitles, + isCloneable, + projectId, + schemaId, + jsonSchema, + questionAnswers, + aboutApplication, + publisher, + formType, + files, + versionTree + } = accessRecord; + + let amendedApplication = { + applicationType, + applicationStatus, + userId, + authorIds, + datasetIds, + datasetTitles, + isCloneable, + projectId, + schemaId, + jsonSchema, + questionAnswers, + aboutApplication, + publisher, + formType, + files, + versionTree + } + + amendedApplication = await this.createApplication(amendedApplication, applicationType); + + return amendedApplication; + } + async updateApplication(accessRecord, updateObj) { // 1. Extract properties let { applicationStatus, _id } = accessRecord; @@ -288,11 +342,13 @@ export default class DataRequestService { async uploadFiles(accessRecord, files = [], descriptions, ids, userId) { let fileArr = []; // Check and see if descriptions and ids are an array - let descriptionArray = Array.isArray(descriptions); - let idArray = Array.isArray(ids); + const descriptionArray = Array.isArray(descriptions); + const idArray = Array.isArray(ids); + const initialApplicationId = accessRecord.getInitialApplicationId(); // Process the files for scanning - for (let i = 0; i < files.length; i++) { //lgtm [js/type-confusion-through-parameter-tampering] + for (let i = 0; i < files.length; i++) { + //lgtm [js/type-confusion-through-parameter-tampering] // Get description information let description = descriptionArray ? descriptions[i] : descriptions; // Get uniqueId @@ -300,7 +356,7 @@ export default class DataRequestService { // Remove - from uuidV4 let uniqueId = generatedId.replace(/-/gim, ''); // Send to db - const response = await processFile(files[i], accessRecord._id, uniqueId); + const response = await processFile(files[i], initialApplicationId, uniqueId); // Deconstruct response let { status } = response; // Setup fileArr for mongoo @@ -331,6 +387,14 @@ export default class DataRequestService { return mediaFiles; } + updateFileStatus(accessRecord, fileId, status) { + // 1. Get all major version Ids to update file status against + const versionIds = accessRecord.getRelatedVersionIds(); + + // 2. Update all applications with file status + this.dataRequestRepository.updateFileStatus(versionIds, fileId, status); + } + doInitialSubmission(accessRecord) { // 1. Update application to submitted status accessRecord.submissionType = constants.submissionTypes.INITIAL; diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 365628cb..3990751a 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -357,6 +357,7 @@ const _notificationTypes = { FINALDECISIONREQUIRED: 'FinalDecisionRequired', DEADLINEWARNING: 'DeadlineWarning', DEADLINEPASSED: 'DeadlinePassed', + APPLICATIONAMENDED: 'ApplicationAmended', RETURNED: 'Returned', MEMBERADDED: 'MemberAdded', MEMBERREMOVED: 'MemberRemoved', From fcc8bb377ce2aa562cf35084b3abf70787e3a79e Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 3 Jun 2021 10:15:21 +0100 Subject: [PATCH 33/81] Continued amend bill --- .../amendment/amendment.service.js | 2 +- .../datarequest/datarequest.controller.js | 13 ++++--- .../datarequest/datarequest.entity.js | 4 +-- .../datarequest/datarequest.model.js | 2 +- .../datarequest/datarequest.repository.js | 14 +++++--- .../datarequest/datarequest.service.js | 34 +++++++++++-------- .../datarequest/utils/datarequest.util.js | 3 ++ src/resources/utilities/constants.util.js | 11 ++---- 8 files changed, 48 insertions(+), 35 deletions(-) diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index 0307f99b..5261b718 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -184,7 +184,7 @@ export default class AmendmentService { // An empty submission date with a valid return date (added by Custodians returning the form) indicates applicants are active const requestedAmendmentIteration = accessRecord.amendmentIterations[versionIndex]; if (requestedAmendmentIteration === _.last(accessRecord.amendmentIterations)) { - if (_.isUndefined(requestedAmendmentIteration.dateSubmitted) && !_.isUndefined(requestedAmendmentIteration.dateReturned)) { + if (!requestedAmendmentIteration || (_.isUndefined(requestedAmendmentIteration.dateSubmitted) && !_.isUndefined(requestedAmendmentIteration.dateReturned))) { return constants.userTypes.APPLICANT; } else { return constants.userTypes.CUSTODIAN; diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index e7a65e3e..5e12dad1 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -318,7 +318,7 @@ export default class DataRequestController extends Controller { accessRecord.applicationStatus === constants.applicationStatuses.SUBMITTED ) { accessRecord = this.amendmentService.doResubmission(accessRecord, requestingUserObjectId.toString()); - this.dataRequestService.syncRelatedApplications(accessRecord.versionTree); + await this.dataRequestService.syncRelatedVersions(accessRecord.versionTree); } // 6. Ensure a valid submission is taking place @@ -882,7 +882,7 @@ export default class DataRequestController extends Controller { } // 4. If invalid version requested, return 404 - const { isValidVersion, requestedMajorVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( + const { isValidVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( accessRecord, requestedVersion ); @@ -913,7 +913,7 @@ export default class DataRequestController extends Controller { accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser); // 8. Perform amend - newAccessRecord = await this.dataRequestService.createAmendment(newAccessRecord).catch(err => { + let newAccessRecord = await this.dataRequestService.createAmendment(accessRecord).catch(err => { logger.logError(err, logCategory); }); @@ -921,10 +921,13 @@ export default class DataRequestController extends Controller { return res.status(400).json({ status: 'error', message: 'Creating application amendment failed' }); } - // 9. Send notifications + // 9. Get amended application (new major version) with all details populated + newAccessRecord = await this.dataRequestService.getApplicationById(newAccessRecord._id); + + // 10. Send notifications await this.createNotifications(constants.notificationTypes.APPLICATIONAMENDED, {}, newAccessRecord, requestingUser); - // 10. Return successful response and version details + // 11. Return successful response and version details return res.status(201).json({ status: 'success', data: { diff --git a/src/resources/datarequest/datarequest.entity.js b/src/resources/datarequest/datarequest.entity.js index 984830d5..ecaeed78 100644 --- a/src/resources/datarequest/datarequest.entity.js +++ b/src/resources/datarequest/datarequest.entity.js @@ -90,7 +90,7 @@ export const buildVersionTree = accessRecord => { majorVersion, versionTree = {}, amendmentIterations = [], - applicationType = constants.applicationTypes.INITIAL, + applicationType = constants.submissionTypes.INITIAL, } = accessRecord; const versionKey = majorVersion ? majorVersion.toString() : '1'; @@ -114,7 +114,7 @@ export const buildVersionTree = accessRecord => { // 4. Create latest major version const hasMinorVersions = amendmentIterations.length > 0; - const isInitial = applicationType === constants.applicationTypes.INITIAL; + const isInitial = applicationType === constants.submissionTypes.INITIAL; const detailedTitle = `Version ${versionKey}.0${!hasMinorVersions && !isInitial ? ' (latest)' : ''}${ isInitial ? '' : ` | ${applicationType}` }`; diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 7cefd165..6c529846 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -24,7 +24,7 @@ const DataRequestSchema = new Schema( applicationType: { type: String, default: 'Initial', - enum: Object.values(constants.applicationTypes) + enum: Object.values(constants.submissionTypes) }, archived: { Boolean, diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index 15deb157..da2cdffc 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -2,7 +2,6 @@ import Repository from '../base/repository'; import { DataRequestModel } from './datarequest.model'; import { DataRequestSchemaModel } from './schema/datarequest.schemas.model'; import { Data as ToolModel } from '../tool/data.model'; -import { Next } from 'react-bootstrap/lib/Pagination'; export default class DataRequestRepository extends Repository { constructor() { @@ -212,12 +211,19 @@ export default class DataRequestRepository extends Repository { }); } - syncRelatedApplications(applicationIds, versionTree) { - return DataRequestModel.updateMany({ _id: { $in: applicationIds } }, { $set: { versionTree } }); + async syncRelatedVersions(versionIds, versionTree) { + const majorVersions = await DataRequestModel.find().where('_id').in(versionIds).select({ versionTree: 1 }); + + for(const version of majorVersions) { + + version.versionTree = versionTree; + + await version.save(); + } } async updateFileStatus(versionIds, fileId, status) { - const majorVersions = DataRequestModel.find({ _id: { $in: [versionIds] } }).select({ files: 1 }); + const majorVersions = await DataRequestModel.find({ _id: { $in: [versionIds] } }).select({ files: 1 }); for(const version of majorVersions) { const fileIndex = version.files.findIndex(file => file.fileId === fileId); diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 5082a4ad..1a1f961a 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -95,18 +95,20 @@ export default class DataRequestService { }; } - async createApplication(data, applicationType = constants.applicationTypes.INITIAL) { - const application = await this.dataRequestRepository.createApplication(data); + async createApplication(data, applicationType = constants.submissionTypes.INITIAL, versionTree = {}) { + let application = await this.dataRequestRepository.createApplication(data); - if (applicationType === constants.applicationTypes.INITIAL) { + if (applicationType === constants.submissionTypes.INITIAL) { application.projectId = helper.generateFriendlyId(application._id); application.createMajorVersion(1); } else { + application.versionTree = versionTree; const versionNumber = application.findNextVersion(); application.createMajorVersion(versionNumber); } - await this.dataRequestRepository.updateApplicationById(application._id, application); + application = await this.dataRequestRepository.updateApplicationById(application._id, application); + return application; } @@ -273,7 +275,7 @@ export default class DataRequestService { async createAmendment(accessRecord) { // TODO persist messages + private notes between applications (copy) - const applicationType = constants.applicationTypes.AMENDED; + const applicationType = constants.submissionTypes.AMENDED; const applicationStatus = constants.applicationStatuses.INPROGRESS; const { @@ -281,18 +283,16 @@ export default class DataRequestService { authorIds, datasetIds, datasetTitles, - isCloneable, projectId, - schemaId, - jsonSchema, questionAnswers, aboutApplication, publisher, - formType, files, versionTree } = accessRecord; + const { jsonSchema, _id: schemaId, isCloneable = false, formType } = await datarequestUtil.getLatestPublisherSchema(publisher); + let amendedApplication = { applicationType, applicationStatus, @@ -308,11 +308,17 @@ export default class DataRequestService { aboutApplication, publisher, formType, - files, - versionTree + files } - amendedApplication = await this.createApplication(amendedApplication, applicationType); + if (questionAnswers && Object.keys(questionAnswers).length > 0 && datarequestUtil.containsUserRepeatedSections(questionAnswers)) { + const updatedSchema = datarequestUtil.copyUserRepeatedSections(accessRecord, jsonSchema); + amendedApplication.jsonSchema = updatedSchema; + } + + amendedApplication = await this.createApplication(amendedApplication, applicationType, versionTree); + + await this.syncRelatedVersions(versionTree); return amendedApplication; } @@ -414,7 +420,7 @@ export default class DataRequestService { return accessRecord; } - syncRelatedApplications(versionTree) { + syncRelatedVersions(versionTree) { // 1. Extract all major version _ids denoted by an application type on each node in the version tree const applicationIds = Object.keys(versionTree).reduce((arr, key) => { if (versionTree[key].applicationType) { @@ -423,6 +429,6 @@ export default class DataRequestService { return arr; }, []); // 2. Update all related applications - this.dataRequestRepository.syncRelatedApplications(applicationIds, versionTree); + this.dataRequestRepository.syncRelatedVersions(applicationIds, versionTree); } } diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index 2b49b8f9..304c92fc 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -357,4 +357,7 @@ export default { setQuestionState: setQuestionState, cloneIntoExistingApplication: cloneIntoExistingApplication, cloneIntoNewApplication: cloneIntoNewApplication, + getLatestPublisherSchema: getLatestPublisherSchema, + containsUserRepeatedSections: containsUserRepeatedSections, + copyUserRepeatedSections: copyUserRepeatedSections }; diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 3990751a..ae7dba4d 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -382,13 +382,6 @@ const _applicationStatuses = { WITHDRAWN: 'withdrawn', }; -const _applicationTypes = { - INITIAL: 'Initial', - AMENDED: 'Amendment', - EXTENDED: 'Extension', - RENEWAL: 'Renewal', -}; - const _amendmentModes = { ADDED: 'added', REMOVED: 'removed', @@ -399,6 +392,9 @@ const _submissionTypes = { INPROGRESS: 'inProgress', INITIAL: 'initial', RESUBMISSION: 'resubmission', + AMENDED: 'amendment', + EXTENDED: 'extension', + RENEWAL: 'renewal', }; const _formActions = { @@ -468,7 +464,6 @@ export default { navigationFlags: _navigationFlags, amendmentStatuses: _amendmentStatuses, notificationTypes: _notificationTypes, - applicationTypes: _applicationTypes, applicationStatuses: _applicationStatuses, amendmentModes: _amendmentModes, submissionTypes: _submissionTypes, From 8a7b0339f0fcfd6939ca13cee77a86550b5bdd22 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 3 Jun 2021 11:18:06 +0100 Subject: [PATCH 34/81] Hooked up notification firing for amendment submission --- .../amendment/amendment.service.js | 2 +- .../datarequest/datarequest.controller.js | 112 +++++++++++++++--- .../datarequest/datarequest.service.js | 14 ++- src/resources/utilities/constants.util.js | 7 ++ 4 files changed, 112 insertions(+), 23 deletions(-) diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index 5261b718..effe288a 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -497,7 +497,7 @@ export default class AmendmentService { return accessRecord; } // 2. Mark submission type as a resubmission later used to determine notification generation - accessRecord.submissionType = constants.submissionTypes.RESUBMISSION; + accessRecord.applicationType = constants.submissionTypes.RESUBMISSION; accessRecord.submitAmendmentIteration(index, userId); // 3. Return updated access record for saving return accessRecord; diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 5e12dad1..c4572066 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -322,7 +322,7 @@ export default class DataRequestController extends Controller { } // 6. Ensure a valid submission is taking place - if (_.isNil(accessRecord.submissionType)) { + if (_.isNil(accessRecord.applicationType)) { return res.status(400).json({ status: 'error', message: 'Application cannot be submitted as it has reached a final decision status.', @@ -334,19 +334,21 @@ export default class DataRequestController extends Controller { logger.logError(err, logCategory); }); - // 8. Send notifications and emails with amendments + // 8. Inject amendments from minor versions savedAccessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser); + + // 9. Calculate notification type to send + const notificationType = constants.submissionNotifications[accessRecord.applicationType]; + await this.createNotifications( - accessRecord.submissionType === constants.submissionTypes.INITIAL - ? constants.notificationTypes.SUBMITTED - : constants.notificationTypes.RESUBMITTED, + notificationType, {}, accessRecord, requestingUser ); // 9. Start workflow process in Camunda if publisher requires it and it is the first submission - if (savedAccessRecord.workflowEnabled && savedAccessRecord.submissionType === constants.submissionTypes.INITIAL) { + if (savedAccessRecord.workflowEnabled && savedAccessRecord.applicationType === constants.submissionTypes.INITIAL) { let { publisherObj: { name: publisher }, dateSubmitted, @@ -882,10 +884,7 @@ export default class DataRequestController extends Controller { } // 4. If invalid version requested, return 404 - const { isValidVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion( - accessRecord, - requestedVersion - ); + const { isValidVersion, requestedMinorVersion } = this.dataRequestService.validateRequestedVersion(accessRecord, requestedVersion); if (!isValidVersion) { return res.status(404).json({ status: 'error', message: 'The requested application version could not be found.' }); } @@ -924,10 +923,7 @@ export default class DataRequestController extends Controller { // 9. Get amended application (new major version) with all details populated newAccessRecord = await this.dataRequestService.getApplicationById(newAccessRecord._id); - // 10. Send notifications - await this.createNotifications(constants.notificationTypes.APPLICATIONAMENDED, {}, newAccessRecord, requestingUser); - - // 11. Return successful response and version details + // 10. Return successful response and version details return res.status(201).json({ status: 'success', data: { @@ -2295,7 +2291,11 @@ export default class DataRequestController extends Controller { await notificationBuilder.triggerNotificationMessage( [accessRecord.userId], `Your Data Access Request for ${datasetTitles} was successfully duplicated - ${_.isEmpty(newDatasetTitles) ? `from an existing form, which can now be edited` : `into a new form for ${newDatasetTitles.join(',')}, which can now be edited`}`, + ${ + _.isEmpty(newDatasetTitles) + ? `from an existing form, which can now be edited` + : `into a new form for ${newDatasetTitles.join(',')}, which can now be edited` + }`, 'data access request', newApplicationId ); @@ -2375,7 +2375,87 @@ export default class DataRequestController extends Controller { ); break; case constants.notificationTypes.APPLICATIONAMENDED: - // TODO application amended notifications + // 1. Create notifications + // Custodian notification + if (_.has(accessRecord.datasets[0], 'publisher.team.users')) { + // Retrieve all custodian user Ids to generate notifications + custodianManagers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, constants.roleTypes.MANAGER); + custodianUserIds = custodianManagers.map(user => user.id); + await notificationBuilder.triggerNotificationMessage( + custodianUserIds, + `A Data Access Request has been resubmitted with updates 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( + [accessRecord.userId], + `Your Data Access Request for ${datasetTitles} was successfully resubmitted with updates to ${publisher}`, + 'data access request', + accessRecord._id + ); + // Contributors/authors notification + if (!_.isEmpty(authors)) { + await notificationBuilder.triggerNotificationMessage( + accessRecord.authors.map(author => author.id), + `A Data Access Request you are contributing to for ${datasetTitles} was successfully resubmitted with updates to ${publisher} by ${firstname} ${lastname}`, + 'data access request', + accessRecord._id + ); + } + // 2. Send emails to custodian and applicant + // Create object to pass to email generator + options = { + userType: '', + userEmail: appEmail, + publisher, + datasetTitles, + userName: `${appFirstName} ${appLastName}`, + }; + // Iterate through the recipient types + for (let emailRecipientType of constants.submissionEmailRecipientTypes) { + // Establish email context object + options = { + ...options, + userType: emailRecipientType, + submissionType: constants.submissionTypes.RESUBMISSION, + }; + // Build email template + ({ html, jsonContent } = await emailGenerator.generateEmail( + aboutApplication, + questions, + pages, + questionPanels, + questionAnswers, + options + )); + // Send emails to custodian team members who have opted in to email notifications + if (emailRecipientType === 'dataCustodian') { + emailRecipients = [...custodianManagers]; + // Generate json attachment for external system integration + attachmentContent = Buffer.from(JSON.stringify({ id: accessRecord._id, ...jsonContent })).toString('base64'); + filename = `${helper.generateFriendlyId(accessRecord._id)} ${moment().format().toString()}.json`; + attachments = [await emailGenerator.generateAttachment(filename, attachmentContent, 'application/json')]; + } else { + // Send email to main applicant and contributors if they have opted in to email notifications + emailRecipients = [accessRecord.mainApplicant, ...accessRecord.authors]; + } + // Send email + if (!_.isEmpty(emailRecipients)) { + await emailGenerator.sendEmail( + emailRecipients, + constants.hdrukEmail, + `Data Access Request to ${publisher} for ${datasetTitles} has been updated with updates`, + html, + false, + attachments + ); + } + } break; } } diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 1a1f961a..10a85dee 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -277,7 +277,7 @@ export default class DataRequestService { // TODO persist messages + private notes between applications (copy) const applicationType = constants.submissionTypes.AMENDED; const applicationStatus = constants.applicationStatuses.INPROGRESS; - + const { userId, authorIds, @@ -288,7 +288,7 @@ export default class DataRequestService { aboutApplication, publisher, files, - versionTree + versionTree, } = accessRecord; const { jsonSchema, _id: schemaId, isCloneable = false, formType } = await datarequestUtil.getLatestPublisherSchema(publisher); @@ -308,8 +308,8 @@ export default class DataRequestService { aboutApplication, publisher, formType, - files - } + files, + }; if (questionAnswers && Object.keys(questionAnswers).length > 0 && datarequestUtil.containsUserRepeatedSections(questionAnswers)) { const updatedSchema = datarequestUtil.copyUserRepeatedSections(accessRecord, jsonSchema); @@ -402,8 +402,10 @@ export default class DataRequestService { } doInitialSubmission(accessRecord) { - // 1. Update application to submitted status - accessRecord.submissionType = constants.submissionTypes.INITIAL; + // 1. Update application type and submitted status + if (!accessRecord.applicationType) { + accessRecord.applicationType = constants.submissionTypes.INITIAL; + } accessRecord.applicationStatus = constants.applicationStatuses.SUBMITTED; // 2. Check if workflow/5 Safes based application, set final status date if status will never change again if (has(accessRecord.toObject(), 'publisherObj')) { diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index ae7dba4d..525d678d 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -397,6 +397,12 @@ const _submissionTypes = { RENEWAL: 'renewal', }; +const _submissionNotifications = { + [`${_submissionTypes.INITIAL}`]: _notificationTypes.SUBMITTED, + [`${_submissionTypes.RESUBMISSION}`]: _notificationTypes.RESUBMITTED, + [`${_submissionTypes.AMENDED}`]:_notificationTypes.APPLICATIONAMENDED +}; + const _formActions = { ADDREPEATABLESECTION: 'addRepeatableSection', REMOVEREPEATABLESECTION: 'removeRepeatableSection', @@ -475,4 +481,5 @@ export default { mailchimpSubscriptionStatuses: _mailchimpSubscriptionStatuses, datatsetStatuses: _datatsetStatuses, logTypes: _logTypes, + submissionNotifications: _submissionNotifications }; From de9db7fee3c7e135757de80f8bf48d43e8b884d6 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Thu, 3 Jun 2021 15:40:18 +0100 Subject: [PATCH 35/81] Continued versioning amend udpates --- src/resources/datarequest/datarequest.controller.js | 9 ++++----- src/resources/datarequest/datarequest.service.js | 3 ++- src/resources/publisher/publisher.controller.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index c4572066..9254c98c 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -45,7 +45,7 @@ export default class DataRequestController extends Controller { accessRecord.projectName = this.dataRequestService.getProjectName(accessRecord); accessRecord.applicants = this.dataRequestService.getApplicantNames(accessRecord); accessRecord.decisionDuration = this.dataRequestService.getDecisionDuration(accessRecord); - accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree); + accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree, accessRecord._id); accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.APPLICANT); return accessRecord; }) @@ -151,10 +151,10 @@ export default class DataRequestController extends Controller { ); // 13. Build version selector - const requestedFullVersion = `Version ${requestedMajorVersion}.${ + const requestedFullVersion = `${requestedMajorVersion}.${ _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion }`; - accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree); + accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree, accessRecord._id, requestedFullVersion); // 14. Return application form return res.status(200).json({ @@ -171,8 +171,7 @@ export default class DataRequestController extends Controller { hasRecommended, workflow, files: accessRecord.files || [], - isLatestMinorVersion, - version: requestedFullVersion, + isLatestMinorVersion }, }); } catch (err) { diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 10a85dee..249b8073 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -150,7 +150,7 @@ export default class DataRequestService { return { isValidVersion, requestedMajorVersion, requestedMinorVersion }; } - buildVersionHistory = versionTree => { + buildVersionHistory = (versionTree, applicationId, requestedVersion) => { const unsortedVersions = Object.keys(versionTree).reduce((arr, versionKey) => { const { applicationId: _id, link, displayTitle, detailedTitle } = versionTree[versionKey]; @@ -160,6 +160,7 @@ export default class DataRequestService { link, displayTitle, detailedTitle, + isCurrent: applicationId.toString() === _id.toString() && ((requestedVersion === versionKey || !requestedVersion)) }; arr = [...arr, version]; diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index ff968f66..ae4726e6 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -93,7 +93,7 @@ export default class PublisherController extends Controller { accessRecord.projectName = this.dataRequestService.getProjectName(accessRecord); accessRecord.applicants = this.dataRequestService.getApplicantNames(accessRecord); accessRecord.decisionDuration = this.dataRequestService.getDecisionDuration(accessRecord); - accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree); + accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree, accessRecord._id); accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.CUSTODIAN); return accessRecord; }) From d1bf09d4bbf1a3cb72aa61a909de404ec515f6e6 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Fri, 4 Jun 2021 11:51:09 +0100 Subject: [PATCH 36/81] Force update --- 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 a53b5d59..dc7f728a 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -2359,7 +2359,6 @@ export default class DataRequestController extends Controller { //Messages to be shown for both applicant and custodian //Applicant notes for only applicant - //Custodian notes for only custodians return res.status(200).json({ From b10c35b248846b2937437ac21f6a5b897ba0c24e Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 4 Jun 2021 12:01:36 +0100 Subject: [PATCH 37/81] Added icons for messages and notes --- src/resources/utilities/constants.util.js | 350 ++++++++++++++++++++++ 1 file changed, 350 insertions(+) diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 365628cb..d846e871 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -47,6 +47,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], inReview: { custodian: { @@ -58,6 +72,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], previousVersion: [ { @@ -67,6 +95,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], }, applicant: { @@ -78,6 +120,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], previousVersion: [ { @@ -87,6 +143,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], }, }, @@ -98,6 +168,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], ['approved with conditions']: [ { @@ -107,6 +191,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], rejected: [ { @@ -116,6 +214,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], withdrawn: [ { @@ -125,6 +237,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], }, manager: { @@ -136,6 +262,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], inReview: { custodian: { @@ -154,6 +294,20 @@ const _userQuestionActions = { toolTip: 'Request applicant updates answer', order: 2, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 3, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 4, + }, ], previousVersion: [ { @@ -163,6 +317,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], }, applicant: { @@ -174,6 +342,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], previousVersion: [ { @@ -183,6 +365,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], }, }, @@ -194,6 +390,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], ['approved with conditions']: [ { @@ -203,6 +413,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], rejected: [ { @@ -212,6 +436,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], withdrawn: [ { @@ -221,6 +459,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], }, }, @@ -233,6 +485,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], submitted: [ { @@ -242,6 +508,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], inReview: [ { @@ -251,6 +531,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], approved: [ { @@ -260,6 +554,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], ['approved with conditions']: [ { @@ -269,6 +577,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], rejected: [ { @@ -278,6 +600,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], withdrawn: [ { @@ -287,6 +623,20 @@ const _userQuestionActions = { toolTip: 'Guidance', order: 1, }, + { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, ], }, }; From 5bfc3ab9f09a060eb23df1700699006b22718879 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Mon, 7 Jun 2021 09:40:20 +0100 Subject: [PATCH 38/81] Code for sending and retrieving messages --- .../datarequest/datarequest.controller.js | 65 +++++++++++++++---- .../datarequest/datarequest.route.js | 40 +++++++++++- .../datarequest/datarequest.service.js | 3 +- src/resources/datarequest/dependency.js | 12 +++- .../datarequest/utils/datarequest.util.js | 32 ++++++--- src/resources/message/message.model.js | 4 ++ src/resources/message/message.repository.js | 20 ++++++ src/resources/message/message.service.js | 9 +++ src/resources/topic/topic.model.js | 6 +- src/resources/topic/topic.repository.js | 26 ++++++++ src/resources/topic/topic.service.js | 13 ++++ src/resources/utilities/constants.util.js | 7 ++ 12 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 src/resources/message/message.repository.js create mode 100644 src/resources/message/message.service.js create mode 100644 src/resources/topic/topic.repository.js create mode 100644 src/resources/topic/topic.service.js diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index dc7f728a..8e0b151e 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -19,11 +19,13 @@ const logCategory = 'Data Access Request'; const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); export default class DataRequestController extends Controller { - constructor(dataRequestService, workflowService, amendmentService) { + constructor(dataRequestService, workflowService, amendmentService, topicService, messageService) { super(dataRequestService); this.dataRequestService = dataRequestService; this.workflowService = workflowService; this.amendmentService = amendmentService; + this.topicService = topicService; + this.messageService = messageService; } // ###### APPLICATION CRUD OPERATIONS ####### @@ -150,13 +152,19 @@ export default class DataRequestController extends Controller { isLatestMinorVersion ); - // 13. Build version selector + // 13. Inject message and note counts + accessRecord.jsonSchema = datarequestUtil.injectMessagesAndNotesCount(accessRecord.jsonSchema, userType); + //Get all messages + //Get notes if applicant + //Get notes if team + + // 14. Build version selector const requestedFullVersion = `Version ${requestedMajorVersion}.${ _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion }`; accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree); - // 14. Return application form + // 15. Return application form return res.status(200).json({ status: 'success', data: { @@ -2333,11 +2341,12 @@ export default class DataRequestController extends Controller { } } - //GET api/v1/data-access-request/:id/messages + //GET api/v1/data-access-request/:id/:messageType async getMessages(req, res) { try { const { params: { id }, + query: { messageType, questionId }, } = req; const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; @@ -2354,15 +2363,35 @@ export default class DataRequestController extends Controller { ); if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } else if ( + userType === constants.userTypes.APPLICANT && + ![constants.DARMessageTypes.DARNOTESAPPLICANT, constants.DARMessageTypes.DARMESSAGE].includes(messageType) + ) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } else if ( + userType === constants.userTypes.CUSTODIAN && + ![constants.DARMessageTypes.DARNOTESCUSTODIAN, constants.DARMessageTypes.DARMESSAGE].includes(messageType) + ) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - //Messages to be shown for both applicant and custodian + const topic = await this.topicService.getTopicForDAR(id, questionId, messageType); - //Applicant notes for only applicant - //Custodian notes for only custodians + let messages = []; + if (!_.isEmpty(topic) && !_.isEmpty(topic.topicMessages)) { + for (let topicMessage of topic.topicMessages.reverse()) { + messages.push({ + name: `${topicMessage.createdBy.firstname} ${topicMessage.createdBy.lastname}`, + date: moment(topicMessage.createdDate).format('D MMM YYYY HH:mm'), + content: topicMessage.messageDescription, + userType: topicMessage.userType, + }); + } + } return res.status(200).json({ status: 'success', + messages, }); } catch (err) { logger.logError(err, logCategory); @@ -2379,7 +2408,7 @@ export default class DataRequestController extends Controller { const { params: { id }, } = req; - const { messageType, messageBody } = req.body; + const { questionId, messageType, messageBody } = req.body; const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; @@ -2395,13 +2424,27 @@ export default class DataRequestController extends Controller { ); if (!authorised) { return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } else if ( + userType === constants.userTypes.APPLICANT && + ![constants.DARMessageTypes.DARNOTESAPPLICANT, constants.DARMessageTypes.DARMESSAGE].includes(messageType) + ) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); + } else if ( + userType === constants.userTypes.CUSTODIAN && + ![constants.DARMessageTypes.DARNOTESCUSTODIAN, constants.DARMessageTypes.DARMESSAGE].includes(messageType) + ) { + return res.status(401).json({ status: 'failure', message: 'Unauthorised' }); } - //Messages to be shown for both applicant and custodian + let topic = await this.topicService.getTopicForDAR(id, questionId, messageType); + + if (_.isEmpty(topic)) { + topic = await this.topicService.createTopicForDAR(id, questionId, messageType); + } - //Applicant notes for only applicant + await this.messageService.createMessageForDAR(messageBody, topic._id, requestingUserObjectId, userType); - //Custodian notes for only custodians + //update message/note count in json return res.status(200).json({ status: 'success', diff --git a/src/resources/datarequest/datarequest.route.js b/src/resources/datarequest/datarequest.route.js index c4d7e12e..51f7fe98 100644 --- a/src/resources/datarequest/datarequest.route.js +++ b/src/resources/datarequest/datarequest.route.js @@ -7,7 +7,7 @@ import { param } from 'express-validator'; import { logger } from '../utilities/logger'; import DataRequestController from './datarequest.controller'; import AmendmentController from './amendment/amendment.controller'; -import { dataRequestService, workflowService, amendmentService } from './dependency'; +import { dataRequestService, workflowService, amendmentService, topicService, messageService } from './dependency'; const fs = require('fs'); const path = './tmp'; @@ -21,7 +21,13 @@ const storage = multer.diskStorage({ }); const multerMid = multer({ storage: storage }); const logCategory = 'Data Access Request'; -const dataRequestController = new DataRequestController(dataRequestService, workflowService, amendmentService); +const dataRequestController = new DataRequestController( + dataRequestService, + workflowService, + amendmentService, + topicService, + messageService +); const amendmentController = new AmendmentController(amendmentService, dataRequestService); const router = express.Router(); @@ -252,4 +258,34 @@ router.post( (req, res) => amendmentController.requestAmendments(req, res) ); +// @route PUT api/v1/data-access-request/:id/share +// @desc Update share flag for application +// @access Private - Applicant +router.put( + '/:id/share', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Update share flag for application' }), + (req, res) => dataRequestController.updateSharedDARFlag(req, res) +); + +// @route GET api/v1/data-access-request/:id/messages +// @desc Get messages or notes for application +// @access Private - Applicant/Custodian Reviewer/Manager +router.get( + '/:id/messages', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Get messages or notes for application' }), + (req, res) => dataRequestController.getMessages(req, res) +); + +// @route POST api/v1/data-access-request/:id/messages +// @desc Submitting a message or note +// @access Private - Applicant/Custodian Reviewer/Manager +router.post( + '/:id/messages', + passport.authenticate('jwt'), + logger.logRequestMiddleware({ logCategory, action: 'Submitting a message or note' }), + (req, res) => dataRequestController.submitMessage(req, res) +); + module.exports = router; diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 2bbc22ca..405e579b 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -292,7 +292,8 @@ export default class DataRequestService { let idArray = Array.isArray(ids); // Process the files for scanning - for (let i = 0; i < files.length; i++) { //lgtm [js/type-confusion-through-parameter-tampering] + for (let i = 0; i < files.length; i++) { + //lgtm [js/type-confusion-through-parameter-tampering] // Get description information let description = descriptionArray ? descriptions[i] : descriptions; // Get uniqueId diff --git a/src/resources/datarequest/dependency.js b/src/resources/datarequest/dependency.js index 2d087006..3f51ac11 100644 --- a/src/resources/datarequest/dependency.js +++ b/src/resources/datarequest/dependency.js @@ -4,6 +4,10 @@ import WorkflowRepository from '../workflow/workflow.repository'; import WorkflowService from '../workflow/workflow.service'; import AmendmentRepository from './amendment/amendment.repository'; import AmendmentService from './amendment/amendment.service'; +import TopicRepository from '../topic/topic.repository'; +import TopicService from '../topic/topic.service'; +import MessageRepository from '../message/message.repository'; +import MessageService from '../message/message.service'; export const dataRequestRepository = new DataRequestRepository(); export const dataRequestService = new DataRequestService(dataRequestRepository); @@ -12,4 +16,10 @@ export const workflowRepository = new WorkflowRepository(); export const workflowService = new WorkflowService(workflowRepository); export const amendmentRepository = new AmendmentRepository(); -export const amendmentService = new AmendmentService(amendmentRepository); \ No newline at end of file +export const amendmentService = new AmendmentService(amendmentRepository); + +export const topicRepository = new TopicRepository(); +export const topicService = new TopicService(topicRepository); + +export const messageRepository = new MessageRepository(); +export const messageService = new MessageService(messageRepository); diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index 2b49b8f9..c222cdf9 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -12,9 +12,12 @@ const injectQuestionActions = (jsonSchema, userType, applicationStatus, role = ' const version = isLatestMinorVersion ? 'latestVersion' : 'previousVersion'; if (userType === constants.userTypes.CUSTODIAN) { if (applicationStatus === constants.applicationStatuses.INREVIEW) { - formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus][activeParty][version] }; + formattedSchema = { + ...jsonSchema, + questionActions: constants.userQuestionActions[userType][role][applicationStatus][activeParty][version], + }; } else { - formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus]}; + formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus] }; } } else { formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][applicationStatus] }; @@ -184,7 +187,8 @@ const buildQuestionAlert = (userType, iterationStatus, completed, amendment, use requestedBy = matchCurrentUser(user, requestedBy); updatedBy = matchCurrentUser(user, updatedBy); // 5. Update the generic question alerts to match the scenario - const relevantActioner = !isNil(updatedBy) && includeCompleted ? updatedBy : userType === constants.userTypes.CUSTODIAN ? requestedBy : publisher; + const relevantActioner = + !isNil(updatedBy) && includeCompleted ? updatedBy : userType === constants.userTypes.CUSTODIAN ? requestedBy : publisher; questionAlert.text = questionAlert.text.replace('#NAME#', relevantActioner); questionAlert.text = questionAlert.text.replace( '#DATE#', @@ -247,7 +251,7 @@ const cloneIntoNewApplication = async (appToClone, context) => { aboutApplication: {}, amendmentIterations: [], applicationStatus: constants.applicationStatuses.INPROGRESS, - originId: _id + originId: _id, }; // 4. Extract and append any user repeated sections from the original form @@ -292,7 +296,7 @@ const copyUserRepeatedSections = (appToClone, schemaToUpdate) => { repeatedQuestionIds.forEach(qId => { // 3. Skip if question has already been copied in by a previous clone operation let questionExists = questionSets.some(qS => !isNil(dynamicForm.findQuestionRecursive(qS.questions, qId))); - if(questionExists) { + if (questionExists) { return; } // 4. Split question id to get original id and unique suffix @@ -300,7 +304,7 @@ const copyUserRepeatedSections = (appToClone, schemaToUpdate) => { // 5. Find the question in the new schema questionSets.forEach(qS => { // 6. Check if related group has already been copied in by this clone operation - if(copiedQuestionSuffixes.includes(uniqueSuffix)) { + if (copiedQuestionSuffixes.includes(uniqueSuffix)) { return; } let question = dynamicForm.findQuestionRecursive(qS.questions, questionId); @@ -320,22 +324,25 @@ const insertUserRepeatedSections = (questionSets, questionSet, schemaToUpdate, u const { questionSetId, questions } = questionSet; // 1. Determine if question is repeatable via a question set or question group const repeatQuestionsId = `add-${questionSetId}`; - if(questionSets.some(qS => qS.questionSetId === repeatQuestionsId)) { + if (questionSets.some(qS => qS.questionSetId === repeatQuestionsId)) { // 2. Replicate question set let duplicateQuestionSet = dynamicForm.duplicateQuestionSet(repeatQuestionsId, schemaToUpdate, uniqueSuffix); schemaToUpdate = dynamicForm.insertQuestionSet(repeatQuestionsId, duplicateQuestionSet, schemaToUpdate); } else { // 2. Find and replicate the question group let duplicateQuestionsButton = dynamicForm.findQuestionRecursive(questions, repeatQuestionsId); - if(duplicateQuestionsButton) { - const { questionId, input: { questionIds, separatorText } } = duplicateQuestionsButton; + if (duplicateQuestionsButton) { + const { + questionId, + input: { questionIds, separatorText }, + } = duplicateQuestionsButton; let duplicateQuestions = dynamicForm.duplicateQuestions(questionSetId, questionIds, separatorText, schemaToUpdate, uniqueSuffix); schemaToUpdate = dynamicForm.insertQuestions(questionSetId, questionId, duplicateQuestions, schemaToUpdate); } } // 3. Return updated schema return schemaToUpdate; -} +}; const extractRepeatedQuestionIds = questionAnswers => { // 1. Reduce original question answers to only answers relating to repeating sections @@ -347,6 +354,10 @@ const extractRepeatedQuestionIds = questionAnswers => { }, []); }; +const injectMessagesAndNotesCount = (jsonSchema, userType) => { + return jsonSchema; +}; + export default { injectQuestionActions: injectQuestionActions, getUserPermissionsForApplication: getUserPermissionsForApplication, @@ -357,4 +368,5 @@ export default { setQuestionState: setQuestionState, cloneIntoExistingApplication: cloneIntoExistingApplication, cloneIntoNewApplication: cloneIntoNewApplication, + injectMessagesAndNotesCount, }; diff --git a/src/resources/message/message.model.js b/src/resources/message/message.model.js index 50dff0c1..a132fc53 100644 --- a/src/resources/message/message.model.js +++ b/src/resources/message/message.model.js @@ -46,6 +46,10 @@ const MessageSchema = new Schema( type: Schema.Types.ObjectId, ref: 'User', }, + userType: { + type: String, + enum: ['applicant', 'custodian'], + }, createdDate: { type: Date, default: Date.now, diff --git a/src/resources/message/message.repository.js b/src/resources/message/message.repository.js new file mode 100644 index 00000000..f399001b --- /dev/null +++ b/src/resources/message/message.repository.js @@ -0,0 +1,20 @@ +import Repository from '../base/repository'; +import { MessagesModel } from './message.model'; + +export default class MessageRepository extends Repository { + constructor() { + super(MessagesModel); + this.messagesModel = MessagesModel; + } + + createMessageForDAR(messageBody, topicID, userID, userType) { + return MessagesModel.create({ + messageID: parseInt(Math.random().toString().replace('0.', '')), + messageObjectID: parseInt(Math.random().toString().replace('0.', '')), + messageDescription: messageBody, + topic: topicID, + createdBy: userID, + userType, + }); + } +} diff --git a/src/resources/message/message.service.js b/src/resources/message/message.service.js new file mode 100644 index 00000000..21e42d53 --- /dev/null +++ b/src/resources/message/message.service.js @@ -0,0 +1,9 @@ +export default class MessageService { + constructor(messageRepository) { + this.messageRepository = messageRepository; + } + + createMessageForDAR(messageBody, topicID, userID, userType) { + return this.messageRepository.createMessageForDAR(messageBody, topicID, userID, userType); + } +} diff --git a/src/resources/topic/topic.model.js b/src/resources/topic/topic.model.js index d60e1c68..7bc9008c 100644 --- a/src/resources/topic/topic.model.js +++ b/src/resources/topic/topic.model.js @@ -12,6 +12,10 @@ const TopicSchema = new Schema( default: '', trim: true, }, + messageType: { + type: String, + enum: ['DAR_Message', 'DAR_Notes_Applicant', 'DAR_Notes_Custodian'], + }, recipients: [ { type: Schema.Types.ObjectId, @@ -85,7 +89,7 @@ TopicSchema.pre(/^find/, function (next) { path: 'createdBy', select: 'firstname lastname', path: 'topicMessages', - select: 'messageDescription createdDate isRead _id readBy', + select: 'messageDescription createdDate isRead _id readBy userType', options: { sort: '-createdDate' }, populate: { path: 'createdBy', diff --git a/src/resources/topic/topic.repository.js b/src/resources/topic/topic.repository.js new file mode 100644 index 00000000..52440d63 --- /dev/null +++ b/src/resources/topic/topic.repository.js @@ -0,0 +1,26 @@ +import Repository from '../base/repository'; +import { TopicModel } from './topic.model'; + +export default class TopicRepository extends Repository { + constructor() { + super(TopicModel); + this.topicModel = TopicModel; + } + + getTopicForDAR(title, subTitle, messageType) { + return TopicModel.findOne({ + title, + subTitle, + messageType, + }).lean(); + } + + createTopicForDAR(title, subTitle, messageType) { + return TopicModel.create({ + title, + subTitle, + createdDate: Date.now(), + messageType, + }); + } +} diff --git a/src/resources/topic/topic.service.js b/src/resources/topic/topic.service.js new file mode 100644 index 00000000..6c877218 --- /dev/null +++ b/src/resources/topic/topic.service.js @@ -0,0 +1,13 @@ +export default class TopicService { + constructor(topicRepository) { + this.topicRepository = topicRepository; + } + + getTopicForDAR(applicationID, questionID, messageType) { + return this.topicRepository.getTopicForDAR(applicationID, questionID, messageType); + } + + createTopicForDAR(applicationID, questionID, messageType) { + return this.topicRepository.createTopicForDAR(applicationID, questionID, messageType); + } +} diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index d846e871..267ec803 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -765,6 +765,12 @@ const _darPanelMapper = { safeoutputs: 'Safe outputs', }; +const _DARMessageTypes = { + DARMESSAGE: 'DAR_Message', + DARNOTESAPPLICANT: 'DAR_Notes_Applicant', + DARNOTESCUSTODIAN: 'DAR_Notes_Custodian', +}; + // // @@ -829,4 +835,5 @@ export default { mailchimpSubscriptionStatuses: _mailchimpSubscriptionStatuses, datatsetStatuses: _datatsetStatuses, logTypes: _logTypes, + DARMessageTypes: _DARMessageTypes, }; From da08b91552258a5bd530860a976b48a3ee093e77 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 7 Jun 2021 15:46:49 +0100 Subject: [PATCH 39/81] Fixed minor versioning issues --- .../1620558117918-applications_versioning.js | 3 +- .../amendment/amendment.controller.js | 43 +++++++-- .../amendment/amendment.service.js | 90 +++++++++++------- .../datarequest/datarequest.controller.js | 42 ++++++-- .../datarequest/datarequest.entity.js | 4 +- .../datarequest/datarequest.model.js | 2 +- .../datarequest/datarequest.repository.js | 2 +- .../datarequest/datarequest.service.js | 19 +++- .../datarequest/utils/datarequest.util.js | 95 ++++++++++--------- .../publisher/publisher.repository.js | 2 +- src/resources/utilities/constants.util.js | 18 ++++ 11 files changed, 222 insertions(+), 98 deletions(-) diff --git a/migrations/1620558117918-applications_versioning.js b/migrations/1620558117918-applications_versioning.js index d0382243..652b97ff 100644 --- a/migrations/1620558117918-applications_versioning.js +++ b/migrations/1620558117918-applications_versioning.js @@ -1,5 +1,6 @@ import { DataRequestModel } from '../src/resources/datarequest/datarequest.model'; import { buildVersionTree } from '../src/resources/datarequest/datarequest.entity'; +import constants from '../src/resources/utilities/constants.util'; async function up() { // 1. Add default application type to all applications @@ -18,7 +19,7 @@ async function up() { updateOne: { filter: { _id }, update: { - applicationType: 'Initial', + applicationType: constants.submissionTypes.INITIAL, majorVersion: 1.0, version: undefined, versionTree, diff --git a/src/resources/datarequest/amendment/amendment.controller.js b/src/resources/datarequest/amendment/amendment.controller.js index a1c0ac6d..0ec670e3 100644 --- a/src/resources/datarequest/amendment/amendment.controller.js +++ b/src/resources/datarequest/amendment/amendment.controller.js @@ -32,7 +32,7 @@ export default class AmendmentController extends Controller { } // 2. Retrieve DAR from database - const accessRecord = await this.dataRequestService.getApplicationWithTeamById(id); + const accessRecord = await this.dataRequestService.getApplicationWithTeamById(id); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'Application not found.' }); } @@ -49,7 +49,11 @@ export default class AmendmentController extends Controller { } // 4. Get the requesting users permission levels - let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(accessRecord.toObject(), requestingUserId, requestingUserObjectId); + let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication( + accessRecord.toObject(), + requestingUserId, + requestingUserObjectId + ); // 5. Get the current iteration amendment party let validParty = false; @@ -104,11 +108,37 @@ export default class AmendmentController extends Controller { console.error(err.message); return res.status(500).json({ status: 'error', message: err.message }); } else { - // 10. Update json schema and question answers with modifications since original submission + // 10. Update json schema and question answers with modifications since original submission and retain previous version requested updates let accessRecordObj = accessRecord.toObject(); - accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user); - // 11. Append question actions depending on user type and application status + // 11. Support for versioning + if (accessRecordObj.amendmentIterations.length > 0) { + + // Detemine which versions to return + let currentVersionIndex; + let previousVersionIndex; + const unreleasedVersionIndex = accessRecordObj.amendmentIterations.findIndex(iteration => _.isNil(iteration.dateReturned)); + + if(unreleasedVersionIndex === -1) { + currentVersionIndex = accessRecordObj.amendmentIterations.length -1; + } else { + currentVersionIndex = accessRecordObj.amendmentIterations.length -2; + } + previousVersionIndex = currentVersionIndex - 1; + + // Inject updates from previous version + accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user, previousVersionIndex, true); + + // Inject updates from current version + accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user, currentVersionIndex, true); + + // Inject updates from possible unreleased version + if(unreleasedVersionIndex !== -1) { + accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user, unreleasedVersionIndex, true, false); + } + } + + // 12. Append question actions depending on user type and application status let userRole = activeParty === constants.userTypes.CUSTODIAN ? constants.roleTypes.MANAGER : ''; accessRecordObj.jsonSchema = datarequestUtil.injectQuestionActions( accessRecordObj.jsonSchema, @@ -118,7 +148,7 @@ export default class AmendmentController extends Controller { activeParty ); - // 12. Count the number of answered/unanswered amendments + // 13. Count the number of answered/unanswered amendments const { answeredAmendments = 0, unansweredAmendments = 0 } = this.amendmentService.countAmendments(accessRecord, userType); return res.status(200).json({ success: true, @@ -202,7 +232,6 @@ export default class AmendmentController extends Controller { console.error(err.message); return res.status(500).json({ status: 'error', message: err.message }); } else { - // 10. Send update request notifications this.amendmentService.createNotifications(constants.notificationTypes.RETURNED, accessRecord); return res.status(200).json({ diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index effe288a..88120478 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -163,8 +163,8 @@ export default class AmendmentService { }); } - getAmendmentIterationParty(accessRecord, versionIndex) { - if (!versionIndex) { + getAmendmentIterationParty(accessRecord, versionIndex, isLatestVersion) { + if ((!versionIndex && versionIndex !== 0) || isLatestVersion) { // 1. Look for an amendment iteration that is in flight // An empty date submitted with populated date returned indicates that the current correction iteration is now with the applicants let index = accessRecord.amendmentIterations.findIndex(v => _.isUndefined(v.dateSubmitted) && !_.isUndefined(v.dateReturned)); @@ -184,7 +184,10 @@ export default class AmendmentService { // An empty submission date with a valid return date (added by Custodians returning the form) indicates applicants are active const requestedAmendmentIteration = accessRecord.amendmentIterations[versionIndex]; if (requestedAmendmentIteration === _.last(accessRecord.amendmentIterations)) { - if (!requestedAmendmentIteration || (_.isUndefined(requestedAmendmentIteration.dateSubmitted) && !_.isUndefined(requestedAmendmentIteration.dateReturned))) { + if ( + !requestedAmendmentIteration || + (_.isUndefined(requestedAmendmentIteration.dateSubmitted) && !_.isUndefined(requestedAmendmentIteration.dateReturned)) + ) { return constants.userTypes.APPLICANT; } else { return constants.userTypes.CUSTODIAN; @@ -197,30 +200,44 @@ export default class AmendmentService { getAmendmentIterationDetailsByVersion(accessRecord, minorVersion) { const { amendmentIterations = [] } = accessRecord; - // Get amendment iteration index, initial version will be offset by 1 to find array index i.e. 1.0 = -1, 1.1 = 0, 1.2 = 1 etc. - // versions beyond 1 will have matching offset to array index as 2.0 includes amendments on first submission i.e. 2.0 = 0, 2.1 = 1, 2.2 = 2 etc. - //const versionIndex = majorVersion === 1 ? minorVersion - 1 : minorVersion; - // If no minor version updates are requested, - const versionIndex = minorVersion - 1; - - // Get active party for selected index - const activeParty = this.getAmendmentIterationParty(accessRecord, versionIndex); + + // 1. Calculate version index from version number by subtracting 1 for zero based array + let versionIndex = minorVersion - 1; + + // 2. Check if selected version is latest (if no minor version then default latest) + const isLatestMinorVersion = + amendmentIterations[versionIndex] === _.last(amendmentIterations) || + isNaN(minorVersion) || + _.isNil(amendmentIterations[minorVersion].dateReturned) || + _.isNil(amendmentIterations[minorVersion].dateSubmitted); + + // 3. Get active party for selected version index + const activeParty = this.getAmendmentIterationParty(accessRecord, versionIndex, isLatestMinorVersion); + + // 4. If version index was not determined, use latest available (if unreleased version is found, skip it) + if (isNaN(versionIndex)) { + const unreleasedVersionIndex = accessRecord.amendmentIterations.findIndex(iteration => _.isNil(iteration.dateReturned)); - // Check if selected version is latest - const isLatestMinorVersion = amendmentIterations[versionIndex] === _.last(amendmentIterations) || isNaN(minorVersion); + if (unreleasedVersionIndex === -1) { + versionIndex = accessRecord.amendmentIterations.length - 1; + } else { + versionIndex = accessRecord.amendmentIterations.length - 2; + } + } + // 5. Return iteration details for request version return { versionIndex, activeParty, isLatestMinorVersion }; } filterAmendments(accessRecord = {}, userType, lastIterationIndex) { // 1. Guard for invalid access record - if (_.isEmpty(accessRecord)) { - return {}; + if (_.isEmpty(accessRecord) || lastIterationIndex === -1) { + return []; } let { amendmentIterations = [] } = accessRecord; // 2. Slice any superfluous amendment iterations if a previous version has been explicitly requested - if (lastIterationIndex) { + if (!_.isNil(lastIterationIndex) && lastIterationIndex > -1) { amendmentIterations = amendmentIterations.slice(0, lastIterationIndex + 1); } @@ -244,26 +261,29 @@ export default class AmendmentService { return amendmentIterations; } - injectAmendments(accessRecord, userType, user, versionIndex, includeCompleted = true) { - let latestIteration; + injectAmendments(accessRecord, userType, user, versionIndex, includeCompleted = true, includeAnswers = true) { + let latestIteration = {}; // 1. Ensure minor versions exist and requested version index is valid - if (accessRecord.amendmentIterations.length === 0 || versionIndex < -1) { + if (accessRecord.amendmentIterations.length === 0 || versionIndex === -1) { return accessRecord; } - // 2. If a specific version has not be requested, fetch the latest (last) amendment iteration to include all changes to date - if (!versionIndex) { + // 2. If a specific version has not been requested, fetch the latest (last) amendment iteration to include all changes to date + if (typeof versionIndex === 'undefined') { versionIndex = _.findLastIndex(accessRecord.amendmentIterations); latestIteration = accessRecord.amendmentIterations[versionIndex]; } else { - latestIteration = accessRecord.amendmentIterations[versionIndex + 1] || accessRecord.amendmentIterations[versionIndex]; + latestIteration = accessRecord.amendmentIterations[versionIndex]; } - // 3. Get requested updates for next version if it exists (must be created by custodians by requesting updates) + // 3. Return without amendments if the custodian has not started a new iteration / unreleased version + if (!latestIteration) return accessRecord; + + // 4. Get requested updates for next version if it exists (must be created by custodians by requesting updates) const { dateReturned } = latestIteration; - // 4. Applicants should see previous amendment iteration requests until current iteration has been returned with new requests + // 5. Applicants should see previous amendment iteration requests until current iteration has been returned with new requests if ( (versionIndex > 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) || (userType === constants.userTypes.CUSTODIAN && _.isNil(latestIteration.questionAnswers)) @@ -273,32 +293,35 @@ export default class AmendmentService { return accessRecord; } - // 5. Update schema if there is a new iteration + // 6. Update schema if there is a new iteration const { publisher = 'Custodian' } = accessRecord; if (!_.isNil(latestIteration)) { accessRecord.jsonSchema = this.formatSchema(accessRecord.jsonSchema, latestIteration, userType, user, publisher, includeCompleted); } - // 6. Filter out amendments that have not yet been exposed to the opposite party + // 7. Filter out amendments that have not yet been exposed to the opposite party or if looking at historic version + if (!includeAnswers) { + versionIndex--; + } const amendmentIterations = this.filterAmendments(accessRecord, userType, versionIndex); - // 7. Update the question answers to reflect all the changes that have been made in later iterations + // 8. Update the question answers to reflect all the changes that have been made in later iterations accessRecord.questionAnswers = this.formatQuestionAnswers(accessRecord.questionAnswers, amendmentIterations); - // 8. Return the updated access record + // 9. Return the updated access record return accessRecord; } formatSchema(jsonSchema, amendmentIteration, userType, user, publisher, includeCompleted = true) { const { questionAnswers = {}, dateSubmitted, dateReturned } = amendmentIteration; - if (_.isEmpty(questionAnswers)) { + if (_.isEmpty(questionAnswers) || (userType === constants.userTypes.APPLICANT && _.isNil(dateReturned))) { return jsonSchema; } // Loop through each amendment for (let questionId in questionAnswers) { - const { questionSetId, answer } = questionAnswers[questionId]; + const { questionSetId, answer, updatedBy } = questionAnswers[questionId]; // 1. Update parent/child navigation with flags for amendments - const amendmentCompleted = _.isNil(answer) || !includeCompleted ? 'incomplete' : 'completed'; + const amendmentCompleted = !_.isNil(answer) && updatedBy && includeCompleted ? 'completed' : 'incomplete'; const iterationStatus = !_.isNil(dateSubmitted) && includeCompleted ? 'submitted' : !_.isNil(dateReturned) ? 'returned' : 'inProgress'; jsonSchema = this.injectNavigationAmendment(jsonSchema, questionSetId, userType, amendmentCompleted, iterationStatus); @@ -508,9 +531,10 @@ export default class AmendmentService { const index = this.getLatestAmendmentIterationIndex(accessRecord); let unansweredAmendments = 0; let answeredAmendments = 0; - + if ( - !isLatestVersion || index === -1 || + !isLatestVersion || + index === -1 || _.isNil(accessRecord.amendmentIterations[index].questionAnswers) || (_.isNil(accessRecord.amendmentIterations[index].dateReturned) && userType == constants.userTypes.APPLICANT) ) { diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 9254c98c..4ffaffb9 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -137,10 +137,13 @@ export default class DataRequestController extends Controller { // 10. Inject completed update requests from previous version to the requested version e.g. 1.1 if 1.2 requested accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex - 1, true); - // 11. Inject updates from requested version e.g. 1.2 - accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex, isLatestMinorVersion); + // 11. Inject updates for current version + accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex, true); - // 12. Append question actions depending on user type and application status + // 12. Inject updates from any unreleased version e.g. 1.2 + accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex + 1, isLatestMinorVersion, false); + + // 13. Append question actions depending on user type and application status accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( jsonSchema, userType, @@ -150,13 +153,13 @@ export default class DataRequestController extends Controller { isLatestMinorVersion ); - // 13. Build version selector + // 14. Build version selector const requestedFullVersion = `${requestedMajorVersion}.${ _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion }`; accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree, accessRecord._id, requestedFullVersion); - // 14. Return application form + // 15. Return application form return res.status(200).json({ status: 'success', data: { @@ -400,9 +403,34 @@ export default class DataRequestController extends Controller { const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = accessRecord; if (dirtySchema) { - accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser); + // 6. Support for versioning + if (accessRecord.amendmentIterations.length > 0) { + + // Detemine which versions to return + let currentVersionIndex; + let previousVersionIndex; + const unreleasedVersionIndex = accessRecord.amendmentIterations.findIndex(iteration => _.isNil(iteration.dateReturned)); + + if(unreleasedVersionIndex === -1) { + currentVersionIndex = accessRecord.amendmentIterations.length -1; + } else { + currentVersionIndex = accessRecord.amendmentIterations.length -2; + } + previousVersionIndex = currentVersionIndex - 1; + + // Inject updates from previous version + accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser, previousVersionIndex, true); + + // Inject updates from current version + accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser, currentVersionIndex, true); + + // Inject updates from possible unreleased version + if(unreleasedVersionIndex !== -1) { + accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser, unreleasedVersionIndex, true, false); + } + } } - // 6. Return new data object + // 7. Return new data object return res.status(200).json({ status: 'success', unansweredAmendments, diff --git a/src/resources/datarequest/datarequest.entity.js b/src/resources/datarequest/datarequest.entity.js index ecaeed78..63a9217e 100644 --- a/src/resources/datarequest/datarequest.entity.js +++ b/src/resources/datarequest/datarequest.entity.js @@ -28,7 +28,7 @@ export default class DataRequestClass extends Entity { } getInitialApplicationId() { - return this.versionTree['1'].applicationId; + return this.versionTree['1.0'].applicationId; } /** @@ -92,7 +92,7 @@ export const buildVersionTree = accessRecord => { amendmentIterations = [], applicationType = constants.submissionTypes.INITIAL, } = accessRecord; - const versionKey = majorVersion ? majorVersion.toString() : '1'; + const versionKey = majorVersion ? majorVersion.toString() : '1.0'; // 3. Reverse iterate through amendment iterations and construct minor versions let minorVersions = {}; diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 6c529846..45389660 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -23,7 +23,7 @@ const DataRequestSchema = new Schema( }, applicationType: { type: String, - default: 'Initial', + default: constants.submissionTypes.INITIAL, enum: Object.values(constants.submissionTypes) }, archived: { diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index da2cdffc..231637ba 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -15,7 +15,7 @@ export default class DataRequestRepository extends Repository { return DataRequestModel.find({ $and: [{ ...query }, { $or: [{ userId }, { authorIds: userId }] }], }) - .select('-jsonSchema -questionAnswers -files') + .select('-jsonSchema -files') .populate([{ path: 'mainApplicant', select: 'firstname lastname -id' }, { path: 'datasets' }]) .lean(); } diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 249b8073..b585f0dd 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -153,6 +153,7 @@ export default class DataRequestService { buildVersionHistory = (versionTree, applicationId, requestedVersion) => { const unsortedVersions = Object.keys(versionTree).reduce((arr, versionKey) => { const { applicationId: _id, link, displayTitle, detailedTitle } = versionTree[versionKey]; + const isCurrent = applicationId.toString() === _id.toString() && (requestedVersion === versionKey || !requestedVersion); const version = { number: versionKey, @@ -160,7 +161,7 @@ export default class DataRequestService { link, displayTitle, detailedTitle, - isCurrent: applicationId.toString() === _id.toString() && ((requestedVersion === versionKey || !requestedVersion)) + isCurrent, }; arr = [...arr, version]; @@ -168,7 +169,21 @@ export default class DataRequestService { return arr; }, []); - return orderBy(unsortedVersions, ['number'], ['desc']); + const orderedVersions = orderBy(unsortedVersions, ['number'], ['desc']); + + // If a current version is not found, this means an unpublished version is in progress with the Custodian, therefore we must select the previous available version + if (!orderedVersions.some(v => v.isCurrent)) { + const previousVersion = parseFloat(requestedVersion) - 0.1; + const previousVersionIndex = orderedVersions.findIndex(v => parseFloat(v.number).toFixed(1) === previousVersion.toFixed(1)); + if (previousVersionIndex !== -1) { + orderedVersions[previousVersionIndex].isCurrent = true; + } + else { + orderedVersions[0].isCurrent = true; + } + } + + return orderedVersions; }; getProjectName(accessRecord) { diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index 304c92fc..dac1c8e4 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -8,18 +8,23 @@ import dynamicForm from '../../utilities/dynamicForms/dynamicForm.util'; const repeatedSectionRegex = /_[a-zA-Z|\d]{5}$/gm; const injectQuestionActions = (jsonSchema, userType, applicationStatus, role = '', activeParty, isLatestMinorVersion = true) => { - let formattedSchema = {}; - const version = isLatestMinorVersion ? 'latestVersion' : 'previousVersion'; - if (userType === constants.userTypes.CUSTODIAN) { - if (applicationStatus === constants.applicationStatuses.INREVIEW) { - formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus][activeParty][version] }; - } else { - formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus]}; - } - } else { - formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][applicationStatus] }; + if ( + userType === constants.userTypes.CUSTODIAN && + applicationStatus === constants.applicationStatuses.INREVIEW && + activeParty === constants.userTypes.CUSTODIAN && + role === constants.roleTypes.MANAGER && + isLatestMinorVersion + ) + return { + ...jsonSchema, + questionActions: [constants.questionActions.guidance, constants.questionActions.updates], + }; + else { + return { + ...jsonSchema, + questionActions: [constants.questionActions.guidance], + }; } - return formattedSchema; }; const getUserPermissionsForApplication = (application, userId, _id) => { @@ -56,30 +61,18 @@ const getUserPermissionsForApplication = (application, userId, _id) => { }; const 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; - } + const fullNameQuestions = ['safepeopleprimaryapplicantfullname', 'safepeopleotherindividualsfullname']; + const fullNames = []; + + if (isNil(questionAnswers)) return fullNames; + + Object.keys(questionAnswers).forEach(key => { + if (fullNameQuestions.some(q => key.includes(q))) { + fullNames.push(questionAnswers[key]); } - } - return fullnames; + }); + + return fullNames; }; const findQuestion = (questionsArr, questionId) => { @@ -183,8 +176,21 @@ const buildQuestionAlert = (userType, iterationStatus, completed, amendment, use // 4. Update audit fields to 'you' if the action was performed by the current user requestedBy = matchCurrentUser(user, requestedBy); updatedBy = matchCurrentUser(user, updatedBy); + let relevantActioner; // 5. Update the generic question alerts to match the scenario - const relevantActioner = !isNil(updatedBy) && includeCompleted ? updatedBy : userType === constants.userTypes.CUSTODIAN ? requestedBy : publisher; + if (userType === constants.userTypes.CUSTODIAN) + if (iterationStatus === 'inProgress' || iterationStatus === 'returned' || !includeCompleted) { + relevantActioner = requestedBy; + } else { + relevantActioner = updatedBy; + } + else if (userType === constants.userTypes.APPLICANT) { + if (!isNil(updatedBy) && includeCompleted) { + relevantActioner = updatedBy; + } else { + relevantActioner = publisher; + } + } questionAlert.text = questionAlert.text.replace('#NAME#', relevantActioner); questionAlert.text = questionAlert.text.replace( '#DATE#', @@ -247,7 +253,7 @@ const cloneIntoNewApplication = async (appToClone, context) => { aboutApplication: {}, amendmentIterations: [], applicationStatus: constants.applicationStatuses.INPROGRESS, - originId: _id + originId: _id, }; // 4. Extract and append any user repeated sections from the original form @@ -292,7 +298,7 @@ const copyUserRepeatedSections = (appToClone, schemaToUpdate) => { repeatedQuestionIds.forEach(qId => { // 3. Skip if question has already been copied in by a previous clone operation let questionExists = questionSets.some(qS => !isNil(dynamicForm.findQuestionRecursive(qS.questions, qId))); - if(questionExists) { + if (questionExists) { return; } // 4. Split question id to get original id and unique suffix @@ -300,7 +306,7 @@ const copyUserRepeatedSections = (appToClone, schemaToUpdate) => { // 5. Find the question in the new schema questionSets.forEach(qS => { // 6. Check if related group has already been copied in by this clone operation - if(copiedQuestionSuffixes.includes(uniqueSuffix)) { + if (copiedQuestionSuffixes.includes(uniqueSuffix)) { return; } let question = dynamicForm.findQuestionRecursive(qS.questions, questionId); @@ -320,22 +326,25 @@ const insertUserRepeatedSections = (questionSets, questionSet, schemaToUpdate, u const { questionSetId, questions } = questionSet; // 1. Determine if question is repeatable via a question set or question group const repeatQuestionsId = `add-${questionSetId}`; - if(questionSets.some(qS => qS.questionSetId === repeatQuestionsId)) { + if (questionSets.some(qS => qS.questionSetId === repeatQuestionsId)) { // 2. Replicate question set let duplicateQuestionSet = dynamicForm.duplicateQuestionSet(repeatQuestionsId, schemaToUpdate, uniqueSuffix); schemaToUpdate = dynamicForm.insertQuestionSet(repeatQuestionsId, duplicateQuestionSet, schemaToUpdate); } else { // 2. Find and replicate the question group let duplicateQuestionsButton = dynamicForm.findQuestionRecursive(questions, repeatQuestionsId); - if(duplicateQuestionsButton) { - const { questionId, input: { questionIds, separatorText } } = duplicateQuestionsButton; + if (duplicateQuestionsButton) { + const { + questionId, + input: { questionIds, separatorText }, + } = duplicateQuestionsButton; let duplicateQuestions = dynamicForm.duplicateQuestions(questionSetId, questionIds, separatorText, schemaToUpdate, uniqueSuffix); schemaToUpdate = dynamicForm.insertQuestions(questionSetId, questionId, duplicateQuestions, schemaToUpdate); } } // 3. Return updated schema return schemaToUpdate; -} +}; const extractRepeatedQuestionIds = questionAnswers => { // 1. Reduce original question answers to only answers relating to repeating sections @@ -359,5 +368,5 @@ export default { cloneIntoNewApplication: cloneIntoNewApplication, getLatestPublisherSchema: getLatestPublisherSchema, containsUserRepeatedSections: containsUserRepeatedSections, - copyUserRepeatedSections: copyUserRepeatedSections + copyUserRepeatedSections: copyUserRepeatedSections, }; diff --git a/src/resources/publisher/publisher.repository.js b/src/resources/publisher/publisher.repository.js index 038823c0..cb087c63 100644 --- a/src/resources/publisher/publisher.repository.js +++ b/src/resources/publisher/publisher.repository.js @@ -35,7 +35,7 @@ export default class PublisherRepository extends Repository { getPublisherDataAccessRequests(query) { return DataRequestModel.find(query) - .select('-jsonSchema -questionAnswers -files') + .select('-jsonSchema -files') .sort({ updatedAt: -1 }) .populate([ { diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 525d678d..246d2e79 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -36,6 +36,23 @@ const _teamNotificationTypesHuman = Object.freeze({ const _enquiryFormId = '5f0c4af5d138d3e486270031'; +const _questionActions = { + guidance: { + key: 'guidance', + icon: 'far fa-question-circle', + color: '#475da7', + toolTip: 'Guidance', + order: 1, + }, + updates: { + key: 'requestAmendment', + icon: 'fas fa-exclamation-circle', + color: '#F0BB24', + toolTip: 'Request applicant updates answer', + order: 2, + }, +}; + const _userQuestionActions = { custodian: { reviewer: { @@ -467,6 +484,7 @@ export default { teamNotificationTypesHuman: _teamNotificationTypesHuman, teamNotificationEmailContentTypes: _teamNotificationEmailContentTypes, userQuestionActions: _userQuestionActions, + questionActions: _questionActions, navigationFlags: _navigationFlags, amendmentStatuses: _amendmentStatuses, notificationTypes: _notificationTypes, From ea61563543f713f85815fcb88e2876f460174f78 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Fri, 11 Jun 2021 08:52:51 +0100 Subject: [PATCH 40/81] Continued building amend --- src/resources/datarequest/datarequest.controller.js | 5 +++-- src/resources/datarequest/datarequest.repository.js | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 4ffaffb9..abf5000f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -212,9 +212,10 @@ export default class DataRequestController extends Controller { const datasets = await this.dataRequestService.getDatasetsForApplicationByIds(arrDatasetIds); const arrDatasetNames = datasets.map(dataset => dataset.name); - // 5. If in progress application found prepare to return data + // 5. If in progress application found use existing endpoint to handle logic to fetch and return if (accessRecord) { - data = { ...accessRecord }; + req.params.id = accessRecord._id; + return await this.getAccessRequestById(req, res); } else { if (_.isEmpty(datasets)) { return res.status(500).json({ status: 'error', message: 'No datasets available.' }); diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index 231637ba..77829b50 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -55,7 +55,7 @@ export default class DataRequestRepository extends Repository { }, { path: 'files.owner', select: 'firstname lastname' }, ]) - .sort({ createdAt: 1 }) + .sort({ createdAt: -1 }) .lean(); } From 1504e3719549495b60876b803864acce25870e44 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 14 Jun 2021 14:40:14 +0100 Subject: [PATCH 41/81] Continued build --- .../datarequest/datarequest.controller.js | 67 +++++++++++++------ .../datarequest/datarequest.model.js | 3 + .../datarequest/datarequest.service.js | 53 ++++++++++++++- 3 files changed, 101 insertions(+), 22 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index abf5000f..29e2804d 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -141,7 +141,14 @@ export default class DataRequestController extends Controller { accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex, true); // 12. Inject updates from any unreleased version e.g. 1.2 - accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex + 1, isLatestMinorVersion, false); + accessRecord = this.amendmentService.injectAmendments( + accessRecord, + userType, + requestingUser, + versionIndex + 1, + isLatestMinorVersion, + false + ); // 13. Append question actions depending on user type and application status accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( @@ -174,7 +181,7 @@ export default class DataRequestController extends Controller { hasRecommended, workflow, files: accessRecord.files || [], - isLatestMinorVersion + isLatestMinorVersion, }, }); } catch (err) { @@ -290,6 +297,7 @@ export default class DataRequestController extends Controller { const requestingUser = req.user; const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; + const { description = '' } = req.body; // 2. Find the relevant data request application let accessRecord = await this.dataRequestService.getApplicationToSubmitById(id); @@ -315,12 +323,20 @@ export default class DataRequestController extends Controller { // 5. Perform either initial submission or resubmission depending on application status if (accessRecord.applicationStatus === constants.applicationStatuses.INPROGRESS) { - accessRecord = this.dataRequestService.doInitialSubmission(accessRecord); + switch(accessRecord.applicationType) { + case constants.submissionTypes.AMENDED: + accessRecord = await this.dataRequestService.doAmendSubmission(accessRecord, description); + break; + case constants.submissionTypes.INITIAL: + default: + accessRecord = await this.dataRequestService.doInitialSubmission(accessRecord); + break; + } } else if ( accessRecord.applicationStatus === constants.applicationStatuses.INREVIEW || accessRecord.applicationStatus === constants.applicationStatuses.SUBMITTED ) { - accessRecord = this.amendmentService.doResubmission(accessRecord, requestingUserObjectId.toString()); + accessRecord = await this.amendmentService.doResubmission(accessRecord, requestingUserObjectId.toString()); await this.dataRequestService.syncRelatedVersions(accessRecord.versionTree); } @@ -343,12 +359,7 @@ export default class DataRequestController extends Controller { // 9. Calculate notification type to send const notificationType = constants.submissionNotifications[accessRecord.applicationType]; - await this.createNotifications( - notificationType, - {}, - accessRecord, - requestingUser - ); + await this.createNotifications(notificationType, {}, accessRecord, requestingUser); // 9. Start workflow process in Camunda if publisher requires it and it is the first submission if (savedAccessRecord.workflowEnabled && savedAccessRecord.applicationType === constants.submissionTypes.INITIAL) { @@ -406,28 +417,46 @@ export default class DataRequestController extends Controller { if (dirtySchema) { // 6. Support for versioning if (accessRecord.amendmentIterations.length > 0) { - // Detemine which versions to return let currentVersionIndex; let previousVersionIndex; const unreleasedVersionIndex = accessRecord.amendmentIterations.findIndex(iteration => _.isNil(iteration.dateReturned)); - - if(unreleasedVersionIndex === -1) { - currentVersionIndex = accessRecord.amendmentIterations.length -1; + + if (unreleasedVersionIndex === -1) { + currentVersionIndex = accessRecord.amendmentIterations.length - 1; } else { - currentVersionIndex = accessRecord.amendmentIterations.length -2; + currentVersionIndex = accessRecord.amendmentIterations.length - 2; } previousVersionIndex = currentVersionIndex - 1; // Inject updates from previous version - accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser, previousVersionIndex, true); + accessRecord = this.amendmentService.injectAmendments( + accessRecord, + constants.userTypes.APPLICANT, + requestingUser, + previousVersionIndex, + true + ); // Inject updates from current version - accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser, currentVersionIndex, true); + accessRecord = this.amendmentService.injectAmendments( + accessRecord, + constants.userTypes.APPLICANT, + requestingUser, + currentVersionIndex, + true + ); // Inject updates from possible unreleased version - if(unreleasedVersionIndex !== -1) { - accessRecord = this.amendmentService.injectAmendments(accessRecord, constants.userTypes.APPLICANT, requestingUser, unreleasedVersionIndex, true, false); + if (unreleasedVersionIndex !== -1) { + accessRecord = this.amendmentService.injectAmendments( + accessRecord, + constants.userTypes.APPLICANT, + requestingUser, + unreleasedVersionIndex, + true, + false + ); } } } diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index 45389660..ee1345c8 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -26,6 +26,9 @@ const DataRequestSchema = new Schema( default: constants.submissionTypes.INITIAL, enum: Object.values(constants.submissionTypes) }, + submissionDescription: { + type: String + }, archived: { Boolean, default: false, diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index b585f0dd..d0fe5142 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -6,7 +6,9 @@ import datarequestUtil from '../datarequest/utils/datarequest.util'; import constants from '../utilities/constants.util'; import { processFile, fileStatus } from '../utilities/cloudStorage.util'; import { amendmentService } from '../datarequest/amendment/dependency'; -import { application } from 'express'; +import teamController from '../team/team.controller'; + +const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); export default class DataRequestService { constructor(dataRequestRepository) { @@ -177,8 +179,7 @@ export default class DataRequestService { const previousVersionIndex = orderedVersions.findIndex(v => parseFloat(v.number).toFixed(1) === previousVersion.toFixed(1)); if (previousVersionIndex !== -1) { orderedVersions[previousVersionIndex].isCurrent = true; - } - else { + } else { orderedVersions[0].isCurrent = true; } } @@ -438,6 +439,52 @@ export default class DataRequestService { return accessRecord; } + async doAmendSubmission(accessRecord, description) { + // 1. Amend submission goes straight into in review rather than submitted + accessRecord.applicationStatus = constants.applicationStatuses.INREVIEW; + accessRecord.submissionDescription = description; + + // 2. Set submission and start review date as now + const dateSubmitted = new Date(); + accessRecord.dateReviewStart = dateSubmitted; + accessRecord.dateSubmitted = dateSubmitted; + accessRecord.upadtedAt = dateSubmitted; + + // 3. Start submission review process for Camunda workflow + let { + publisherObj: { name: publisher }, + } = accessRecord; + let bpmContext = { + dateSubmitted, + applicationStatus: constants.applicationStatuses.SUBMITTED, + publisher, + businessKey: accessRecord._id.toString(), + }; + await bpmController.postStartPreReview(bpmContext); + + // 4. Call Camunda controller to get pre-review process + const managers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, constants.roleTypes.MANAGER); + const response = await bpmController.getProcess(accessRecord._id.toString()); + const { data = {} } = response; + if (!isEmpty(data)) { + const [obj] = data; + const { id: taskId } = obj; + const bpmContext = { + taskId, + applicationStatus: constants.applicationStatuses.INREVIEW, + managerId: managers[0]._id.toString(), + publisher, + notifyManager: 'P999D', + }; + + // 5. Call Camunda controller to start manager review process + await bpmController.postStartManagerReview(bpmContext); + } + + // 6. Return updated access record for saving + return accessRecord; + } + syncRelatedVersions(versionTree) { // 1. Extract all major version _ids denoted by an application type on each node in the version tree const applicationIds = Object.keys(versionTree).reduce((arr, key) => { From 2ddb254b154aac5e2e6587cb21b69b5711ba3a36 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 14 Jun 2021 16:52:42 +0100 Subject: [PATCH 42/81] Spelling mistake --- src/resources/filters/filters.mapper.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/resources/filters/filters.mapper.js b/src/resources/filters/filters.mapper.js index 06f657f3..2737b5a0 100644 --- a/src/resources/filters/filters.mapper.js +++ b/src/resources/filters/filters.mapper.js @@ -510,16 +510,16 @@ export const datasetFilters = [ }, { id: 40, - label: 'Commerical use', - key: 'commercialUse', - dataPath: 'commercialUse', + label: 'Commercial use', + key: 'commericalUse', + dataPath: 'commericalUse', type: 'boolean', tooltip: null, closed: true, isSearchable: false, selectedCount: 0, - filters: [{ id: 998, label: 'Consented for commerical uses', value: 'Consented for commerical uses', checked: false }], - highlighted: ['consented for commerical uses'], + filters: [{ id: 998, label: 'Consented for commercial uses', value: 'Consented for commercial uses', checked: false }], + highlighted: ['consented for commercial uses'], beta: true }, ]; From 97cea99c860eefd0e4df06edf95f60fa0d40c07b Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 15 Jun 2021 15:12:02 +0100 Subject: [PATCH 43/81] Fixed delete draft and verison selection for Custodians --- .../datarequest/datarequest.controller.js | 22 +++++---- .../datarequest/datarequest.entity.js | 2 + .../datarequest/datarequest.service.js | 46 +++++++++++++++---- .../publisher/publisher.controller.js | 2 +- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 29e2804d..f516291f 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -45,7 +45,7 @@ export default class DataRequestController extends Controller { accessRecord.projectName = this.dataRequestService.getProjectName(accessRecord); accessRecord.applicants = this.dataRequestService.getApplicantNames(accessRecord); accessRecord.decisionDuration = this.dataRequestService.getDecisionDuration(accessRecord); - accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree, accessRecord._id); + accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree, accessRecord._id, null, constants.userTypes.APPLICANT); accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.APPLICANT); return accessRecord; }) @@ -164,7 +164,7 @@ export default class DataRequestController extends Controller { const requestedFullVersion = `${requestedMajorVersion}.${ _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion }`; - accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree, accessRecord._id, requestedFullVersion); + accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree, accessRecord._id, requestedFullVersion, userType); // 15. Return application form return res.status(200).json({ @@ -601,6 +601,9 @@ export default class DataRequestController extends Controller { ); } if (statusChange) { + //Update any connected version trees + this.dataRequestService.updateVersionStatus(accessRecord, accessRecord.applicationStatus); + // Send notifications to custodian team, main applicant and contributors regarding status change await this.createNotifications( constants.notificationTypes.STATUSCHANGE, @@ -668,9 +671,9 @@ export default class DataRequestController extends Controller { message: 'This application is no longer in pre-submission status and therefore this action cannot be performed', }); } - + // 6. Delete application - await this.dataRequestService.deleteApplicationById(appIdToDelete).catch(err => { + await this.dataRequestService.deleteApplication(appToDelete).catch(err => { logger.logError(err, logCategory); }); @@ -1614,12 +1617,15 @@ export default class DataRequestController extends Controller { accessRecord.applicationStatus = constants.applicationStatuses.INREVIEW; accessRecord.dateReviewStart = new Date(); - // 7. Save update to access record + // 7. Update any connected version trees + this.dataRequestService.updateVersionStatus(accessRecord, constants.applicationStatuses.INREVIEW); + + // 8. Save update to access record await accessRecord.save().catch(err => { logger.logError(err, logCategory); }); - // 8. Call Camunda controller to get pre-review process + // 9. Call Camunda controller to get pre-review process const response = await bpmController.getProcess(id); const { data = {} } = response; if (!_.isEmpty(data)) { @@ -1636,11 +1642,11 @@ export default class DataRequestController extends Controller { notifyManager: 'P999D', }; - // 9. Call Camunda controller to start manager review process + // 10. Call Camunda controller to start manager review process bpmController.postStartManagerReview(bpmContext); } - // 10. Return aplication and successful response + // 11. Return aplication and successful response return res.status(200).json({ status: 'success' }); } catch (err) { // Return error response if something goes wrong diff --git a/src/resources/datarequest/datarequest.entity.js b/src/resources/datarequest/datarequest.entity.js index 63a9217e..d3e33b8f 100644 --- a/src/resources/datarequest/datarequest.entity.js +++ b/src/resources/datarequest/datarequest.entity.js @@ -91,6 +91,7 @@ export const buildVersionTree = accessRecord => { versionTree = {}, amendmentIterations = [], applicationType = constants.submissionTypes.INITIAL, + applicationStatus = constants.applicationStatuses.INPROGRESS } = accessRecord; const versionKey = majorVersion ? majorVersion.toString() : '1.0'; @@ -125,6 +126,7 @@ export const buildVersionTree = accessRecord => { detailedTitle, link: `/data-access-request/${applicationId}?version=${versionKey}.0`, applicationType, + applicationStatus }, }; diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index d0fe5142..013f4367 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -63,8 +63,16 @@ export default class DataRequestService { return this.dataRequestRepository.getDatasetsForApplicationByIds(arrDatasetIds); } - deleteApplicationById(id) { - return this.dataRequestRepository.deleteApplicationById(id); + async deleteApplication(accessRecord) { + await this.dataRequestRepository.deleteApplicationById(accessRecord._id); + + Object.keys(accessRecord.versionTree).forEach(key => { + if (accessRecord.versionTree[key].applicationId.toString() === accessRecord._id.toString()) { + return delete accessRecord.versionTree[key]; + } + }); + + return await this.syncRelatedVersions(accessRecord.versionTree); } replaceApplicationById(id, newAcessRecord) { @@ -152,9 +160,12 @@ export default class DataRequestService { return { isValidVersion, requestedMajorVersion, requestedMinorVersion }; } - buildVersionHistory = (versionTree, applicationId, requestedVersion) => { + buildVersionHistory = (versionTree, applicationId, requestedVersion, userType) => { const unsortedVersions = Object.keys(versionTree).reduce((arr, versionKey) => { - const { applicationId: _id, link, displayTitle, detailedTitle } = versionTree[versionKey]; + const { applicationId: _id, link, displayTitle, detailedTitle, applicationStatus } = versionTree[versionKey]; + + if (userType === constants.userTypes.CUSTODIAN && applicationStatus === constants.applicationStatuses.INPROGRESS) return arr; + const isCurrent = applicationId.toString() === _id.toString() && (requestedVersion === versionKey || !requestedVersion); const version = { @@ -418,7 +429,7 @@ export default class DataRequestService { this.dataRequestRepository.updateFileStatus(versionIds, fileId, status); } - doInitialSubmission(accessRecord) { + async doInitialSubmission(accessRecord) { // 1. Update application type and submitted status if (!accessRecord.applicationType) { accessRecord.applicationType = constants.submissionTypes.INITIAL; @@ -435,7 +446,9 @@ export default class DataRequestService { } const dateSubmitted = new Date(); accessRecord.dateSubmitted = dateSubmitted; - // 3. Return updated access record for saving + // 3. Update any connected version trees + await this.updateVersionStatus(accessRecord, constants.applicationStatuses.SUBMITTED); + // 4. Return updated access record for saving return accessRecord; } @@ -450,7 +463,10 @@ export default class DataRequestService { accessRecord.dateSubmitted = dateSubmitted; accessRecord.upadtedAt = dateSubmitted; - // 3. Start submission review process for Camunda workflow + // 3. Update any connected version trees + await this.updateVersionStatus(accessRecord, constants.applicationStatuses.INREVIEW); + + // 4. Start submission review process for Camunda workflow let { publisherObj: { name: publisher }, } = accessRecord; @@ -462,7 +478,7 @@ export default class DataRequestService { }; await bpmController.postStartPreReview(bpmContext); - // 4. Call Camunda controller to get pre-review process + // 5. Call Camunda controller to get pre-review process const managers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, constants.roleTypes.MANAGER); const response = await bpmController.getProcess(accessRecord._id.toString()); const { data = {} } = response; @@ -477,14 +493,24 @@ export default class DataRequestService { notifyManager: 'P999D', }; - // 5. Call Camunda controller to start manager review process + // 6. Call Camunda controller to start manager review process await bpmController.postStartManagerReview(bpmContext); } - // 6. Return updated access record for saving + // 7. Return updated access record for saving return accessRecord; } + async updateVersionStatus(accessRecord, newStatus) { + Object.keys(accessRecord.versionTree).forEach(key => { + if (accessRecord.versionTree[key].applicationId.toString() === accessRecord._id.toString()) { + return (accessRecord.versionTree[key].applicationStatus = newStatus); + } + }); + + return await this.syncRelatedVersions(accessRecord.versionTree); + } + syncRelatedVersions(versionTree) { // 1. Extract all major version _ids denoted by an application type on each node in the version tree const applicationIds = Object.keys(versionTree).reduce((arr, key) => { diff --git a/src/resources/publisher/publisher.controller.js b/src/resources/publisher/publisher.controller.js index ae4726e6..9f3b840c 100644 --- a/src/resources/publisher/publisher.controller.js +++ b/src/resources/publisher/publisher.controller.js @@ -93,7 +93,7 @@ export default class PublisherController extends Controller { accessRecord.projectName = this.dataRequestService.getProjectName(accessRecord); accessRecord.applicants = this.dataRequestService.getApplicantNames(accessRecord); accessRecord.decisionDuration = this.dataRequestService.getDecisionDuration(accessRecord); - accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree, accessRecord._id); + accessRecord.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree, accessRecord._id, null, constants.userTypes.CUSTODIAN); accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.CUSTODIAN); return accessRecord; }) From 996b3909fd7612d15b6338a300f7f8016f938a7d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 16 Jun 2021 15:22:54 +0100 Subject: [PATCH 44/81] Fixed amend logic --- .../datarequest/datarequest.service.js | 42 +++---------------- 1 file changed, 5 insertions(+), 37 deletions(-) diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 013f4367..80ff62a5 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -453,51 +453,19 @@ export default class DataRequestService { } async doAmendSubmission(accessRecord, description) { - // 1. Amend submission goes straight into in review rather than submitted - accessRecord.applicationStatus = constants.applicationStatuses.INREVIEW; + // 1. Amend submission goes to submitted status with text reason for amendment + accessRecord.applicationStatus = constants.applicationStatuses.SUBMITTED; accessRecord.submissionDescription = description; - // 2. Set submission and start review date as now + // 2. Set submission date as now const dateSubmitted = new Date(); - accessRecord.dateReviewStart = dateSubmitted; accessRecord.dateSubmitted = dateSubmitted; accessRecord.upadtedAt = dateSubmitted; // 3. Update any connected version trees - await this.updateVersionStatus(accessRecord, constants.applicationStatuses.INREVIEW); - - // 4. Start submission review process for Camunda workflow - let { - publisherObj: { name: publisher }, - } = accessRecord; - let bpmContext = { - dateSubmitted, - applicationStatus: constants.applicationStatuses.SUBMITTED, - publisher, - businessKey: accessRecord._id.toString(), - }; - await bpmController.postStartPreReview(bpmContext); - - // 5. Call Camunda controller to get pre-review process - const managers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, constants.roleTypes.MANAGER); - const response = await bpmController.getProcess(accessRecord._id.toString()); - const { data = {} } = response; - if (!isEmpty(data)) { - const [obj] = data; - const { id: taskId } = obj; - const bpmContext = { - taskId, - applicationStatus: constants.applicationStatuses.INREVIEW, - managerId: managers[0]._id.toString(), - publisher, - notifyManager: 'P999D', - }; - - // 6. Call Camunda controller to start manager review process - await bpmController.postStartManagerReview(bpmContext); - } + await this.updateVersionStatus(accessRecord, constants.applicationStatuses.SUBMITTED); - // 7. Return updated access record for saving + // 4. Return updated access record for saving return accessRecord; } From 38bd8c90ab7b342b0d67eeab6d1d08eb6fca7fa2 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Wed, 16 Jun 2021 15:44:00 +0100 Subject: [PATCH 45/81] Removed LGTM issues --- src/resources/datarequest/datarequest.service.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 80ff62a5..e456cd16 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -6,9 +6,6 @@ import datarequestUtil from '../datarequest/utils/datarequest.util'; import constants from '../utilities/constants.util'; import { processFile, fileStatus } from '../utilities/cloudStorage.util'; import { amendmentService } from '../datarequest/amendment/dependency'; -import teamController from '../team/team.controller'; - -const bpmController = require('../bpmnworkflow/bpmnworkflow.controller'); export default class DataRequestService { constructor(dataRequestRepository) { From e467fdbdf7e472939d7e8a97989651ddaf51e96c Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Wed, 23 Jun 2021 13:46:21 +0100 Subject: [PATCH 46/81] IG-2083 header with logo added to all emails using sendEmail --- src/resources/utilities/emailGenerator.util.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index fd51e42a..dcd3492b 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -1842,7 +1842,6 @@ const _generateMessageNotification = options => { let { firstMessage, firstname, lastname, messageDescription, openMessagesLink } = options; let body = `
- HDR UK Logo
{ }); }; +const _generateEmailHeader = ` + HDR UK Logo + `; + const _generateEmailFooter = (recipient, allowUnsubscribe) => { // 1. Generate HTML for unsubscribe link if allowed depending on context From 17a8986fb59af26d794b2d60b163eb686a9f3bf7 Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Thu, 24 Jun 2021 10:37:56 +0100 Subject: [PATCH 47/81] IG-2083 Updates to entity emails, formatted in line with new emails and minor text updates --- src/resources/course/course.repository.js | 52 ++++++++------ src/resources/tool/data.repository.js | 68 +++++++++++++------ .../utilities/emailGenerator.util.js | 68 +++++++++++++++++++ 3 files changed, 149 insertions(+), 39 deletions(-) diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index 28cb8464..ce15e5ae 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -357,30 +357,38 @@ async function createMessage(authorId, toolId, toolName, toolType, activeflag, r async function sendEmailNotifications(tool, activeflag, rejectionReason) { let subject; - let html; let adminCanUnsubscribe = true; // 1. Generate tool URL for linking user from email const toolLink = process.env.homeURL + '/' + tool.type + '/' + tool.id; + let resourceType = tool.type.charAt(0).toUpperCase() + tool.type.slice(1); - // 2. Build email body + // 2. Build email subject if (activeflag === 'active') { - subject = `Your ${tool.type} ${tool.title} has been approved and is now live`; - html = `Your ${tool.type} ${tool.title} has been approved and is now live

${toolLink}`; + subject = `${resourceType} ${tool.title} has been approved and is now live`; } else if (activeflag === 'archive') { - subject = `Your ${tool.type} ${tool.title} has been archived`; - html = `Your ${tool.type} ${tool.title} has been archived

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

Rejection reason: ${rejectionReason}

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

${toolLink}`; + subject = `${resourceType} ${tool.title} has been submitted for approval`; adminCanUnsubscribe = false; } else if (activeflag === 'edit') { - subject = `Your ${tool.type} ${tool.title} has been updated`; - html = `Your ${tool.type} ${tool.title} has been updated

${toolLink}`; + subject = `${resourceType} ${tool.title} has been updated`; } + // Create object to pass through email data + let options = { + resourceType: tool.type, + resourceName: tool.title, + resourceLink: toolLink, + subject, + rejectionReason: rejectionReason, + activeflag, + type: 'author', + }; + // Create email body content + let html = emailGenerator.generateEntityNotification(options); + if (adminCanUnsubscribe) { // 3. Find the creator of the course and admins if they have opted in to email updates var q = UserModel.aggregate([ @@ -456,17 +464,23 @@ async function sendEmailNotificationToAuthors(tool, toolOwner) { { $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 + // 3. Create object to pass through email data + let options = { + resourceType: tool.type, + resourceName: tool.name, + resourceLink: toolLink, + type: 'co-author', + resourceAuthor: toolOwner.name, + }; + // 4. Create email body content + let html = emailGenerator.generateEntityNotification(options); + + // 5. 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}` - ); + emailGenerator.sendEmail(emailRecipients, `${hdrukEmail}`, `${toolOwner.name} added you as an author of the tool ${tool.name}`, html); }); } diff --git a/src/resources/tool/data.repository.js b/src/resources/tool/data.repository.js index 0b59ae60..90c00a10 100644 --- a/src/resources/tool/data.repository.js +++ b/src/resources/tool/data.repository.js @@ -103,13 +103,19 @@ const addTool = async (req, res) => { if (err) { return new Error({ success: false, error: err }); } - emailGenerator.sendEmail( - emailRecipients, - `${hdrukEmail}`, - `A new ${data.type} has been added and is ready for review`, - `Approval needed: new ${data.type} ${data.name}

${toolLink}`, - false - ); + + // Create object to pass through email data + let options = { + resourceType: data.type, + resourceName: data.name, + resourceLink: toolLink, + type: 'admin', + }; + // Create email body content + let html = emailGenerator.generateEntityNotification(options); + + // Send email + emailGenerator.sendEmail(emailRecipients, `${hdrukEmail}`, `A new ${data.type} has been added and is ready for review`, html, false); }); if (data.type === 'tool') { @@ -436,24 +442,34 @@ async function createMessage(authorId, toolId, toolName, toolType, activeflag, r } 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; + let resourceType = tool.type.charAt(0).toUpperCase() + tool.type.slice(1); - // 2. Build email body + // 2. Build email subject + let subject; 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}`; + subject = `${resourceType} ${tool.name} has been approved and is now live`; } else if (activeflag === 'archive') { - subject = `Your ${tool.type} ${tool.name} has been archived`; - html = `Your ${tool.type} ${tool.name} has been archived

${toolLink}`; + subject = `${resourceType} ${tool.name} has been archived`; } 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}`; + subject = `${resourceType} ${tool.name} has been rejected`; } - // 3. Find all authors of the tool who have opted in to email updates + // 3. Create object to pass through email data + let options = { + resourceType: tool.type, + resourceName: tool.name, + resourceLink: toolLink, + subject, + rejectionReason: rejectionReason, + activeflag, + type: 'author', + }; + // 4. Create email body content + let html = emailGenerator.generateEntityNotification(options); + + // 5. 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 } }] } }, @@ -465,11 +481,12 @@ async function sendEmailNotifications(tool, activeflag, rejectionReason) { { $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 + // 6. 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, false); }); } @@ -490,7 +507,18 @@ async function sendEmailNotificationToAuthors(tool, toolOwner) { { $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 + // 3. Create object to pass through email data + let options = { + resourceType: tool.type, + resourceName: tool.name, + resourceLink: toolLink, + type: 'co-author', + resourceAuthor: toolOwner.name, + }; + // 4. Create email body content + let html = emailGenerator.generateEntityNotification(options); + + // 5. 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 }); @@ -499,7 +527,7 @@ async function sendEmailNotificationToAuthors(tool, toolOwner) { 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}`, + html, false ); }); diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index dcd3492b..dc81b761 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -1876,6 +1876,73 @@ const _generateMessageNotification = options => { return body; }; +const _generateEntityNotification = options => { + let { resourceType, resourceName, resourceLink, subject, rejectionReason, activeflag, type, resourceAuthor } = options; + + let authorBody; + if (activeflag === 'active') { + authorBody = `Your ${resourceType} ${resourceName} has been approved and is now live.`; + } else if (activeflag === 'archive') { + authorBody = `Your ${resourceType} ${resourceName} has been archived.`; + } else if (activeflag === 'rejected') { + authorBody = `Your ${resourceType} ${resourceName} has been rejected

Rejection reason: ${rejectionReason}.`; + } else if (activeflag === 'add') { + authorBody = `Your ${resourceType} ${resourceName} has been submitted for approval.`; + } else if (activeflag === 'edit') { + authorBody = `Your ${resourceType} ${resourceName} has been updated.`; + } + + let body = `
+
+
+ + + + + + + + + + + + + +
+ ${!_.isEmpty(type) && type === 'admin' ? `A new ${resourceType} has been added and is ready for review` : ``} + ${!_.isEmpty(type) && type === 'author' ? `${subject}` : ``} + ${ + !_.isEmpty(type) && type === 'co-author' + ? `${resourceAuthor} added you as an author of the tool ${resourceName}` + : `` + } +
+

+ ${!_.isEmpty(type) && type === 'admin' ? `Approval needed: new ${resourceType} ${resourceName}` : ``} + ${!_.isEmpty(type) && type === 'author' ? authorBody : ``} + ${ + !_.isEmpty(type) && type === 'co-author' + ? `${resourceAuthor} added you as an author of the tool ${resourceName}` + : `` + } +

+
+ ${!_.isEmpty(type) && type === 'admin' ? `View ${resourceType}` : ``} + ${!_.isEmpty(type) && type === 'author' ? `View ${resourceType}` : ``} + ${!_.isEmpty(type) && type === 'co-author' ? `View ${resourceType}` : ``} +
+
+ `; + return body; +}; + /** * [_sendEmail] * @@ -2026,4 +2093,5 @@ export default { //generateMetadataOnboardingUnArchived: _generateMetadataOnboardingUnArchived, //Messages generateMessageNotification: _generateMessageNotification, + generateEntityNotification: _generateEntityNotification, }; From ef337c4f100e195c71f5b567910a5f3b3a0a0032 Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Thu, 24 Jun 2021 12:09:02 +0100 Subject: [PATCH 48/81] IG-2083 Review emails copy updated based on action taken, styling updated to be consistent with newer emails --- src/resources/tool/v1/tool.route.js | 67 ++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/src/resources/tool/v1/tool.route.js b/src/resources/tool/v1/tool.route.js index 6a4f4c1e..c6a91f01 100644 --- a/src/resources/tool/v1/tool.route.js +++ b/src/resources/tool/v1/tool.route.js @@ -303,7 +303,7 @@ router.put('/review/approve', passport.authenticate('jwt'), utils.checkIsInRole( await storeNotificationMessages(review); // Send email notififcation of approval to authors and admins who have opted in - await sendEmailNotifications(review); + await sendEmailNotifications(review, activeflag); }); }); @@ -425,11 +425,11 @@ async function storeNotificationMessages(review) { return { success: true, id: message.messageID }; } -async function sendEmailNotifications(review) { +async function sendEmailNotifications(review, activeflag) { // 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; + const toolLink = process.env.homeURL + '/tool/' + tool.id; // 2. Query Db for all admins or authors of the tool who have opted in to email updates var q = UserModel.aggregate([ @@ -464,12 +464,59 @@ async function sendEmailNotifications(review) { 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}`, - false - ); + + let subject; + if (activeflag === 'active') { + subject = `A review has been added to the ${tool.type} ${tool.name}`; + } else if (activeflag === 'rejected') { + subject = `A review on the ${tool.type} ${tool.name} has been rejected`; + } else if (activeflag === 'archive') { + subject = `A review on the ${tool.type} ${tool.name} has been archived`; + } + + let html = `
+
+ + + + + + + + + + + + + + +
+ ${subject} +
+

+ ${ + activeflag === 'active' + ? `${reviewer.firstname} ${reviewer.lastname} gave a ${review.rating}-star review to the tool ${tool.name}.` + : activeflag === 'rejected' + ? `A ${review.rating}-star review from ${reviewer.firstname} ${reviewer.lastname} on the ${tool.type} ${tool.name} has been rejected.` + : activeflag === 'archive' + ? `A ${review.rating}-star review from ${reviewer.firstname} ${reviewer.lastname} on the ${tool.type} ${tool.name} has been archived.` + : `` + } +

+
+ View ${tool.type} +
+
+
`; + + emailGenerator.sendEmail(emailRecipients, `${hdrukEmail}`, subject, html, false); }); } From 294ac5f30db898f2a30c728c21d3314ea88cdffb Mon Sep 17 00:00:00 2001 From: Tony Espley <60223309+tonyespley-pa@users.noreply.github.com> Date: Thu, 24 Jun 2021 20:07:45 +0100 Subject: [PATCH 49/81] Trust all healthdatagateway.org domains to be able to access API Charles has asked for search.healthdatagateway.org and api.healthdatagateway.org to be domains for production (they are both active - but failing because of cors issues). This looks like a simple way to get it work without significant rework, --- src/config/server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/server.js b/src/config/server.js index 48026c72..b51129bf 100644 --- a/src/config/server.js +++ b/src/config/server.js @@ -52,7 +52,7 @@ 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 domains = [/\.healthdatagateway\.org$/, process.env.homeURL]; var rx = /^([http|https]+:\/\/[a-z]+)\.([^/]*)/; var arr = rx.exec(process.env.homeURL); From 0b7faf144113947f6b305ef2f71dfc881974cd47 Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Fri, 25 Jun 2021 10:53:22 +0100 Subject: [PATCH 50/81] IG-2083 updates to the copy and links for entity emails --- package.json | 4 +-- src/resources/course/course.repository.js | 16 +++++++++++- src/resources/tool/data.repository.js | 2 +- .../utilities/emailGenerator.util.js | 25 +++++++++++-------- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 92c6008c..57ffc0b8 100644 --- a/package.json +++ b/package.json @@ -76,8 +76,8 @@ "supertest": "^4.0.2" }, "scripts": { - "start": "node index.js", - "server": "nodemon index.js", + "start": "node --inspect=0.0.0.0:3001 index.js", + "server": "nodemon --inspect=0.0.0.0:3001 index.js", "debug": "nodemon --inspect=0.0.0.0:3001 index.js", "build": "", "test": "jest --runInBand", diff --git a/src/resources/course/course.repository.js b/src/resources/course/course.repository.js index ce15e5ae..32f81d0b 100644 --- a/src/resources/course/course.repository.js +++ b/src/resources/course/course.repository.js @@ -443,6 +443,20 @@ async function sendEmailNotifications(tool, activeflag, rejectionReason) { if (err) { return new Error({ success: false, error: err }); } + + // Create object to pass through email data + options = { + resourceType: tool.type, + resourceName: tool.title, + resourceLink: toolLink, + subject, + rejectionReason: rejectionReason, + activeflag, + type: 'admin', + }; + + html = emailGenerator.generateEntityNotification(options); + emailGenerator.sendEmail(emailRecipients, `${hdrukEmail}`, subject, html, adminCanUnsubscribe); }); } @@ -480,7 +494,7 @@ async function sendEmailNotificationToAuthors(tool, toolOwner) { 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}`, html); + emailGenerator.sendEmail(emailRecipients, `${hdrukEmail}`, `${toolOwner.name} added you as an author of the course ${tool.name}`, html); }); } diff --git a/src/resources/tool/data.repository.js b/src/resources/tool/data.repository.js index 90c00a10..170a36eb 100644 --- a/src/resources/tool/data.repository.js +++ b/src/resources/tool/data.repository.js @@ -526,7 +526,7 @@ async function sendEmailNotificationToAuthors(tool, toolOwner) { 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.type} ${tool.name}`, html, false ); diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index dc81b761..bc0a3c75 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -1878,20 +1878,21 @@ const _generateMessageNotification = options => { const _generateEntityNotification = options => { let { resourceType, resourceName, resourceLink, subject, rejectionReason, activeflag, type, resourceAuthor } = options; - let authorBody; if (activeflag === 'active') { - authorBody = `Your ${resourceType} ${resourceName} has been approved and is now live.`; + authorBody = `${resourceName} ${resourceType} has been approved by the HDR UK admin team and can be publicly viewed on the gateway, including in search results.`; } else if (activeflag === 'archive') { - authorBody = `Your ${resourceType} ${resourceName} has been archived.`; + authorBody = `${resourceName} ${resourceType} has been archived by the HDR UK admin team.`; } else if (activeflag === 'rejected') { - authorBody = `Your ${resourceType} ${resourceName} has been rejected

Rejection reason: ${rejectionReason}.`; + authorBody = `${resourceName} ${resourceType} has been rejected by the HDR UK admin team.

Reason for rejection: ${rejectionReason}`; } else if (activeflag === 'add') { - authorBody = `Your ${resourceType} ${resourceName} has been submitted for approval.`; + authorBody = `${resourceName} ${resourceType} has been submitted to the HDR UK admin team for approval.`; } else if (activeflag === 'edit') { - authorBody = `Your ${resourceType} ${resourceName} has been updated.`; + authorBody = `${resourceName} ${resourceType} has been edited, the updated version can now be viewed on the gateway.`; } + let dashboardLink = process.env.homeURL + '/account?tab=' + resourceType + 's'; + let body = `
{ ${!_.isEmpty(type) && type === 'author' ? `${subject}` : ``} ${ !_.isEmpty(type) && type === 'co-author' - ? `${resourceAuthor} added you as an author of the tool ${resourceName}` + ? `${resourceAuthor} added you as an author of the ${resourceType} ${resourceName}` : `` } @@ -1917,11 +1918,15 @@ const _generateEntityNotification = options => { From a0e032f9dc8591a1afff70a34af8cb64bd1169f4 Mon Sep 17 00:00:00 2001 From: CiaraWardPA <57766586+CiaraWardPA@users.noreply.github.com> Date: Fri, 25 Jun 2021 16:19:58 +0100 Subject: [PATCH 51/81] Update package.json --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 57ffc0b8..92c6008c 100644 --- a/package.json +++ b/package.json @@ -76,8 +76,8 @@ "supertest": "^4.0.2" }, "scripts": { - "start": "node --inspect=0.0.0.0:3001 index.js", - "server": "nodemon --inspect=0.0.0.0:3001 index.js", + "start": "node index.js", + "server": "nodemon index.js", "debug": "nodemon --inspect=0.0.0.0:3001 index.js", "build": "", "test": "jest --runInBand", From 35a7c10eddf7e70495408547f729a0d006a4ca7d Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Mon, 28 Jun 2021 16:30:53 +0100 Subject: [PATCH 52/81] Completed amend --- .../amendment/amendment.controller.js | 30 +- .../amendment/amendment.service.js | 44 ++- .../datarequest/datarequest.controller.js | 50 +-- .../datarequest/datarequest.model.js | 12 + .../datarequest/datarequest.repository.js | 2 +- .../datarequest/datarequest.service.js | 6 +- src/resources/utilities/constants.util.js | 9 +- .../utilities/emailGenerator.util.js | 286 +++++++++++++----- 8 files changed, 322 insertions(+), 117 deletions(-) diff --git a/src/resources/datarequest/amendment/amendment.controller.js b/src/resources/datarequest/amendment/amendment.controller.js index 0ec670e3..650db9ea 100644 --- a/src/resources/datarequest/amendment/amendment.controller.js +++ b/src/resources/datarequest/amendment/amendment.controller.js @@ -118,14 +118,23 @@ export default class AmendmentController extends Controller { let currentVersionIndex; let previousVersionIndex; const unreleasedVersionIndex = accessRecordObj.amendmentIterations.findIndex(iteration => _.isNil(iteration.dateReturned)); - - if(unreleasedVersionIndex === -1) { - currentVersionIndex = accessRecordObj.amendmentIterations.length -1; + + if (unreleasedVersionIndex === -1) { + currentVersionIndex = accessRecordObj.amendmentIterations.length - 1; } else { - currentVersionIndex = accessRecordObj.amendmentIterations.length -2; + currentVersionIndex = accessRecordObj.amendmentIterations.length - 2; } previousVersionIndex = currentVersionIndex - 1; + // Handle amendment type application loading for Custodian showing any changes in the major version + if ( + accessRecordObj.applicationType === constants.submissionTypes.AMENDED && + userType === constants.userTypes.CUSTODIAN && + currentVersionIndex === -1 + ) { + accessRecordObj = this.amendmentService.highlightChanges(accessRecordObj); + } + // Inject updates from previous version accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user, previousVersionIndex, true); @@ -133,9 +142,18 @@ export default class AmendmentController extends Controller { accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user, currentVersionIndex, true); // Inject updates from possible unreleased version - if(unreleasedVersionIndex !== -1) { - accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user, unreleasedVersionIndex, true, false); + if (unreleasedVersionIndex !== -1) { + accessRecordObj = this.amendmentService.injectAmendments( + accessRecordObj, + userType, + req.user, + unreleasedVersionIndex, + true, + false + ); } + } else if (accessRecordObj.applicationType === constants.submissionTypes.AMENDED && userType === constants.userTypes.CUSTODIAN) { + accessRecordObj = this.amendmentService.highlightChanges(accessRecordObj); } // 12. Append question actions depending on user type and application status diff --git a/src/resources/datarequest/amendment/amendment.service.js b/src/resources/datarequest/amendment/amendment.service.js index 88120478..babc1a0b 100644 --- a/src/resources/datarequest/amendment/amendment.service.js +++ b/src/resources/datarequest/amendment/amendment.service.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import moment from 'moment'; import { AmendmentModel } from './amendment.model'; import constants from '../../utilities/constants.util'; @@ -6,6 +7,7 @@ import helperUtil from '../../utilities/helper.util'; import datarequestUtil from '../utils/datarequest.util'; import notificationBuilder from '../../utilities/notificationBuilder'; import emailGenerator from '../../utilities/emailGenerator.util'; +import dynamicForm from '../../utilities/dynamicForms/dynamicForm.util'; export default class AmendmentService { constructor(amendmentRepository) { @@ -200,7 +202,7 @@ export default class AmendmentService { getAmendmentIterationDetailsByVersion(accessRecord, minorVersion) { const { amendmentIterations = [] } = accessRecord; - + // 1. Calculate version index from version number by subtracting 1 for zero based array let versionIndex = minorVersion - 1; @@ -213,7 +215,7 @@ export default class AmendmentService { // 3. Get active party for selected version index const activeParty = this.getAmendmentIterationParty(accessRecord, versionIndex, isLatestMinorVersion); - + // 4. If version index was not determined, use latest available (if unreleased version is found, skip it) if (isNaN(versionIndex)) { const unreleasedVersionIndex = accessRecord.amendmentIterations.findIndex(iteration => _.isNil(iteration.dateReturned)); @@ -520,7 +522,7 @@ export default class AmendmentService { return accessRecord; } // 2. Mark submission type as a resubmission later used to determine notification generation - accessRecord.applicationType = constants.submissionTypes.RESUBMISSION; + accessRecord.amendmentIterations[index].applicationType = constants.submissionTypes.RESUBMISSION; accessRecord.submitAmendmentIteration(index, userId); // 3. Return updated access record for saving return accessRecord; @@ -609,6 +611,42 @@ export default class AmendmentService { return amendmentStatus; } + highlightChanges(accessRecord) { + const { datasetIds, initialDatasetIds, questionAnswers, initialQuestionAnswers } = accessRecord; + + if (!_.isEqual(datasetIds, initialDatasetIds)) { + accessRecord.areDatasetsAmended = true; + } + + Object.keys(questionAnswers).forEach(questionId => { + if (!_.isEqual(questionAnswers[questionId], initialQuestionAnswers[questionId])) { + this.highlightQuestionChange(accessRecord, questionId); + } + }); + + return accessRecord; + } + + highlightQuestionChange(accessRecord, questionId) { + const { dateSubmitted, mainApplicant } = accessRecord; + + const questionAlert = { + status: 'WARNING', + options: [], + text: `${mainApplicant.firstname} ${mainApplicant.lastname} submitted an amendment on ${moment(dateSubmitted).format('Do MMM YYYY')}`, + }; + + accessRecord.jsonSchema.questionSets.forEach(questionSet => { + let question = dynamicForm.findQuestionRecursive(questionSet.questions, questionId); + if (question) { + question = datarequestUtil.setQuestionState(question, questionAlert, true); + questionSet.questions = datarequestUtil.updateQuestion(questionSet.questions, question); + accessRecord.jsonSchema = this.injectNavigationAmendment(accessRecord.jsonSchema, questionSet.questionSetId, constants.userTypes.CUSTODIAN, 'completed', 'returned'); + return; + } + }); + } + async createNotifications(type, accessRecord) { // Project details from about application let { aboutApplication = {}, questionAnswers } = accessRecord; diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index f516291f..16370ee8 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -115,7 +115,7 @@ export default class DataRequestController extends Controller { } // 6. Set edit mode for applicants who have not yet submitted - const { applicationStatus, jsonSchema, versionTree } = accessRecord; + const { applicationStatus, jsonSchema, versionTree, applicationType } = accessRecord; accessRecord.readOnly = this.dataRequestService.getApplicationIsReadOnly(userType, applicationStatus); // 7. Count amendments for the latest version - returns 0 immediately if not viewing latest version @@ -134,13 +134,22 @@ export default class DataRequestController extends Controller { const userRole = userType === constants.userTypes.APPLICANT ? '' : isManager ? constants.roleTypes.MANAGER : constants.roleTypes.REVIEWER; - // 10. Inject completed update requests from previous version to the requested version e.g. 1.1 if 1.2 requested + // 10. Handle amendment type application loading for Custodian showing any changes in the major version + if(applicationType === constants.submissionTypes.AMENDED && userType === constants.userTypes.CUSTODIAN) { + const minorVersion = _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion; + + if(accessRecord.amendmentIterations.length === 0 || (minorVersion === 0)) { + accessRecord = this.amendmentService.highlightChanges(accessRecord); + } + } + + // 11. Inject completed update requests from previous version to the requested version e.g. 1.1 if 1.2 requested accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex - 1, true); - // 11. Inject updates for current version + // 12. Inject updates for current version accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex, true); - // 12. Inject updates from any unreleased version e.g. 1.2 + // 13. Inject updates from any unreleased version e.g. 1.2 accessRecord = this.amendmentService.injectAmendments( accessRecord, userType, @@ -150,7 +159,7 @@ export default class DataRequestController extends Controller { false ); - // 13. Append question actions depending on user type and application status + // 14. Append question actions depending on user type and application status accessRecord.jsonSchema = datarequestUtil.injectQuestionActions( jsonSchema, userType, @@ -160,13 +169,13 @@ export default class DataRequestController extends Controller { isLatestMinorVersion ); - // 14. Build version selector + // 15. Build version selector const requestedFullVersion = `${requestedMajorVersion}.${ _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion }`; accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree, accessRecord._id, requestedFullVersion, userType); - // 15. Return application form + // 16. Return application form return res.status(200).json({ status: 'success', data: { @@ -298,6 +307,7 @@ export default class DataRequestController extends Controller { const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; const { description = '' } = req.body; + let notificationType; // 2. Find the relevant data request application let accessRecord = await this.dataRequestService.getApplicationToSubmitById(id); @@ -326,10 +336,12 @@ export default class DataRequestController extends Controller { switch(accessRecord.applicationType) { case constants.submissionTypes.AMENDED: accessRecord = await this.dataRequestService.doAmendSubmission(accessRecord, description); + notificationType = constants.notificationTypes.APPLICATIONAMENDED; break; case constants.submissionTypes.INITIAL: default: accessRecord = await this.dataRequestService.doInitialSubmission(accessRecord); + notificationType = constants.notificationTypes.SUBMITTED; break; } } else if ( @@ -338,6 +350,7 @@ export default class DataRequestController extends Controller { ) { accessRecord = await this.amendmentService.doResubmission(accessRecord, requestingUserObjectId.toString()); await this.dataRequestService.syncRelatedVersions(accessRecord.versionTree); + notificationType = constants.notificationTypes.RESUBMITTED; } // 6. Ensure a valid submission is taking place @@ -356,12 +369,10 @@ export default class DataRequestController extends Controller { // 8. Inject amendments from minor versions savedAccessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser); - // 9. Calculate notification type to send - const notificationType = constants.submissionNotifications[accessRecord.applicationType]; - - await this.createNotifications(notificationType, {}, accessRecord, requestingUser); + // 9. Send notifications + await this.createNotifications(notificationType, {}, accessRecord.toObject(), requestingUser); - // 9. Start workflow process in Camunda if publisher requires it and it is the first submission + // 10. Start workflow process in Camunda if publisher requires it and it is the first submission if (savedAccessRecord.workflowEnabled && savedAccessRecord.applicationType === constants.submissionTypes.INITIAL) { let { publisherObj: { name: publisher }, @@ -376,7 +387,7 @@ export default class DataRequestController extends Controller { bpmController.postStartPreReview(bpmContext); } - // 10. Return aplication and successful response + // 11. Return aplication and successful response return res.status(200).json({ status: 'success', data: savedAccessRecord }); } catch (err) { // Return error response if something goes wrong @@ -1755,7 +1766,7 @@ export default class DataRequestController extends Controller { async createNotifications(type, context, accessRecord, user) { // Project details from about application if 5 Safes - let { aboutApplication = {} } = accessRecord; + let { aboutApplication = {}, datasetIds = [], initialDatasetIds = [], } = accessRecord; let { projectName = 'No project name set' } = aboutApplication; let { projectId, _id, workflow = {}, dateSubmitted = '', jsonSchema, questionAnswers, createdAt } = accessRecord; if (_.isEmpty(projectId)) { @@ -2472,12 +2483,16 @@ export default class DataRequestController extends Controller { } // 2. Send emails to custodian and applicant // Create object to pass to email generator + const initialDatasetTitles = accessRecord.initialDatasets.map(dataset => dataset.name).join(', '); options = { userType: '', userEmail: appEmail, publisher, datasetTitles, + initialDatasetTitles, userName: `${appFirstName} ${appLastName}`, + submissionDescription: accessRecord.submissionDescription, + applicationId: accessRecord._id.toString() }; // Iterate through the recipient types for (let emailRecipientType of constants.submissionEmailRecipientTypes) { @@ -2485,15 +2500,16 @@ export default class DataRequestController extends Controller { options = { ...options, userType: emailRecipientType, - submissionType: constants.submissionTypes.RESUBMISSION, + submissionType: constants.submissionTypes.AMENDED, }; // Build email template - ({ html, jsonContent } = await emailGenerator.generateEmail( + ({ html, jsonContent } = await emailGenerator.generateAmendEmail( aboutApplication, questions, pages, questionPanels, questionAnswers, + accessRecord.initialQuestionAnswers, options )); // Send emails to custodian team members who have opted in to email notifications @@ -2512,7 +2528,7 @@ export default class DataRequestController extends Controller { await emailGenerator.sendEmail( emailRecipients, constants.hdrukEmail, - `Data Access Request to ${publisher} for ${datasetTitles} has been updated with updates`, + `Data Access Request to ${publisher} for ${datasetTitles} has been amended with updates`, html, false, attachments diff --git a/src/resources/datarequest/datarequest.model.js b/src/resources/datarequest/datarequest.model.js index ee1345c8..91489906 100644 --- a/src/resources/datarequest/datarequest.model.js +++ b/src/resources/datarequest/datarequest.model.js @@ -11,6 +11,7 @@ const DataRequestSchema = new Schema( authorIds: [Number], dataSetId: String, datasetIds: [{ type: String }], + initialDatasetIds: [{ type: String }], datasetTitles: [{ type: String }], isCloneable: Boolean, projectId: String, @@ -43,6 +44,10 @@ const DataRequestSchema = new Schema( type: Object, default: {}, }, + initialQuestionAnswers: { + type: Object, + default: {}, + }, aboutApplication: { type: Object, default: {}, @@ -134,6 +139,13 @@ DataRequestSchema.virtual('authors', { localField: 'authorIds', }); +DataRequestSchema.virtual('initialDatasets', { + ref: 'Data', + foreignField: 'datasetid', + localField: 'initialDatasetIds', + justOne: false, +}); + // Load entity class DataRequestSchema.loadClass(DataRequestClass); diff --git a/src/resources/datarequest/datarequest.repository.js b/src/resources/datarequest/datarequest.repository.js index 77829b50..63797709 100644 --- a/src/resources/datarequest/datarequest.repository.js +++ b/src/resources/datarequest/datarequest.repository.js @@ -107,7 +107,7 @@ export default class DataRequestRepository extends Repository { getApplicationToSubmitById(id) { return DataRequestModel.findOne({ _id: id }).populate([ { - path: 'datasets dataset', + path: 'datasets dataset initialDatasets', populate: { path: 'publisher', populate: { diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index e456cd16..2abe2bdb 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -166,7 +166,7 @@ export default class DataRequestService { const isCurrent = applicationId.toString() === _id.toString() && (requestedVersion === versionKey || !requestedVersion); const version = { - number: versionKey, + number: parseFloat(versionKey), _id, link, displayTitle, @@ -187,7 +187,7 @@ export default class DataRequestService { const previousVersionIndex = orderedVersions.findIndex(v => parseFloat(v.number).toFixed(1) === previousVersion.toFixed(1)); if (previousVersionIndex !== -1) { orderedVersions[previousVersionIndex].isCurrent = true; - } else { + } else if (orderedVersions.length > 0) { orderedVersions[0].isCurrent = true; } } @@ -324,12 +324,14 @@ export default class DataRequestService { userId, authorIds, datasetIds, + initialDatasetIds: datasetIds, datasetTitles, isCloneable, projectId, schemaId, jsonSchema, questionAnswers, + initialQuestionAnswers: questionAnswers, aboutApplication, publisher, formType, diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 246d2e79..ac4aa3a1 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -414,12 +414,6 @@ const _submissionTypes = { RENEWAL: 'renewal', }; -const _submissionNotifications = { - [`${_submissionTypes.INITIAL}`]: _notificationTypes.SUBMITTED, - [`${_submissionTypes.RESUBMISSION}`]: _notificationTypes.RESUBMITTED, - [`${_submissionTypes.AMENDED}`]:_notificationTypes.APPLICATIONAMENDED -}; - const _formActions = { ADDREPEATABLESECTION: 'addRepeatableSection', REMOVEREPEATABLESECTION: 'removeRepeatableSection', @@ -498,6 +492,5 @@ export default { hdrukEmail: _hdrukEmail, mailchimpSubscriptionStatuses: _mailchimpSubscriptionStatuses, datatsetStatuses: _datatsetStatuses, - logTypes: _logTypes, - submissionNotifications: _submissionNotifications + logTypes: _logTypes }; diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 1124f742..66316507 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -1,4 +1,4 @@ -import _, { isNil, isEmpty, capitalize, groupBy, forEach } from 'lodash'; +import _, { isNil, isEmpty, capitalize, groupBy, forEach, isEqual } from 'lodash'; import moment from 'moment'; import { UserModel } from '../user/user.model'; import helper from '../utilities/helper.util'; @@ -185,20 +185,127 @@ const _formatSectionTitle = value => { return capitalize(questionId); }; -const _buildSubjectTitle = (user, title, submissionType) => { - let subject = ''; - if (user.toUpperCase() === 'DATACUSTODIAN') { - subject = `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 { - if (submissionType === constants.submissionTypes.INPROGRESS) { - subject = `You are in progress with a request access to ${title}. The custodian will be in contact after you submit the application.`; - } else if (submissionType === constants.submissionTypes.INITIAL) { - subject = `You have requested access to ${title}. The custodian will be in contact about the application.`; - } else { - subject = `You have made updates to your Data Access Request for ${title}. The custodian will be in contact about the application.`; - } +const _getSubmissionDetails = ( + userType, + userName, + userEmail, + datasetTitles, + initialDatasetTitles, + submissionType, + projectName, + isNationalCoreStudies, + dateSubmitted +) => { + let body = `

- ${!_.isEmpty(type) && type === 'admin' ? `Approval needed: new ${resourceType} ${resourceName}` : ``} + ${ + !_.isEmpty(type) && type === 'admin' + ? `${resourceName} ${resourceType} has been added and is pending a review. View and then either approve or reject via the link below.` + : `` + } ${!_.isEmpty(type) && type === 'author' ? authorBody : ``} ${ !_.isEmpty(type) && type === 'co-author' - ? `${resourceAuthor} added you as an author of the tool ${resourceName}` + ? `${resourceAuthor} added you as an author of the ${resourceType} ${resourceName}` : `` }

@@ -1931,7 +1936,7 @@ const _generateEntityNotification = options => {
- ${!_.isEmpty(type) && type === 'admin' ? `View ${resourceType}` : ``} + ${!_.isEmpty(type) && type === 'admin' ? `View ${resourceType}s dashboard` : ``} ${!_.isEmpty(type) && type === 'author' ? `View ${resourceType}` : ``} ${!_.isEmpty(type) && type === 'co-author' ? `View ${resourceType}` : ``}
+ + + + + + + + + + + + + + + + + + + + +
Project${projectName}
Related NCS project${ + isNationalCoreStudies ? `View NCS project` : 'no' + }
Dataset(s)${datasetTitles}
Date of submission${dateSubmitted}
Applicant${userName}, ${_displayCorrectEmailAddress( + userEmail, + userType + )}
`; + + const amendBody = ` + + + + + + + + + + + + +
Project${projectName}
Date of amendment submission${dateSubmitted}
Applicant${userName}, ${_displayCorrectEmailAddress( + userEmail, + userType + )}
+ + + + + + + + + + + + +
+

Datasets requested

+
Previous datasets${initialDatasetTitles}
New datasets${datasetTitles}
`; + + let heading, subject; + switch (submissionType) { + case constants.submissionTypes.INPROGRESS: + heading = 'Data access request application in progress'; + subject = `You are in progress with a request access to ${datasetTitles}. The custodian will be in contact after you submit the application.`; + break; + case constants.submissionTypes.INITIAL: + heading = 'New data access request application'; + subject = `You have requested access to ${datasetTitles}. The custodian will be in contact about the application.`; + break; + case constants.submissionTypes.RESUBMISSION: + heading = 'Existing data access request application with new updates'; + subject = `You have made updates to your Data Access Request for ${datasetTitles}. The custodian will be in contact about the application.`; + break; + case constants.submissionTypes.AMENDED: + heading = 'Data access request application amended'; + subject = `${userName} has made amendments to an approved application`; + body = amendBody; + break; } - return subject; + + return `
+ + + + + + + + + + + + + + `; }; /** @@ -211,76 +318,38 @@ const _buildSubjectTitle = (user, title, submissionType) => { * @return {String} Questions Answered */ const _buildEmail = (aboutApplication, fullQuestions, questionAnswers, options) => { + const { + userType, + userName, + userEmail, + datasetTitles, + initialDatasetTitles, + submissionType, + submissionDescription, + applicationId, + } = options; + const dateSubmitted = moment().format('D MMM YYYY'); + const { projectName = 'No project name set', isNationalCoreStudies = false, nationalCoreStudiesProjectId = '' } = aboutApplication; + const linkNationalCoreStudies = + nationalCoreStudiesProjectId === '' ? '' : `${process.env.homeURL}/project/${nationalCoreStudiesProjectId}`; + let parent; - let { userType, userName, userEmail, datasetTitles, submissionType } = options; - let dateSubmitted = moment().format('D MMM YYYY'); - let { projectName = 'No project name set', isNationalCoreStudies = false, nationalCoreStudiesProjectId = '' } = aboutApplication; - let linkNationalCoreStudies = nationalCoreStudiesProjectId === '' ? '' : `${process.env.homeURL}/project/${nationalCoreStudiesProjectId}`; - let heading = - submissionType === constants.submissionTypes.INPROGRESS - ? 'Data access request application in progress' - : constants.submissionTypes.INITIAL - ? `New data access request application` - : `Existing data access request application with new updates`; - let subject = _buildSubjectTitle(userType, datasetTitles, submissionType); let questionTree = { ...fullQuestions }; let answers = { ...questionAnswers }; let pages = Object.keys(questionTree); let gatewayAttributionPolicy = `We ask that use of the Innovation Gateway be attributed in any resulting research outputs. Please include the following statement in the acknowledgments: 'Data discovery and access was facilitated by the Health Data Research UK Innovation Gateway - HDRUK Innovation Gateway | Homepage 2020.'`; - let table = `
-
+ ${heading} +
+ ${subject} +
+ ${body} +
- - - - - - - - - - - - - `; + datasetTitles, + initialDatasetTitles, + submissionType, + projectName, + isNationalCoreStudies, + dateSubmitted + ); // Create json content payload for attaching to email const jsonContent = { @@ -301,7 +370,7 @@ const _buildEmail = (aboutApplication, fullQuestions, questionAnswers, options)
- ${heading} -
- ${subject} -
- - - - - - - - - - - - - - - - - - - - - -
Project${projectName}
Related NCS project${ - isNationalCoreStudies ? `View NCS project` : 'no' - }
Dataset(s)${datasetTitles}
Date of submission${dateSubmitted}
Applicant${userName}, ${_displayCorrectEmailAddress( + + let table = _getSubmissionDetails( + userType, + userName, userEmail, - userType - )}
-
`; @@ -340,11 +409,31 @@ const _buildEmail = (aboutApplication, fullQuestions, questionAnswers, options) } table += `
-

${page}

+

${page}

`; } + + if (submissionDescription) { + table += ` + + +

Message to data custodian:

+

${submissionDescription}

+ + `; + } + + table += ` + +
+ ${_displayDARLink(applicationId)} +
+ +`; + table += ` -

${gatewayAttributionPolicy}

+

${gatewayAttributionPolicy}

`; + table += `
`; return { html: table, jsonContent }; @@ -471,6 +560,42 @@ const _generateEmail = async (aboutApplication, questions, pages, questionPanels return { html, jsonContent }; }; +const _generateAmendEmail = async ( + aboutApplication, + questions, + pages, + questionPanels, + questionAnswers, + initialQuestionAnswers, + options +) => { + // filter out unchanged answers + const changedAnswers = Object.keys(questionAnswers).reduce((obj, key) => { + if (isEqual(questionAnswers[key], initialQuestionAnswers[key])) { + return obj; + } + return { ...obj, [key]: questionAnswers[key] }; + }, {}); + + // reset questionList arr + questionList = []; + // set questionAnswers + let flatQuestionAnswers = await _actualQuestionAnswers(changedAnswers, options); + // unnest each questionPanel if questionSets + let flatQuestionPanels = _unNestQuestionPanels(questionPanels); + // unnest question flat + let unNestedQuestions = _initalQuestionSpread(questions, pages, flatQuestionPanels); + // assigns to questionList + _getAllQuestionsFlattened(unNestedQuestions); + // filter to only changed questions + let changedQuestions = questionList.filter(q => Object.keys(changedAnswers).some(key => key === q.questionId)); + let fullQuestions = _groupByPageSection([...changedQuestions]); + // build up email with values + let { html, jsonContent } = _buildEmail(aboutApplication, fullQuestions, flatQuestionAnswers, options); + // return email + return { html, jsonContent }; +}; + const _displayConditionalStatusDesc = (applicationStatus, applicationStatusDesc) => { if ((applicationStatusDesc && applicationStatus === 'approved with conditions') || applicationStatus === 'rejected') { let conditionalTitle = ''; @@ -1999,6 +2124,7 @@ export default { generateAttachment: _generateAttachment, //DAR generateEmail: _generateEmail, + generateAmendEmail: _generateAmendEmail, generateDARReturnedEmail: _generateDARReturnedEmail, generateDARStatusChangedEmail: _generateDARStatusChangedEmail, generateDARClonedEmail: _generateDARClonedEmail, From d4ff7238b4865da8f445f488af4714bcc6e01392 Mon Sep 17 00:00:00 2001 From: Richard Date: Tue, 29 Jun 2021 09:42:58 +0100 Subject: [PATCH 53/81] Allowing paper/project authors to receive noticiations and emails when entity is updated/added. --- src/resources/tool/data.repository.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/resources/tool/data.repository.js b/src/resources/tool/data.repository.js index 170a36eb..9278dd8c 100644 --- a/src/resources/tool/data.repository.js +++ b/src/resources/tool/data.repository.js @@ -118,9 +118,7 @@ const addTool = async (req, res) => { emailGenerator.sendEmail(emailRecipients, `${hdrukEmail}`, `A new ${data.type} has been added and is ready for review`, html, false); }); - if (data.type === 'tool') { - await sendEmailNotificationToAuthors(data, toolCreator); - } + await sendEmailNotificationToAuthors(data, toolCreator); await storeNotificationsForAuthors(data, toolCreator); resolve(newDataObj); @@ -208,7 +206,7 @@ const editTool = async (req, res) => { ).then(tool => { if (tool == null) { reject(new Error(`No record found with id of ${id}.`)); - } else if (type === 'tool') { + } else { // Send email notification of update to all authors who have opted in to updates sendEmailNotificationToAuthors(data, toolCreator); storeNotificationsForAuthors(data, toolCreator); @@ -493,7 +491,7 @@ async function sendEmailNotifications(tool, activeflag, rejectionReason) { 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 + `/${tool.type}/` + tool.id; // 2. Find all authors of the tool who have opted in to email updates var q = UserModel.aggregate([ @@ -534,8 +532,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; // clone deep the object tool take a deep clone of properties let toolCopy = cloneDeep(tool); From 68629b1d8dbf21bdb10df9373e1dafde4f822c71 Mon Sep 17 00:00:00 2001 From: Robin Kavanagh Date: Tue, 29 Jun 2021 15:23:30 +0100 Subject: [PATCH 54/81] Completed amend work --- src/resources/datarequest/datarequest.controller.js | 11 +++++++---- src/resources/datarequest/datarequest.service.js | 5 +++-- src/resources/utilities/emailGenerator.util.js | 6 ++++-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 16370ee8..8f89465b 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1766,7 +1766,7 @@ export default class DataRequestController extends Controller { async createNotifications(type, context, accessRecord, user) { // Project details from about application if 5 Safes - let { aboutApplication = {}, datasetIds = [], initialDatasetIds = [], } = accessRecord; + let { aboutApplication = {} } = accessRecord; let { projectName = 'No project name set' } = aboutApplication; let { projectId, _id, workflow = {}, dateSubmitted = '', jsonSchema, questionAnswers, createdAt } = accessRecord; if (_.isEmpty(projectId)) { @@ -1838,6 +1838,7 @@ export default class DataRequestController extends Controller { userName: `${appFirstName} ${appLastName}`, userType: 'applicant', submissionType: constants.submissionTypes.INPROGRESS, + applicationId: accessRecord._id.toString() }; // Build email template @@ -1923,7 +1924,7 @@ export default class DataRequestController extends Controller { // 1. Create notifications // Custodian notification if ( - _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') && + _.has(accessRecord.datasets[0], 'publisher.team.users') && accessRecord.datasets[0].publisher.allowAccessRequestManagement ) { // Retrieve all custodian user Ids to generate notifications @@ -1965,6 +1966,7 @@ export default class DataRequestController extends Controller { publisher, datasetTitles, userName: `${appFirstName} ${appLastName}`, + applicationId: accessRecord._id.toString() }; // Iterate through the recipient types for (let emailRecipientType of constants.submissionEmailRecipientTypes) { @@ -2048,6 +2050,7 @@ export default class DataRequestController extends Controller { publisher, datasetTitles, userName: `${appFirstName} ${appLastName}`, + applicationId: accessRecord._id.toString() }; // Iterate through the recipient types for (let emailRecipientType of constants.submissionEmailRecipientTypes) { @@ -2468,7 +2471,7 @@ export default class DataRequestController extends Controller { // Applicant notification await notificationBuilder.triggerNotificationMessage( [accessRecord.userId], - `Your Data Access Request for ${datasetTitles} was successfully resubmitted with updates to ${publisher}`, + `Your Data Access Request for ${datasetTitles} was successfully submitted with amendments to ${publisher}`, 'data access request', accessRecord._id ); @@ -2476,7 +2479,7 @@ export default class DataRequestController extends Controller { if (!_.isEmpty(authors)) { await notificationBuilder.triggerNotificationMessage( accessRecord.authors.map(author => author.id), - `A Data Access Request you are contributing to for ${datasetTitles} was successfully resubmitted with updates to ${publisher} by ${firstname} ${lastname}`, + `A Data Access Request you are contributing to for ${datasetTitles} was successfully submitted with amendments to ${publisher} by ${firstname} ${lastname}`, 'data access request', accessRecord._id ); diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 2abe2bdb..8855e5b9 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -166,7 +166,8 @@ export default class DataRequestService { const isCurrent = applicationId.toString() === _id.toString() && (requestedVersion === versionKey || !requestedVersion); const version = { - number: parseFloat(versionKey), + number: versionKey, + versionNumber: parseFloat(versionKey), _id, link, displayTitle, @@ -179,7 +180,7 @@ export default class DataRequestService { return arr; }, []); - const orderedVersions = orderBy(unsortedVersions, ['number'], ['desc']); + const orderedVersions = orderBy(unsortedVersions, ['versionNumber'], ['desc']); // If a current version is not found, this means an unpublished version is in progress with the Custodian, therefore we must select the previous available version if (!orderedVersions.some(v => v.isCurrent)) { diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 66316507..1a0fd60d 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -194,7 +194,8 @@ const _getSubmissionDetails = ( submissionType, projectName, isNationalCoreStudies, - dateSubmitted + dateSubmitted, + linkNationalCoreStudies ) => { let body = ` @@ -348,7 +349,8 @@ const _buildEmail = (aboutApplication, fullQuestions, questionAnswers, options) submissionType, projectName, isNationalCoreStudies, - dateSubmitted + dateSubmitted, + linkNationalCoreStudies ); // Create json content payload for attaching to email From 4568bf59668ebcb6c4c6d36a60597b34094385f0 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 1 Jul 2021 13:08:26 +0100 Subject: [PATCH 55/81] Rewrote advancedSearch endpoint to allow an Admin to set a users role. Added new middleware to check if admin or if current user --- src/resources/auth/utils.js | 16 +++++++++++++++- src/resources/user/user.route.js | 16 ++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/resources/auth/utils.js b/src/resources/auth/utils.js index 0ddd687d..30e6f8c0 100644 --- a/src/resources/auth/utils.js +++ b/src/resources/auth/utils.js @@ -57,6 +57,20 @@ const checkIsInRole = (...roles) => (req, res, next) => { return next(); }; +const checkIsAdminOrIsUser = () => (req, res, next) => { + if (req.user) { + if (req.user.role === ROLES.Admin) return next(); + else if (req.params.userID && req.params.userID === req.user.id.toString()) return next(); + else if (req.params.id && req.params.id === req.user.id.toString()) return next(); + else if (req.body.id && req.body.id.toString() === req.user.id.toString()) return next(); + + return res.status(401).json({ + status: 'error', + message: 'Unauthorised to perform this action.', + }); + } +}; + const whatIsRole = req => { if (!req.user) { return 'Reader'; @@ -103,4 +117,4 @@ const checkAllowedToAccess = type => async (req, res, next) => { }); }; -export { setup, signToken, camundaToken, checkIsInRole, whatIsRole, checkIsUser, checkAllowedToAccess }; +export { setup, signToken, camundaToken, checkIsInRole, whatIsRole, checkIsUser, checkIsAdminOrIsUser, checkAllowedToAccess }; diff --git a/src/resources/user/user.route.js b/src/resources/user/user.route.js index c181e574..943a5a80 100644 --- a/src/resources/user/user.route.js +++ b/src/resources/user/user.route.js @@ -5,6 +5,8 @@ import { utils } from '../auth'; import { UserModel } from './user.model'; import { Data } from '../tool/data.model'; import helper from '../utilities/helper.util'; +import { ROLES } from './user.roles'; + //import { createServiceAccount } from './user.repository'; const router = express.Router(); @@ -101,16 +103,22 @@ router.patch('/advancedSearch/terms/:id', passport.authenticate('jwt'), utils.ch // @router PATCH /api/v1/users/advancedSearch/roles/:id // @desc Set advanced search roles for a user // @access Private -router.patch('/advancedSearch/roles/:id', passport.authenticate('jwt'), utils.checkIsUser(), async (req, res) => { +router.patch('/advancedSearch/roles/:id', passport.authenticate('jwt'), utils.checkIsAdminOrIsUser(), async (req, res) => { const { advancedSearchRoles } = req.body; if (typeof advancedSearchRoles !== 'object') { return res.status(400).json({ status: 'error', message: 'Invalid role(s) supplied.' }); } - let roles = advancedSearchRoles.map(role => role.toString()); - let user = await UserModel.findOneAndUpdate({ id: req.params.id }, { advancedSearchRoles: roles }, { new: true }, err => { + + const user = await UserModel.findOne({ id: req.params.id }); + if (user.advancedSearchRoles && user.advancedSearchRoles.includes('BANNED')) { + return res.status(403).json({ status: 'error', message: 'User is banned. No update applied.' }); + } + + const roles = advancedSearchRoles.map(role => role.toString()); + const updatedUser = await UserModel.findOneAndUpdate({ id: req.params.id }, { advancedSearchRoles: roles }, { new: true }, err => { if (err) return res.json({ success: false, error: err }); }); - return res.status(200).json({ status: 'success', response: user }); + return res.status(200).json({ status: 'success', response: updatedUser }); }); // @router POST /api/v1/users/serviceaccount From 5181abdc45c8780aedfb9c71ebfb60f23d45d9f7 Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Thu, 1 Jul 2021 13:25:06 +0100 Subject: [PATCH 56/81] IG-2049 Endpoint to delete draft added --- .../dataset/datasetonboarding.controller.js | 18 ++++++++++++++++++ .../dataset/datasetonboarding.route.js | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/src/resources/dataset/datasetonboarding.controller.js b/src/resources/dataset/datasetonboarding.controller.js index 5eb6e491..77b5a4ba 100644 --- a/src/resources/dataset/datasetonboarding.controller.js +++ b/src/resources/dataset/datasetonboarding.controller.js @@ -1619,4 +1619,22 @@ module.exports = { break; } }, + + //DELETE api/v1/dataset-onboarding/delete/:id + deleteDraftDataset: async (req, res) => { + try { + let id = req.params.id; + + let dataset = await Data.findOneAndRemove({ _id: id, activeflag: 'draft' }); + let draftDatasetName = dataset.name; + + return res.status(200).json({ + success: true, + data: draftDatasetName, + }); + } catch (err) { + console.error(err.message); + res.status(500).json({ status: 'error', message: err.message }); + } + }, }; diff --git a/src/resources/dataset/datasetonboarding.route.js b/src/resources/dataset/datasetonboarding.route.js index 274c3075..df57b232 100644 --- a/src/resources/dataset/datasetonboarding.route.js +++ b/src/resources/dataset/datasetonboarding.route.js @@ -43,4 +43,9 @@ router.post('/:id', passport.authenticate('jwt'), datasetOnboardingController.su // @access Private - Custodian Manager/Reviewer ? router.put('/:id', passport.authenticate('jwt'), datasetOnboardingController.changeDatasetVersionStatus); +// @route DELETE /api/v1/dataset-onboarding/delete/:id +// @desc Delete Draft Dataset +// @access Private - Custodian Manager ? +router.delete('/delete/:id', passport.authenticate('jwt'), datasetOnboardingController.deleteDraftDataset); + module.exports = router; From ecc78f80bb93ac2b5efb01d2dca1eebda12b4b30 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 1 Jul 2021 14:32:45 +0100 Subject: [PATCH 57/81] Allowing query to be used to select which filters are returned --- src/resources/filters/filters.service.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/filters/filters.service.js b/src/resources/filters/filters.service.js index 70c7b274..e6a1b72b 100644 --- a/src/resources/filters/filters.service.js +++ b/src/resources/filters/filters.service.js @@ -12,7 +12,7 @@ export default class FiltersService { // 1. Get filters from repository for the entity type and query provided const options = { lean: false }; let filters = await this.filtersRepository.getFilters(id, query, options); - if (filters) { + if (filters && !has(query, 'fields')) { filters = filters.mapDto(); } return filters; @@ -175,7 +175,7 @@ export default class FiltersService { ...temporal, ...access, ...formatAndStandards, - publisher + publisher, }; break; } From bc76c03a0e15368471f0bdb80c7bf3258e8f9298 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 1 Jul 2021 16:06:19 +0100 Subject: [PATCH 58/81] Removed unused import --- src/resources/user/user.route.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/resources/user/user.route.js b/src/resources/user/user.route.js index 943a5a80..4fc5728d 100644 --- a/src/resources/user/user.route.js +++ b/src/resources/user/user.route.js @@ -5,8 +5,6 @@ import { utils } from '../auth'; import { UserModel } from './user.model'; import { Data } from '../tool/data.model'; import helper from '../utilities/helper.util'; -import { ROLES } from './user.roles'; - //import { createServiceAccount } from './user.repository'; const router = express.Router(); From e22ed4402133759407ed75a59b7c02d3fcb28aaf Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Fri, 2 Jul 2021 10:42:11 +0100 Subject: [PATCH 59/81] Updates to contextual messaging --- .../datarequest/datarequest.controller.js | 16 +++++--- .../datarequest/datarequest.service.js | 3 +- .../datarequest/utils/datarequest.util.js | 41 +++++++++++++++++-- src/resources/topic/topic.repository.js | 7 ++++ src/resources/topic/topic.service.js | 4 ++ src/resources/utilities/constants.util.js | 16 +++++++- 6 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index c54dd73c..3d48327a 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -168,10 +168,16 @@ export default class DataRequestController extends Controller { ); // 13. Inject message and note counts - accessRecord.jsonSchema = datarequestUtil.injectMessagesAndNotesCount(accessRecord.jsonSchema, userType); - //Get all messages - //Get notes if applicant - //Get notes if team + const messages = await this.topicService.getTopicsForDAR(id, constants.DARMessageTypes.DARMESSAGE); + let notes = []; + if (userType === constants.userTypes.APPLICANT) { + notes = await this.topicService.getTopicsForDAR(id, constants.DARMessageTypes.DARNOTESAPPLICANT); + } else if (userType === constants.userTypes.CUSTODIAN) { + notes = await this.topicService.getTopicsForDAR(id, constants.DARMessageTypes.DARNOTESCUSTODIAN); + } + if (messages.length > 0 || notes.length > 0) { + accessRecord.jsonSchema = datarequestUtil.injectMessagesAndNotesCount(accessRecord.jsonSchema, messages, notes); + } // 14. Build version selector const requestedFullVersion = `${requestedMajorVersion}.${ @@ -2680,8 +2686,6 @@ export default class DataRequestController extends Controller { await this.messageService.createMessageForDAR(messageBody, topic._id, requestingUserObjectId, userType); - //update message/note count in json - return res.status(200).json({ status: 'success', }); diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index e456cd16..a924aeed 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -161,7 +161,8 @@ export default class DataRequestService { const unsortedVersions = Object.keys(versionTree).reduce((arr, versionKey) => { const { applicationId: _id, link, displayTitle, detailedTitle, applicationStatus } = versionTree[versionKey]; - if (userType === constants.userTypes.CUSTODIAN && applicationStatus === constants.applicationStatuses.INPROGRESS) return arr; + //if (userType === constants.userTypes.CUSTODIAN && applicationStatus === constants.applicationStatuses.INPROGRESS /* && !isShared */) + // return arr; const isCurrent = applicationId.toString() === _id.toString() && (requestedVersion === versionKey || !requestedVersion); diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index 22b2098d..9a357025 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -17,12 +17,17 @@ const injectQuestionActions = (jsonSchema, userType, applicationStatus, role = ' ) return { ...jsonSchema, - questionActions: [constants.questionActions.guidance, constants.questionActions.updates], + questionActions: [ + constants.questionActions.guidance, + constants.questionActions.messages, + constants.questionActions.notes, + constants.questionActions.updates, + ], }; else { return { ...jsonSchema, - questionActions: [constants.questionActions.guidance], + questionActions: [constants.questionActions.guidance, constants.questionActions.messages, constants.questionActions.notes], }; } }; @@ -42,7 +47,7 @@ const getUserPermissionsForApplication = (application, userId, _id) => { } else if (has(application, 'publisherObj.team')) { isTeamMember = teamController.checkTeamPermissions('', application.publisherObj.team, _id); } - if (isTeamMember && application.applicationStatus !== constants.applicationStatuses.INPROGRESS) { + if (isTeamMember) { userType = constants.userTypes.CUSTODIAN; authorised = true; } @@ -356,7 +361,35 @@ const extractRepeatedQuestionIds = questionAnswers => { }, []); }; -const injectMessagesAndNotesCount = (jsonSchema, userType) => { +const injectMessagesAndNotesCount = (jsonSchema, messages, notes) => { + let messageNotesArray = []; + + messages.forEach(topic => { + messageNotesArray.push({ question: topic.subTitle, messageCount: topic.topicMessages.length, notesCount: 0 }); + }); + + notes.forEach(topic => { + if (messageNotesArray.find(x => x.question === topic.subTitle)) { + let existingTopic = messageNotesArray.find(x => x.question === topic.subTitle); + existingTopic.notesCount = topic.topicMessages.length; + } else { + messageNotesArray.push({ question: topic.subTitle, messageCount: 0, notesCount: topic.topicMessages.length }); + } + }); + + jsonSchema.questionSets.forEach(questionPanel => { + if (questionPanel.questions.find(x => messageNotesArray.some(e => e.question === x.questionId))) { + let foundQuestions = questionPanel.questions.filter(x => messageNotesArray.some(e => e.question === x.questionId)); + foundQuestions.forEach(question => { + let messageNoteQuestion = messageNotesArray.find(x => x.question === question.questionId); + question.counts = { + messagesCount: messageNoteQuestion.messageCount, + notesCount: messageNoteQuestion.notesCount, + }; + }); + } + }); + return jsonSchema; }; diff --git a/src/resources/topic/topic.repository.js b/src/resources/topic/topic.repository.js index 52440d63..810a0b86 100644 --- a/src/resources/topic/topic.repository.js +++ b/src/resources/topic/topic.repository.js @@ -7,6 +7,13 @@ export default class TopicRepository extends Repository { this.topicModel = TopicModel; } + getTopicsForDAR(title, messageType) { + return TopicModel.find({ + title, + messageType, + }).lean(); + } + getTopicForDAR(title, subTitle, messageType) { return TopicModel.findOne({ title, diff --git a/src/resources/topic/topic.service.js b/src/resources/topic/topic.service.js index 6c877218..45fc50c8 100644 --- a/src/resources/topic/topic.service.js +++ b/src/resources/topic/topic.service.js @@ -3,6 +3,10 @@ export default class TopicService { this.topicRepository = topicRepository; } + getTopicsForDAR(applicationID, messageType) { + return this.topicRepository.getTopicsForDAR(applicationID, messageType); + } + getTopicForDAR(applicationID, questionID, messageType) { return this.topicRepository.getTopicForDAR(applicationID, questionID, messageType); } diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 73576a46..7c4f935b 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -44,12 +44,26 @@ const _questionActions = { toolTip: 'Guidance', order: 1, }, + messages: { + key: 'messages', + icon: 'far fa-comment-alt', + color: '#475da7', + toolTip: 'Messages', + order: 2, + }, + notes: { + key: 'notes', + icon: 'far fa-edit', + color: '#475da7', + toolTip: 'Notes', + order: 3, + }, updates: { key: 'requestAmendment', icon: 'fas fa-exclamation-circle', color: '#F0BB24', toolTip: 'Request applicant updates answer', - order: 2, + order: 4, }, }; From c1ce47da92f5049b07cec95e5bcb70488e3db602 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 2 Jul 2021 13:20:28 +0100 Subject: [PATCH 60/81] Split advanced search endpoint into 2. Removed middleware --- src/resources/auth/utils.js | 16 +----------- src/resources/user/user.route.js | 42 +++++++++++++++++++++--------- src/resources/user/user.service.js | 26 ++++++++++++++---- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/resources/auth/utils.js b/src/resources/auth/utils.js index 30e6f8c0..0ddd687d 100644 --- a/src/resources/auth/utils.js +++ b/src/resources/auth/utils.js @@ -57,20 +57,6 @@ const checkIsInRole = (...roles) => (req, res, next) => { return next(); }; -const checkIsAdminOrIsUser = () => (req, res, next) => { - if (req.user) { - if (req.user.role === ROLES.Admin) return next(); - else if (req.params.userID && req.params.userID === req.user.id.toString()) return next(); - else if (req.params.id && req.params.id === req.user.id.toString()) return next(); - else if (req.body.id && req.body.id.toString() === req.user.id.toString()) return next(); - - return res.status(401).json({ - status: 'error', - message: 'Unauthorised to perform this action.', - }); - } -}; - const whatIsRole = req => { if (!req.user) { return 'Reader'; @@ -117,4 +103,4 @@ const checkAllowedToAccess = type => async (req, res, next) => { }); }; -export { setup, signToken, camundaToken, checkIsInRole, whatIsRole, checkIsUser, checkIsAdminOrIsUser, checkAllowedToAccess }; +export { setup, signToken, camundaToken, checkIsInRole, whatIsRole, checkIsUser, checkAllowedToAccess }; diff --git a/src/resources/user/user.route.js b/src/resources/user/user.route.js index 4fc5728d..6397acea 100644 --- a/src/resources/user/user.route.js +++ b/src/resources/user/user.route.js @@ -5,6 +5,9 @@ import { utils } from '../auth'; import { UserModel } from './user.model'; import { Data } from '../tool/data.model'; import helper from '../utilities/helper.util'; +import { ROLES } from './user.roles'; +import { setCohortDiscoveryAccess } from './user.service'; +import { upperCase } from 'lodash'; //import { createServiceAccount } from './user.repository'; const router = express.Router(); @@ -98,25 +101,40 @@ router.patch('/advancedSearch/terms/:id', passport.authenticate('jwt'), utils.ch return res.status(200).json({ status: 'success', response: user }); }); -// @router PATCH /api/v1/users/advancedSearch/roles/:id -// @desc Set advanced search roles for a user +// @router PATCH /api/v1/users/advancedSearch/customRoles/:id +// @desc Allow admin to set custom advanced search roles for a user // @access Private -router.patch('/advancedSearch/roles/:id', passport.authenticate('jwt'), utils.checkIsAdminOrIsUser(), async (req, res) => { +router.patch('/advancedSearch/customRoles/:id', passport.authenticate('jwt'), utils.checkIsInRole(ROLES.Admin), async (req, res) => { const { advancedSearchRoles } = req.body; if (typeof advancedSearchRoles !== 'object') { return res.status(400).json({ status: 'error', message: 'Invalid role(s) supplied.' }); } - const user = await UserModel.findOne({ id: req.params.id }); - if (user.advancedSearchRoles && user.advancedSearchRoles.includes('BANNED')) { - return res.status(403).json({ status: 'error', message: 'User is banned. No update applied.' }); - } + await setCohortDiscoveryAccess(req.params.id, advancedSearchRoles) + .then(response => { + return res.status(200).json({ status: 'success', response }); + }) + .catch(err => { + return res.status(err.statusCode).json({ status: 'error', message: err.message }); + }); +}); - const roles = advancedSearchRoles.map(role => role.toString()); - const updatedUser = await UserModel.findOneAndUpdate({ id: req.params.id }, { advancedSearchRoles: roles }, { new: true }, err => { - if (err) return res.json({ success: false, error: err }); - }); - return res.status(200).json({ status: 'success', response: updatedUser }); +// @router PATCH /api/v1/users/advancedSearch/roles/:id +// @desc Grant basic advanced search role for an Open Athens user +// @access Private +router.patch('/advancedSearch/roles/:id', passport.authenticate('jwt'), utils.checkIsUser(), async (req, res) => { + if (upperCase(req.user.provider) !== 'OIDC') + return res.status(403).json({ status: 'error', message: 'Only Open Athens users are permitted to use this route.' }); + + const advancedSearchRoles = ['GENERAL_ACCESS']; + + await setCohortDiscoveryAccess(req.params.id, advancedSearchRoles) + .then(response => { + return res.status(200).json({ status: 'success', response }); + }) + .catch(err => { + return res.status(err.statusCode).json({ status: 'error', message: err.message }); + }); }); // @router POST /api/v1/users/serviceaccount diff --git a/src/resources/user/user.service.js b/src/resources/user/user.service.js index fd027bc2..9123672a 100644 --- a/src/resources/user/user.service.js +++ b/src/resources/user/user.service.js @@ -15,12 +15,12 @@ export async function createUser({ firstname, lastname, email, providerId, provi role, }); // if a user has been created send new introduction email - if(user) { + if (user) { const msg = { to: email, from: 'gateway@hdruk.ac.uk', - templateId: process.env.SENDGRID_INTRO_EMAIL - } + templateId: process.env.SENDGRID_INTRO_EMAIL, + }; emailGeneratorUtil.sendIntroEmail(msg); } // return user via promise @@ -39,8 +39,8 @@ export async function updateUser({ id, firstname, lastname, email, discourseKey, email, discourseKey, discourseUsername, - feedback, - news + feedback, + news, } ) ); @@ -59,3 +59,19 @@ export async function updateRedirectURL({ id, redirectURL }) { ); }); } + +export async function setCohortDiscoveryAccess(id, roles) { + return new Promise(async (resolve, reject) => { + const user = await UserModel.findOne({ id }, { advancedSearchRoles: 1 }).lean(); + if (!user) return reject({ statusCode: 400, message: 'No user exists for id provided.' }); + + if (user.advancedSearchRoles && user.advancedSearchRoles.includes('BANNED')) { + return reject({ statusCode: 403, message: 'User is banned. No update applied.' }); + } + + const updatedUser = await UserModel.findOneAndUpdate({ id }, { advancedSearchRoles: roles }, { new: true }, err => { + if (err) return reject({ statusCode: 500, message: err }); + }).lean(); + return resolve(updatedUser); + }); +} From 2b5be8a2afabb23bf4602be7b690a5a394241d08 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 2 Jul 2021 14:21:47 +0100 Subject: [PATCH 61/81] Safe guarding database query for LGTM --- src/resources/user/user.service.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/resources/user/user.service.js b/src/resources/user/user.service.js index 9123672a..60f2b6b1 100644 --- a/src/resources/user/user.service.js +++ b/src/resources/user/user.service.js @@ -69,7 +69,8 @@ export async function setCohortDiscoveryAccess(id, roles) { return reject({ statusCode: 403, message: 'User is banned. No update applied.' }); } - const updatedUser = await UserModel.findOneAndUpdate({ id }, { advancedSearchRoles: roles }, { new: true }, err => { + const rolesCleansed = roles.map(role => role.toString()); + const updatedUser = await UserModel.findOneAndUpdate({ id }, { advancedSearchRoles: rolesCleansed }, { new: true }, err => { if (err) return reject({ statusCode: 500, message: err }); }).lean(); return resolve(updatedUser); From 3a3d40f0cdfd7a125e2930b9b2f0305d97d5b302 Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Fri, 2 Jul 2021 15:53:53 +0100 Subject: [PATCH 62/81] IG-2050 Adding in notifications and emails for delete draft dataset --- .../dataset/datasetonboarding.controller.js | 37 +++++++++++++++++++ src/resources/message/message.model.js | 1 + .../utilities/emailGenerator.util.js | 34 +++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/src/resources/dataset/datasetonboarding.controller.js b/src/resources/dataset/datasetonboarding.controller.js index 77b5a4ba..fd166ecf 100644 --- a/src/resources/dataset/datasetonboarding.controller.js +++ b/src/resources/dataset/datasetonboarding.controller.js @@ -1617,6 +1617,41 @@ module.exports = { false ); break; + case constants.notificationTypes.DRAFTDATASETDELETED: + let draftDatasetName = context.name; + let publisherName = context.datasetv2.summary.publisher.name; + + // 1. Get relevant team members to notify + team = await TeamModel.findOne({ _id: context.datasetv2.summary.publisher.identifier }).lean(); + + for (let member of team.members) { + if (member.roles.some(role => ['manager', 'metadata_editor'].includes(role))) teamMembers.push(member.memberid); + } + + teamMembersDetails = await UserModel.find({ _id: { $in: teamMembers } }) + .populate('additionalInfo') + .lean(); + + for (let member of teamMembersDetails) { + teamMembersIds.push(member.id); + } + + // 2. Create user notifications + notificationBuilder.triggerNotificationMessage( + teamMembersIds, + `${publisherName} has deleted the draft dataset for ${draftDatasetName}.`, + 'draft dataset deleted', + context._id, + context.datasetv2.summary.publisher.identifier + ); + // 3. Create email + options = { + publisherName, + draftDatasetName, + }; + html = emailGenerator.generateMetadataOnboardingDraftDeleted(options); + emailGenerator.sendEmail(teamMembersDetails, constants.hdrukEmail, `Draft dataset deleted`, html, false); + break; } }, @@ -1628,6 +1663,8 @@ module.exports = { let dataset = await Data.findOneAndRemove({ _id: id, activeflag: 'draft' }); let draftDatasetName = dataset.name; + await module.exports.createNotifications(constants.notificationTypes.DRAFTDATASETDELETED, dataset); + return res.status(200).json({ success: true, data: draftDatasetName, diff --git a/src/resources/message/message.model.js b/src/resources/message/message.model.js index 8f2ff11a..ba651a14 100644 --- a/src/resources/message/message.model.js +++ b/src/resources/message/message.model.js @@ -31,6 +31,7 @@ const MessageSchema = new Schema( 'dataset submitted', 'dataset approved', 'dataset rejected', + 'draft dataset deleted', ], }, publisherName: { diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index bc0a3c75..1836db98 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -1838,6 +1838,39 @@ const _generateMetadataOnboardingRejected = options => { return body; }; +const _generateMetadataOnboardingDraftDeleted = options => { + let { publisherName, draftDatasetName } = options; + + let body = `
+
+
+ + + + + + + + + + +
+ Draft dataset deleted +
+

${publisherName} has deleted the draft dataset for ${draftDatasetName}.

+
+
+
`; + return body; +}; + const _generateMessageNotification = options => { let { firstMessage, firstname, lastname, messageDescription, openMessagesLink } = options; @@ -2094,6 +2127,7 @@ export default { generateMetadataOnboardingSumbitted: _generateMetadataOnboardingSumbitted, generateMetadataOnboardingApproved: _generateMetadataOnboardingApproved, generateMetadataOnboardingRejected: _generateMetadataOnboardingRejected, + generateMetadataOnboardingDraftDeleted: _generateMetadataOnboardingDraftDeleted, //generateMetadataOnboardingArchived: _generateMetadataOnboardingArchived, //generateMetadataOnboardingUnArchived: _generateMetadataOnboardingUnArchived, //Messages From 7b3bd4850be6de681c1184017dbe255d0d0ccd4b Mon Sep 17 00:00:00 2001 From: Paul McCafferty <60512806+PaulMcCaffertyPA@users.noreply.github.com> Date: Mon, 5 Jul 2021 12:21:25 +0100 Subject: [PATCH 63/81] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 92c6008c..22c5b999 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hdruk-rdt-api", "config": { "mongodbMemoryServer": { - "version": "latest" + "version": "6.9.2" } }, "version": "0.1.1", From 3055294a36e47137b004bbf2eaaaaecb77883fb4 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <60512806+PaulMcCaffertyPA@users.noreply.github.com> Date: Mon, 5 Jul 2021 12:32:59 +0100 Subject: [PATCH 64/81] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 22c5b999..0011f64f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hdruk-rdt-api", "config": { "mongodbMemoryServer": { - "version": "6.9.2" + "version": "5.0.0" } }, "version": "0.1.1", From b2e8ec4758ba5e2c06b64759059792918061bfcf Mon Sep 17 00:00:00 2001 From: Paul McCafferty <60512806+PaulMcCaffertyPA@users.noreply.github.com> Date: Mon, 5 Jul 2021 12:41:16 +0100 Subject: [PATCH 65/81] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0011f64f..7c0efeac 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hdruk-rdt-api", "config": { "mongodbMemoryServer": { - "version": "5.0.0" + "version": "5.0.0-rc7" } }, "version": "0.1.1", From 9df305ae34c6678dda3794f9fcfcab6fa258d85e Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Wed, 7 Jul 2021 13:48:40 +0100 Subject: [PATCH 66/81] IG-1893 Updates to rejected dataset email --- .../dataset/datasetonboarding.controller.js | 2 +- .../utilities/emailGenerator.util.js | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/resources/dataset/datasetonboarding.controller.js b/src/resources/dataset/datasetonboarding.controller.js index fd166ecf..ec436496 100644 --- a/src/resources/dataset/datasetonboarding.controller.js +++ b/src/resources/dataset/datasetonboarding.controller.js @@ -1612,7 +1612,7 @@ module.exports = { emailGenerator.sendEmail( teamMembersDetails, constants.hdrukEmail, - `Your dataset version has been reviewed and rejected`, + `Your dataset version requires revision before it can be accepted on the Gateway`, html, false ); diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 1836db98..9e0b0cbd 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -1796,15 +1796,15 @@ const _generateMetadataOnboardingRejected = options => { if (!_.isEmpty(comment)) { commentHTML = ` - - Reason for rejection - - - - - ${comment} - - `; + + Comment from reviewer: + + + + + "${comment}" + + `; } let body = `
@@ -1817,14 +1817,14 @@ const _generateMetadataOnboardingRejected = options => { style="font-family: Arial, sans-serif"> - - Your dataset version has been reviewed and rejected + + Your dataset version requires revision before it can be accepted on the Gateway - The submitted version of ${name} has been reviewed and rejected by the HDRUK admins. Please view and create a new version of this dataset and make the necessary changes if you would like to make another submission to the Gateway. - + Thank you for submitting ${name}, which has been reviewed by the team at HDR UK. The dataset version cannot be approved for release on the Gateway at this time. Please look at the comment from the reviewer below and make any necessary changes on a new version of the dataset before resubmitting. + ${commentHTML} From b30211f7d71de25a5259aae093e115fe5bbf15b4 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Wed, 7 Jul 2021 14:05:27 +0100 Subject: [PATCH 67/81] Fixes for shared applications no showing --- src/resources/datarequest/datarequest.service.js | 3 +-- src/resources/datarequest/utils/datarequest.util.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/resources/datarequest/datarequest.service.js b/src/resources/datarequest/datarequest.service.js index 71d74dba..8855e5b9 100644 --- a/src/resources/datarequest/datarequest.service.js +++ b/src/resources/datarequest/datarequest.service.js @@ -161,8 +161,7 @@ export default class DataRequestService { const unsortedVersions = Object.keys(versionTree).reduce((arr, versionKey) => { const { applicationId: _id, link, displayTitle, detailedTitle, applicationStatus } = versionTree[versionKey]; - //if (userType === constants.userTypes.CUSTODIAN && applicationStatus === constants.applicationStatuses.INPROGRESS /* && !isShared */) - // return arr; + if (userType === constants.userTypes.CUSTODIAN && applicationStatus === constants.applicationStatuses.INPROGRESS) return arr; const isCurrent = applicationId.toString() === _id.toString() && (requestedVersion === versionKey || !requestedVersion); diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index 9a357025..8a4a3fe8 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -47,7 +47,7 @@ const getUserPermissionsForApplication = (application, userId, _id) => { } else if (has(application, 'publisherObj.team')) { isTeamMember = teamController.checkTeamPermissions('', application.publisherObj.team, _id); } - if (isTeamMember) { + if (isTeamMember && (application.applicationStatus !== constants.applicationStatuses.INPROGRESS || application.isShared)) { userType = constants.userTypes.CUSTODIAN; authorised = true; } From f9fa2ff20b175afb0534f002c9e8db65a71e4cda Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Thu, 8 Jul 2021 13:19:45 +0100 Subject: [PATCH 68/81] IG-2051 Updates to schema, default values and return for spatial field changing to an array --- .../dataset/datasetonboarding.controller.js | 6 +++--- src/resources/dataset/schema.json | 15 ++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/resources/dataset/datasetonboarding.controller.js b/src/resources/dataset/datasetonboarding.controller.js index ec436496..2ecfa2e0 100644 --- a/src/resources/dataset/datasetonboarding.controller.js +++ b/src/resources/dataset/datasetonboarding.controller.js @@ -139,7 +139,7 @@ module.exports = { questionAnswers['properties/documentation/isPartOf'] = dataset.datasetv2.documentation.isPartOf; //Coverage if (!_.isNil(dataset.datasetv2.coverage.spatial) && !_.isEmpty(dataset.datasetv2.coverage.spatial)) - questionAnswers['properties/coverage/spatial'] = dataset.datasetv2.coverage.spatial; + questionAnswers['properties/coverage/spatial'] = module.exports.returnAsArray(dataset.datasetv2.coverage.spatial); if (!_.isNil(dataset.datasetv2.coverage.typicalAgeRange) && !_.isEmpty(dataset.datasetv2.coverage.typicalAgeRange)) questionAnswers['properties/coverage/typicalAgeRange'] = dataset.datasetv2.coverage.typicalAgeRange; if ( @@ -761,7 +761,7 @@ module.exports = { isPartOf: dataset.questionAnswers['properties/documentation/isPartOf'] || [], }, coverage: { - spatial: dataset.questionAnswers['properties/coverage/spatial'] || '', + spatial: dataset.questionAnswers['properties/coverage/spatial'] || [], typicalAgeRange: dataset.questionAnswers['properties/coverage/typicalAgeRange'] || '', physicalSampleAvailability: dataset.questionAnswers['properties/coverage/physicalSampleAvailability'] || [], followup: dataset.questionAnswers['properties/coverage/followup'] || '', @@ -843,7 +843,7 @@ module.exports = { counter: previousCounter, datasetfields: { publisher: `${publisherData[0].publisherDetails.memberOf} > ${publisherData[0].publisherDetails.name}`, - geographicCoverage: dataset.questionAnswers['properties/coverage/spatial'] || '', + geographicCoverage: dataset.questionAnswers['properties/coverage/spatial'] || [], physicalSampleAvailability: dataset.questionAnswers['properties/coverage/physicalSampleAvailability'] || [], abstract: dataset.questionAnswers['properties/summary/abstract'] || '', releaseDate: dataset.questionAnswers['properties/provenance/temporal/distributionReleaseDate'] || '', diff --git a/src/resources/dataset/schema.json b/src/resources/dataset/schema.json index 13589102..4db80758 100644 --- a/src/resources/dataset/schema.json +++ b/src/resources/dataset/schema.json @@ -515,10 +515,19 @@ "title": "Geographic Coverage", "$comment": "dct:spatial", "description": "The geographical area covered by the dataset. It is recommended that links are to entries in a well-maintained gazetteer such as https://www.geonames.org/ or https://what3words.com/daring.lion.race.", - "examples": ["https://www.geonames.org/2635167/united-kingdom-of-great-britain-and-northern-ireland.html"], - "allOf": [ + "anyOf": [ { - "$ref": "#/definitions/url" + "$ref": "#/definitions/commaSeparatedValues" + }, + { + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/url" + } + ] + } } ] }, From 90d652b2746c3c53922c700467fe62d677bde93f Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Thu, 8 Jul 2021 13:35:50 +0100 Subject: [PATCH 69/81] IG-1894 Copy change to delete draft email and notifcation --- src/resources/dataset/datasetonboarding.controller.js | 2 +- src/resources/utilities/emailGenerator.util.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/resources/dataset/datasetonboarding.controller.js b/src/resources/dataset/datasetonboarding.controller.js index ec436496..509ffd0e 100644 --- a/src/resources/dataset/datasetonboarding.controller.js +++ b/src/resources/dataset/datasetonboarding.controller.js @@ -1639,7 +1639,7 @@ module.exports = { // 2. Create user notifications notificationBuilder.triggerNotificationMessage( teamMembersIds, - `${publisherName} has deleted the draft dataset for ${draftDatasetName}.`, + `The draft version of ${draftDatasetName} has been deleted.`, 'draft dataset deleted', context._id, context.datasetv2.summary.publisher.identifier diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 9e0b0cbd..eb105afe 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -1861,7 +1861,9 @@ const _generateMetadataOnboardingDraftDeleted = options => { -

${publisherName} has deleted the draft dataset for ${draftDatasetName}.

+

+ The draft version of ${draftDatasetName} has been deleted. +

From 45362542568a34a8820fe5ea0fc0e1bcbcb8488d Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Wed, 21 Jul 2021 16:57:37 +0100 Subject: [PATCH 70/81] IG-2108 Get publisher teams endpoint --- src/resources/team/team.controller.js | 43 ++++++++++++++++++++++++++- src/resources/team/team.route.js | 11 +++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index ad796172..08d7f2bc 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -4,7 +4,7 @@ import { UserModel } from '../user/user.model'; import emailGenerator from '../utilities/emailGenerator.util'; import notificationBuilder from '../utilities/notificationBuilder'; import constants from '../utilities/constants.util'; - + // GET api/v1/teams/:id const getTeamById = async (req, res) => { try { @@ -528,6 +528,46 @@ const deleteTeamMember = async (req, res) => { } }; +/** + * GET api/v1/teams/getList + * + * @desc Get the list of all publisher teams + * + */ +const getTeamsList = async (req, res) => { + try { + // 1. Get the publisher teams from the database + const teams = await TeamModel.find( + { $and: [{ type: 'publisher' }, { active: true }]}, + { + _id: 1, + updatedAt: 1, + members: 1, + membersCount: {$size: '$members'} + } + ) + .populate('publisher', { name: 1 }) + .populate('users', { firstname: 1, lastname: 1 }) + .lean(); + + // 2. Check the current user is a member of the HDR admin team + const hdrAdminTeam = await TeamModel.findOne({ type: 'admin' }).lean(); + + const hdrAdminTeamMember = hdrAdminTeam.members.filter( member => member.memberid.toString() === req.user._id.toString() ) + + // 3. If not return unauthorised + if(_.isEmpty(hdrAdminTeamMember)){ + return res.status(401).json({ success: false, message: 'Unauthorised' }); + } + + // 4. Return team + return res.status(200).json({ success: true, teams }); + } catch (err) { + console.error(err.message); + return res.status(500).json(err.message); + } +}; + /** * Check a users permission levels for a team * @@ -848,4 +888,5 @@ export default { checkTeamPermissions: checkTeamPermissions, getTeamMembersByRole: getTeamMembersByRole, createNotifications: createNotifications, + getTeamsList: getTeamsList, }; diff --git a/src/resources/team/team.route.js b/src/resources/team/team.route.js index 798a230d..3b7c345f 100644 --- a/src/resources/team/team.route.js +++ b/src/resources/team/team.route.js @@ -5,13 +5,18 @@ import teamController from './team.controller'; const router = express.Router(); +// @route GET api/v1/teams/getList +// @desc Returns List of all Teams +// @access Private +router.get('/getList', passport.authenticate('jwt'), teamController.getTeamsList); + // @route GET api/teams/:id // @desc GET A team by :id // @access Public -router.get('/:id', passport.authenticate('jwt'), teamController.getTeamById); +router.get('/:id', passport.authenticate('jwt'), teamController.getTeamById); // @route GET api/teams/:id/members -// @desc GET all team members for team +// @desc GET all team members for team // @access Private router.get('/:id/members', passport.authenticate('jwt'), teamController.getTeamMembers); @@ -46,4 +51,4 @@ router.put('/:id/notifications', passport.authenticate('jwt'), teamController.up // @access Private router.put('/:id/notification-messages', passport.authenticate('jwt'), teamController.updateNotificationMessages); -module.exports = router; +module.exports = router; From b817ac54b01018fbaa88cea11b4a74012d07f656 Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Thu, 22 Jul 2021 09:41:40 +0100 Subject: [PATCH 71/81] IG-2108 Removing unnecessary and --- src/resources/team/team.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index 0cb78fc1..63e07781 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -539,7 +539,7 @@ const getTeamsList = async (req, res) => { try { // 1. Get the publisher teams from the database const teams = await TeamModel.find( - { $and: [{ type: 'publisher' }, { active: true }]}, + { type: 'publisher', active: true }, { _id: 1, updatedAt: 1, From 0675f56e9bf920c092c4509e47f77a4b121515dd Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Thu, 22 Jul 2021 15:30:30 +0100 Subject: [PATCH 72/81] IG-2108 removed 'getList' from route --- src/resources/team/team.controller.js | 2 +- src/resources/team/team.route.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index 63e07781..aad3785b 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -530,7 +530,7 @@ const deleteTeamMember = async (req, res) => { }; /** - * GET api/v1/teams/getList + * GET api/v1/teams * * @desc Get the list of all publisher teams * diff --git a/src/resources/team/team.route.js b/src/resources/team/team.route.js index 3b7c345f..bedc908f 100644 --- a/src/resources/team/team.route.js +++ b/src/resources/team/team.route.js @@ -8,7 +8,7 @@ const router = express.Router(); // @route GET api/v1/teams/getList // @desc Returns List of all Teams // @access Private -router.get('/getList', passport.authenticate('jwt'), teamController.getTeamsList); +router.get('/', passport.authenticate('jwt'), teamController.getTeamsList); // @route GET api/teams/:id // @desc GET A team by :id From 0097ac18a6f75f3fd7bdd8c0a04d9f80e4dea62a Mon Sep 17 00:00:00 2001 From: CiaraWardPA Date: Thu, 22 Jul 2021 15:35:54 +0100 Subject: [PATCH 73/81] IG-2108 Get team moved to after the HDR admin team member check --- src/resources/team/team.controller.js | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js index aad3785b..0796d886 100644 --- a/src/resources/team/team.controller.js +++ b/src/resources/team/team.controller.js @@ -536,8 +536,18 @@ const deleteTeamMember = async (req, res) => { * */ const getTeamsList = async (req, res) => { - try { - // 1. Get the publisher teams from the database + try { + // 1. Check the current user is a member of the HDR admin team + const hdrAdminTeam = await TeamModel.findOne({ type: 'admin' }).lean(); + + const hdrAdminTeamMember = hdrAdminTeam.members.filter( member => member.memberid.toString() === req.user._id.toString() ) + + // 2. If not return unauthorised + if(_.isEmpty(hdrAdminTeamMember)){ + return res.status(401).json({ success: false, message: 'Unauthorised' }); + } + + // 3. Get the publisher teams from the database const teams = await TeamModel.find( { type: 'publisher', active: true }, { @@ -550,17 +560,7 @@ const getTeamsList = async (req, res) => { .populate('publisher', { name: 1 }) .populate('users', { firstname: 1, lastname: 1 }) .lean(); - - // 2. Check the current user is a member of the HDR admin team - const hdrAdminTeam = await TeamModel.findOne({ type: 'admin' }).lean(); - - const hdrAdminTeamMember = hdrAdminTeam.members.filter( member => member.memberid.toString() === req.user._id.toString() ) - - // 3. If not return unauthorised - if(_.isEmpty(hdrAdminTeamMember)){ - return res.status(401).json({ success: false, message: 'Unauthorised' }); - } - + // 4. Return team return res.status(200).json({ success: true, teams }); } catch (err) { From cb21512d202549b115519d190c1c1327ea63b1da Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Sun, 25 Jul 2021 17:11:05 +0100 Subject: [PATCH 74/81] Updates --- .../datarequest/datarequest.controller.js | 107 +++++++++++++++- src/resources/message/message.model.js | 1 + src/resources/utilities/constants.util.js | 1 + .../utilities/emailGenerator.util.js | 114 +++++++++++++++++- .../utilities/notificationBuilder.js | 2 +- 5 files changed, 220 insertions(+), 5 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 66fa7e7e..a64e97e9 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -1839,6 +1839,9 @@ export default class DataRequestController extends Controller { remainingReviewers = [], remainingReviewerUserIds = [], dateDeadline, + userType = '', + messageBody = '', + questionWithAnswer = {}, } = context; switch (type) { @@ -2555,6 +2558,65 @@ export default class DataRequestController extends Controller { } } break; + case constants.notificationTypes.MESSAGESENT: + let title = projectName !== 'No project name set' ? projectName : datasetTitles; + if (userType === constants.userTypes.APPLICANT) { + const custodianManagers = teamController.getTeamMembersByRole(accessRecord.publisherObj.team, constants.roleTypes.MANAGER); + const custodianManagersIds = custodianManagers.map(user => user.id); + const custodianReviewers = teamController.getTeamMembersByRole(accessRecord.publisherObj.team, constants.roleTypes.REVIEWER); + const custodianReviewersIds = custodianManagers.map(user => user.id); + + await notificationBuilder.triggerNotificationMessage( + [...custodianManagersIds, ...custodianReviewersIds, ...accessRecord.authors.map(author => author.id)], + `There is a new message for the application ${title} from ${user.firstname} ${user.lastname}`, + 'data access message sent', + accessRecord._id + ); + + html = emailGenerator.generateNewDARMessage({ + id: accessRecord._id, + datasetTitles, + applicants, + firstname: user.firstname, + lastname: user.lastname, + messageBody, + questionWithAnswer, + }); + + await emailGenerator.sendEmail( + [...custodianManagers, ...custodianReviewers, ...accessRecord.authors], + constants.hdrukEmail, + `There is a new message for the application ${title} from ${user.firstname} ${user.lastname}`, + html, + false + ); + } else if (userType === constants.userTypes.CUSTODIAN) { + await notificationBuilder.triggerNotificationMessage( + [accessRecord.userId, ...accessRecord.authors.map(author => author.id)], + `There is a new message for the application ${title} from ${user.firstname} ${user.lastname} from ${accessRecord.publisherObj.name}`, + 'data access message sent', + accessRecord._id + ); + + html = emailGenerator.generateNewDARMessage({ + id: accessRecord._id, + datasetTitles, + applicants, + firstname: user.firstname, + lastname: user.lastname, + messageBody, + questionWithAnswer, + }); + + await emailGenerator.sendEmail( + [accessRecord.mainApplicant, ...accessRecord.authors], + constants.hdrukEmail, + `There is a new message for the application ${title} from ${user.firstname} ${user.lastname}`, + html, + false + ); + } + break; } } @@ -2669,8 +2731,9 @@ export default class DataRequestController extends Controller { const { questionId, messageType, messageBody } = req.body; const requestingUserId = parseInt(req.user.id); const requestingUserObjectId = req.user._id; + const requestingUser = req.user; - let accessRecord = await this.dataRequestService.getApplicationById(id); + let accessRecord = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true }); if (!accessRecord) { return res.status(404).json({ status: 'error', message: 'The application could not be found.' }); } @@ -2702,6 +2765,48 @@ export default class DataRequestController extends Controller { await this.messageService.createMessageForDAR(messageBody, topic._id, requestingUserObjectId, userType); + if (messageType === constants.DARMessageTypes.DARMESSAGE) { + let foundQuestion = {}, + foundQuestionSet = {}, + foundPage = {}; + + for (let questionSet of accessRecord.jsonSchema.questionSets) { + for (let question of questionSet.questions) { + if (question.questionId === questionId) { + foundQuestion = question; + foundQuestionSet = questionSet; + break; + } + } + if (!_.isEmpty(foundQuestion)) break; + } + + const panel = dynamicForm.findQuestionPanel(foundQuestionSet.questionSetId, accessRecord.jsonSchema.questionPanels); + + for (let page of accessRecord.jsonSchema.pages) { + if (page.pageId === panel.pageId) { + foundPage = page; + break; + } + } + + this.createNotifications( + constants.notificationTypes.MESSAGESENT, + { + userType, + messageBody, + questionWithAnswer: { + question: foundQuestion.question, + questionPanel: foundQuestionSet.questionSetHeader, + page: foundPage.title, + answer: accessRecord.questionAnswers[questionId] || '', + }, + }, + accessRecord, + requestingUser + ); + } + return res.status(200).json({ status: 'success', }); diff --git a/src/resources/message/message.model.js b/src/resources/message/message.model.js index b03feaa1..46837ed8 100644 --- a/src/resources/message/message.model.js +++ b/src/resources/message/message.model.js @@ -28,6 +28,7 @@ const MessageSchema = new Schema( 'team unlinked', 'edit', 'workflow', + 'data access message sent', 'dataset submitted', 'dataset approved', 'dataset rejected', diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 2f2412e8..34688d8e 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -751,6 +751,7 @@ const _notificationTypes = { DATASETSUBMITTED: 'DatasetSubmitted', DATASETAPPROVED: 'DatasetApproved', DATASETREJECTED: 'DatasetRejected', + MESSAGESENT: 'MessageSent', }; const _applicationStatuses = { diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js index 877d8043..f772a853 100644 --- a/src/resources/utilities/emailGenerator.util.js +++ b/src/resources/utilities/emailGenerator.util.js @@ -195,7 +195,7 @@ const _getSubmissionDetails = ( projectName, isNationalCoreStudies, dateSubmitted, - linkNationalCoreStudies + linkNationalCoreStudies ) => { let body = ` @@ -350,7 +350,7 @@ const _buildEmail = (aboutApplication, fullQuestions, questionAnswers, options) projectName, isNationalCoreStudies, dateSubmitted, - linkNationalCoreStudies + linkNationalCoreStudies ); // Create json content payload for attaching to email @@ -590,7 +590,7 @@ const _generateAmendEmail = async ( // assigns to questionList _getAllQuestionsFlattened(unNestedQuestions); // filter to only changed questions - let changedQuestions = questionList.filter(q => Object.keys(changedAnswers).some(key => key === q.questionId)); + let changedQuestions = questionList.filter(q => Object.keys(changedAnswers).some(key => key === q.questionId)); let fullQuestions = _groupByPageSection([...changedQuestions]); // build up email with values let { html, jsonContent } = _buildEmail(aboutApplication, fullQuestions, flatQuestionAnswers, options); @@ -1834,6 +1834,113 @@ const _generateAddedToTeam = options => { return body; }; +const _generateNewDARMessage = options => { + let { id, projectName, datasetTitles, applicants, firstname, lastname, messageBody, questionWithAnswer } = options; + let body = `
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ New message about an application +
+ ${firstname} ${lastname} sent a message regarding their application form +
+ + + + + + + + + + + + + +
Application name${ + projectName || 'No project name set' + }
Dataset(s)${datasetTitles}
Applicants${applicants}
+
+ Message from ${firstname} ${lastname} +
+ + + + + + + +
${messageBody}
+ ${_displayDARLink(id)} +
+
+ ${questionWithAnswer.page} +
+ ${questionWithAnswer.questionPanel} +
+ + + + + + + + + +
Question${ + questionWithAnswer.question + }
Answer${ + questionWithAnswer.answer + }
+
+
`; + return body; +}; + const _generateMetadataOnboardingSumbitted = options => { let { name, publisher } = options; @@ -2250,6 +2357,7 @@ export default { generateTeamNotificationEmail: _generateTeamNotificationEmail, generateRemovedFromTeam: _generateRemovedFromTeam, generateAddedToTeam: _generateAddedToTeam, + generateNewDARMessage: _generateNewDARMessage, //Workflows generateWorkflowAssigned: _generateWorkflowAssigned, generateWorkflowCreated: _generateWorkflowCreated, diff --git a/src/resources/utilities/notificationBuilder.js b/src/resources/utilities/notificationBuilder.js index d89b7878..5bb081ad 100644 --- a/src/resources/utilities/notificationBuilder.js +++ b/src/resources/utilities/notificationBuilder.js @@ -11,7 +11,7 @@ const triggerNotificationMessage = (messageRecipients, messageDescription, messa messageID, messageObjectID: typeof messageObjectID == 'number' ? messageObjectID : messageID, messageTo: recipient, - messageDataRequestID: messageType === 'data access request' ? messageObjectID : null, + messageDataRequestID: messageType === 'data access request' || messageType === 'data access message sent' ? messageObjectID : null, publisherName, datasetID: messageType === 'dataset approved' || messageType === 'dataset rejected' ? messageObjectID : null, }); From b6224187f2fe20d18b261123366f6d5b0b224339 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Mon, 26 Jul 2021 14:07:28 +0100 Subject: [PATCH 75/81] Fix for when there is no answer --- 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 a64e97e9..4cc2ae83 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -2790,6 +2790,11 @@ export default class DataRequestController extends Controller { } } + const answer = + accessRecord.questionAnswers && accessRecord.questionAnswers[questionId] + ? accessRecord.questionAnswers[questionId] + : 'No answer for this question'; + this.createNotifications( constants.notificationTypes.MESSAGESENT, { @@ -2799,7 +2804,7 @@ export default class DataRequestController extends Controller { question: foundQuestion.question, questionPanel: foundQuestionSet.questionSetHeader, page: foundPage.title, - answer: accessRecord.questionAnswers[questionId] || '', + answer, }, }, accessRecord, From cbda24de497b71492d8d521f669c9cbf77462271 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Mon, 26 Jul 2021 14:44:38 +0100 Subject: [PATCH 76/81] Fixing LGTM issue --- src/resources/topic/topic.repository.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/resources/topic/topic.repository.js b/src/resources/topic/topic.repository.js index 810a0b86..31c593e8 100644 --- a/src/resources/topic/topic.repository.js +++ b/src/resources/topic/topic.repository.js @@ -9,25 +9,25 @@ export default class TopicRepository extends Repository { getTopicsForDAR(title, messageType) { return TopicModel.find({ - title, - messageType, + title: { $eq: title }, + messageType: { $eq: messageType }, }).lean(); } getTopicForDAR(title, subTitle, messageType) { return TopicModel.findOne({ - title, - subTitle, - messageType, + title: { $eq: title }, + subTitle: { $eq: subTitle }, + messageType: { $eq: messageType }, }).lean(); } createTopicForDAR(title, subTitle, messageType) { return TopicModel.create({ - title, - subTitle, + title: { $eq: title }, + subTitle: { $eq: subTitle }, createdDate: Date.now(), - messageType, + messageType: { $eq: messageType }, }); } } From 386be9ff4d91c9eb5973aadbc908b32685b480e4 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Thu, 29 Jul 2021 12:01:36 +0100 Subject: [PATCH 77/81] Updates to allow for counts to be injected for nested questions --- src/resources/datarequest/utils/datarequest.util.js | 12 ++++++------ src/resources/topic/topic.repository.js | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js index 8a4a3fe8..574ff45a 100644 --- a/src/resources/datarequest/utils/datarequest.util.js +++ b/src/resources/datarequest/utils/datarequest.util.js @@ -377,16 +377,16 @@ const injectMessagesAndNotesCount = (jsonSchema, messages, notes) => { } }); - jsonSchema.questionSets.forEach(questionPanel => { - if (questionPanel.questions.find(x => messageNotesArray.some(e => e.question === x.questionId))) { - let foundQuestions = questionPanel.questions.filter(x => messageNotesArray.some(e => e.question === x.questionId)); - foundQuestions.forEach(question => { - let messageNoteQuestion = messageNotesArray.find(x => x.question === question.questionId); + messageNotesArray.forEach(messageNoteQuestion => { + for (let questionPanel of jsonSchema.questionSets) { + let question = findQuestion(questionPanel.questions, messageNoteQuestion.question); + if (question) { question.counts = { messagesCount: messageNoteQuestion.messageCount, notesCount: messageNoteQuestion.notesCount, }; - }); + break; + } } }); diff --git a/src/resources/topic/topic.repository.js b/src/resources/topic/topic.repository.js index 31c593e8..e0d98f04 100644 --- a/src/resources/topic/topic.repository.js +++ b/src/resources/topic/topic.repository.js @@ -24,10 +24,10 @@ export default class TopicRepository extends Repository { createTopicForDAR(title, subTitle, messageType) { return TopicModel.create({ - title: { $eq: title }, - subTitle: { $eq: subTitle }, + title, + subTitle, createdDate: Date.now(), - messageType: { $eq: messageType }, + messageType, }); } } From bde2130be003ac05f982bd9941e3d772bdc0ce38 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Thu, 29 Jul 2021 12:19:43 +0100 Subject: [PATCH 78/81] Fix for email not finding nested questions page or panel correctly --- src/resources/datarequest/datarequest.controller.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/resources/datarequest/datarequest.controller.js b/src/resources/datarequest/datarequest.controller.js index 4cc2ae83..d42696d8 100644 --- a/src/resources/datarequest/datarequest.controller.js +++ b/src/resources/datarequest/datarequest.controller.js @@ -2771,14 +2771,11 @@ export default class DataRequestController extends Controller { foundPage = {}; for (let questionSet of accessRecord.jsonSchema.questionSets) { - for (let question of questionSet.questions) { - if (question.questionId === questionId) { - foundQuestion = question; - foundQuestionSet = questionSet; - break; - } + foundQuestion = datarequestUtil.findQuestion(questionSet.questions, questionId); + if (foundQuestion) { + foundQuestionSet = questionSet; + break; } - if (!_.isEmpty(foundQuestion)) break; } const panel = dynamicForm.findQuestionPanel(foundQuestionSet.questionSetId, accessRecord.jsonSchema.questionPanels); From a43d229cacccf32f1428b6bc12743da1a52d1cd4 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Thu, 29 Jul 2021 13:03:20 +0100 Subject: [PATCH 79/81] Removing the userQuestionActions code as it is no longer used --- .../dataset/utils/datasetonboarding.util.js | 24 +- src/resources/utilities/constants.util.js | 606 ------------------ 2 files changed, 3 insertions(+), 627 deletions(-) diff --git a/src/resources/dataset/utils/datasetonboarding.util.js b/src/resources/dataset/utils/datasetonboarding.util.js index d8834a88..26247e60 100644 --- a/src/resources/dataset/utils/datasetonboarding.util.js +++ b/src/resources/dataset/utils/datasetonboarding.util.js @@ -3,18 +3,6 @@ import constants from '../../utilities/constants.util'; import teamController from '../../team/team.controller'; import moment from 'moment'; -const injectQuestionActions = (jsonSchema, userType, applicationStatus, role = '') => { - let formattedSchema = {}; - if (userType === constants.userTypes.CUSTODIAN) { - formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus] }; - } else { - //let test = JSON.stringify(constants.userQuestionActions[userType][applicationStatus]); - //questionActions: [{"key":"guidance","icon":"far fa-question-circle","color":"#475da7","toolTip":"Guidance","order":1}] - formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][applicationStatus] }; - } - return formattedSchema; -}; - const getUserPermissionsForApplication = (application, userId, _id) => { try { let authorised = false, @@ -91,7 +79,7 @@ const findQuestion = (questionsArr, questionId) => { return typeof option.conditionalQuestions !== 'undefined' && option.conditionalQuestions.length > 0; }) .forEach(option => { - if(!child) { + if (!child) { child = findQuestion(option.conditionalQuestions, questionId); } }); @@ -178,15 +166,10 @@ const buildQuestionAlert = (userType, iterationStatus, completed, amendment, use updatedBy = matchCurrentUser(user, updatedBy); // 5. Update the generic question alerts to match the scenario let relevantActioner = !_.isNil(updatedBy) ? updatedBy : userType === constants.userTypes.CUSTODIAN ? requestedBy : publisher; - questionAlert.text = questionAlert.text.replace( - '#NAME#', - relevantActioner - ); + questionAlert.text = questionAlert.text.replace('#NAME#', relevantActioner); questionAlert.text = questionAlert.text.replace( '#DATE#', - userType === !_.isNil(dateUpdated) - ? moment(dateUpdated).format('Do MMM YYYY') - : moment(dateRequested).format('Do MMM YYYY') + userType === !_.isNil(dateUpdated) ? moment(dateUpdated).format('Do MMM YYYY') : moment(dateRequested).format('Do MMM YYYY') ); // 6. Return the built question alert return questionAlert; @@ -208,7 +191,6 @@ const matchCurrentUser = (user, auditField) => { }; export default { - injectQuestionActions: injectQuestionActions, getUserPermissionsForApplication: getUserPermissionsForApplication, extractApplicantNames: extractApplicantNames, findQuestion: findQuestion, diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js index 34688d8e..8de05536 100644 --- a/src/resources/utilities/constants.util.js +++ b/src/resources/utilities/constants.util.js @@ -67,611 +67,6 @@ const _questionActions = { }, }; -const _userQuestionActions = { - custodian: { - reviewer: { - submitted: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - inReview: { - custodian: { - latestVersion: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - previousVersion: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - }, - applicant: { - latestVersion: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - previousVersion: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - }, - }, - approved: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - ['approved with conditions']: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - rejected: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - withdrawn: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - }, - manager: { - submitted: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - inReview: { - custodian: { - latestVersion: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'requestAmendment', - icon: 'fas fa-exclamation-circle', - color: '#F0BB24', - toolTip: 'Request applicant updates answer', - order: 2, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 3, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 4, - }, - ], - previousVersion: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - }, - applicant: { - latestVersion: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - previousVersion: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - }, - }, - approved: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - ['approved with conditions']: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - rejected: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - withdrawn: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - }, - }, - applicant: { - inProgress: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - submitted: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - inReview: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - approved: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - ['approved with conditions']: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - rejected: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - withdrawn: [ - { - key: 'guidance', - icon: 'far fa-question-circle', - color: '#475da7', - toolTip: 'Guidance', - order: 1, - }, - { - key: 'messages', - icon: 'far fa-comment-alt', - color: '#475da7', - toolTip: 'Messages', - order: 2, - }, - { - key: 'notes', - icon: 'far fa-edit', - color: '#475da7', - toolTip: 'Notes', - order: 3, - }, - ], - }, -}; - const _navigationFlags = { custodian: { submitted: { @@ -848,7 +243,6 @@ export default { teamNotificationMessages: _teamNotificationMessages, teamNotificationTypesHuman: _teamNotificationTypesHuman, teamNotificationEmailContentTypes: _teamNotificationEmailContentTypes, - userQuestionActions: _userQuestionActions, questionActions: _questionActions, navigationFlags: _navigationFlags, amendmentStatuses: _amendmentStatuses, From c895d4c265cae6bafbfa7ed7f0a18bb2c6bfc711 Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Thu, 29 Jul 2021 16:05:36 +0100 Subject: [PATCH 80/81] Fix for broken test due to contextual messaging --- .../utils/__tests__/datarequest.util.test.js | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/resources/datarequest/utils/__tests__/datarequest.util.test.js b/src/resources/datarequest/utils/__tests__/datarequest.util.test.js index d9331270..8f3ce2e4 100644 --- a/src/resources/datarequest/utils/__tests__/datarequest.util.test.js +++ b/src/resources/datarequest/utils/__tests__/datarequest.util.test.js @@ -8,13 +8,16 @@ describe('injectQuestionActions', () => { // Arrange const data = _.cloneDeep(dataRequest); const guidance = { key: 'guidance', icon: 'far fa-question-circle', color: '#475da7', toolTip: 'Guidance', order: 1 }; + const messages = { key: 'messages', icon: 'far fa-comment-alt', color: '#475da7', toolTip: 'Messages', order: 2 }; + const notes = { key: 'messages', icon: 'far fa-edit', color: '#475da7', toolTip: 'Notes', order: 3 }; const requestAmendment = { key: 'requestAmendment', icon: 'fas fa-exclamation-circle', color: '#F0BB24', toolTip: 'Request applicant updates answer', - order: 2, + order: 4, }; + const cases = [ [ data[0].jsonSchema, @@ -22,7 +25,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.INPROGRESS, '', constants.userTypes.APPLICANT, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -30,7 +33,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.APPROVED, '', constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -38,7 +41,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.APPROVEDWITHCONDITIONS, '', constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -46,7 +49,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.INREVIEW, '', constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -54,7 +57,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.WITHDRAWN, '', constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -62,7 +65,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.SUBMITTED, '', constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -70,7 +73,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.APPROVED, constants.roleTypes.MANAGER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -78,7 +81,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.APPROVEDWITHCONDITIONS, constants.roleTypes.MANAGER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -86,7 +89,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.INREVIEW, constants.roleTypes.MANAGER, constants.userTypes.CUSTODIAN, - [guidance, requestAmendment], + [guidance, messages, notes, requestAmendment], ], [ data[0].jsonSchema, @@ -94,7 +97,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.INREVIEW, constants.roleTypes.MANAGER, constants.userTypes.APPLICANT, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -102,7 +105,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.WITHDRAWN, constants.roleTypes.MANAGER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -110,7 +113,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.SUBMITTED, constants.roleTypes.MANAGER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -118,7 +121,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.APPROVED, constants.roleTypes.REVIEWER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -126,7 +129,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.APPROVEDWITHCONDITIONS, constants.roleTypes.REVIEWER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -134,7 +137,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.INREVIEW, constants.roleTypes.REVIEWER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -142,7 +145,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.WITHDRAWN, constants.roleTypes.REVIEWER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], [ data[0].jsonSchema, @@ -150,7 +153,7 @@ describe('injectQuestionActions', () => { constants.applicationStatuses.SUBMITTED, constants.roleTypes.REVIEWER, constants.userTypes.CUSTODIAN, - [guidance], + [guidance, messages, notes], ], ]; test.each(cases)( From 4c7411d1f59e0244929c29afa5e433ceafb9ad0d Mon Sep 17 00:00:00 2001 From: Paul McCafferty <> Date: Thu, 29 Jul 2021 16:54:02 +0100 Subject: [PATCH 81/81] Fixing typo --- .../datarequest/utils/__tests__/datarequest.util.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/datarequest/utils/__tests__/datarequest.util.test.js b/src/resources/datarequest/utils/__tests__/datarequest.util.test.js index 8f3ce2e4..b61bc78e 100644 --- a/src/resources/datarequest/utils/__tests__/datarequest.util.test.js +++ b/src/resources/datarequest/utils/__tests__/datarequest.util.test.js @@ -9,7 +9,7 @@ describe('injectQuestionActions', () => { const data = _.cloneDeep(dataRequest); const guidance = { key: 'guidance', icon: 'far fa-question-circle', color: '#475da7', toolTip: 'Guidance', order: 1 }; const messages = { key: 'messages', icon: 'far fa-comment-alt', color: '#475da7', toolTip: 'Messages', order: 2 }; - const notes = { key: 'messages', icon: 'far fa-edit', color: '#475da7', toolTip: 'Notes', order: 3 }; + const notes = { key: 'notes', icon: 'far fa-edit', color: '#475da7', toolTip: 'Notes', order: 3 }; const requestAmendment = { key: 'requestAmendment', icon: 'fas fa-exclamation-circle',