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`
diff --git a/migrations/1620558117918-applications_versioning.js b/migrations/1620558117918-applications_versioning.js
new file mode 100644
index 00000000..652b97ff
--- /dev/null
+++ b/migrations/1620558117918-applications_versioning.js
@@ -0,0 +1,62 @@
+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
+ // 2. Add version 1 to all applications
+ // 3. Create version tree for all applications
+
+ 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: constants.submissionTypes.INITIAL,
+ majorVersion: 1.0,
+ version: undefined,
+ versionTree,
+ },
+ upsert: false,
+ },
+ });
+ });
+
+ await DataRequestModel.bulkWrite(ops);
+}
+
+async function down() {
+ // 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,
+ majorVersion: undefined,
+ version: 1,
+ versionTree: undefined,
+ },
+ upsert: false,
+ },
+ });
+ });
+
+ await DataRequestModel.bulkWrite(ops);
+}
+
+module.exports = { up, down };
diff --git a/package.json b/package.json
index 92c6008c..7c0efeac 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "hdruk-rdt-api",
"config": {
"mongodbMemoryServer": {
- "version": "latest"
+ "version": "5.0.0-rc7"
}
},
"version": "0.1.1",
diff --git a/src/config/server.js b/src/config/server.js
index 48026c72..1d01c050 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);
@@ -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/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/course/course.repository.js b/src/resources/course/course.repository.js
index 28cb8464..32f81d0b 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([
@@ -435,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);
});
}
@@ -456,17 +478,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 course ${tool.name}`, html);
});
}
diff --git a/src/resources/datarequest/amendment/__tests__/amendments.test.js b/src/resources/datarequest/amendment/__tests__/amendments.test.js
index 7f10c286..9620dc49 100755
--- a/src/resources/datarequest/amendment/__tests__/amendments.test.js
+++ b/src/resources/datarequest/amendment/__tests__/amendments.test.js
@@ -1,7 +1,8 @@
import constants from '../../../utilities/constants.util';
+import DataRequestClass from '../../datarequest.entity';
+import { amendmentService } from '../dependency';
import _ from 'lodash';
-const amendmentController = require('../amendment.controller');
const dataRequest = require('../../__mocks__/datarequest');
const users = require('../../__mocks__/users');
@@ -23,7 +24,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 +51,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 +82,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 +124,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 +157,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 +187,7 @@ describe('getCurrentAmendmentIteration', () => {
},
};
// Act
- const result = amendmentController.getCurrentAmendmentIteration(data.amendmentIterations);
+ const result = amendmentService.getCurrentAmendmentIteration(data.amendmentIterations);
// Assert
expect(result).toEqual(expected);
});
@@ -197,7 +198,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 +209,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 +220,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 +259,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 +275,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 +293,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 +321,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 +342,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 +358,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 +378,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 +397,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 +410,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 +422,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 +433,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 +445,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 +454,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 +464,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 +476,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 +491,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 +501,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 +511,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 +519,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]);
});
@@ -528,26 +529,27 @@ 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 = amendmentController.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);
});
});
-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 = amendmentController.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 +558,7 @@ describe('countUnsubmittedAmendments', () => {
// Arrange
let data = _.cloneDeep(dataRequest[6]);
// Act
- const result = amendmentController.countUnsubmittedAmendments(data, constants.userTypes.APPLICANT);
+ const result = amendmentService.countAmendments(data, constants.userTypes.APPLICANT);
// Assert
expect(result.unansweredAmendments).toBe(0);
expect(result.answeredAmendments).toBe(0);
@@ -577,7 +579,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 +593,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 +604,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 +614,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 +636,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 +647,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 +659,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 +671,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..650db9ea 100644
--- a/src/resources/datarequest/amendment/amendment.controller.js
+++ b/src/resources/datarequest/amendment/amendment.controller.js
@@ -1,826 +1,269 @@
-import { DataRequestModel } from '../datarequest.model';
-import { AmendmentModel } from './amendment.model';
+import _ from 'lodash';
+
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 { logger } from '../../utilities/logger';
-import _ from 'lodash';
+const logCategory = 'Data Access Request';
-//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,
- },
- });
- }
- });
- } catch (err) {
- console.error(err.message);
- return res.status(500).json({
- success: false,
- message: 'An error occurred updating the application amendment',
- });
+export default class AmendmentController extends Controller {
+ constructor(amendmentService, dataRequestService) {
+ super(amendmentService);
+ this.amendmentService = amendmentService;
+ this.dataRequestService = dataRequestService;
}
-};
-//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([
- {
- 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.' });
- }
- // 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,
+ async setAmendment(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, 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 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];
- }
-};
+ // 2. Retrieve DAR from database
+ const accessRecord = await this.dataRequestService.getApplicationWithTeamById(id);
+ if (!accessRecord) {
+ return res.status(404).json({ status: 'error', message: 'Application not found.' });
+ }
-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);
- }
- return accessRecord;
+ // 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.',
+ });
}
- } 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);
- });
- }
-};
+ // 4. Get the requesting users permission levels
+ let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(
+ accessRecord.toObject(),
+ requestingUserId,
+ requestingUserObjectId
+ );
-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);
-};
+ // 5. Get the current iteration amendment party
+ let validParty = false;
+ const activeParty = this.amendmentService.getAmendmentIterationParty(accessRecord);
-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;
- }
+ // 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;
+ }
+ }
- 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;
-};
+ // 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' });
+ }
-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();
- });
-};
+ // 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.',
+ });
+ }
-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;
- }
-};
+ // 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 and retain previous version requested updates
+ let accessRecordObj = accessRecord.toObject();
-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);
- }
- return iteration;
- });
- }
- // 2. Return relevant iterations
- return amendmentIterations;
-};
+ // 11. Support for versioning
+ if (accessRecordObj.amendmentIterations.length > 0) {
-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;
-};
+ // Detemine which versions to return
+ let currentVersionIndex;
+ let previousVersionIndex;
+ const unreleasedVersionIndex = accessRecordObj.amendmentIterations.findIndex(iteration => _.isNil(iteration.dateReturned));
-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;
-};
+ if (unreleasedVersionIndex === -1) {
+ currentVersionIndex = accessRecordObj.amendmentIterations.length - 1;
+ } else {
+ currentVersionIndex = accessRecordObj.amendmentIterations.length - 2;
+ }
+ previousVersionIndex = currentVersionIndex - 1;
-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;
-};
+ // 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);
+ }
-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;
-};
+ // Inject updates from previous version
+ accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user, previousVersionIndex, true);
-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;
- }, []);
+ // Inject updates from current version
+ accessRecordObj = this.amendmentService.injectAmendments(accessRecordObj, userType, req.user, currentVersionIndex, true);
- if (_.isEmpty(latestAnswers)) {
- return undefined;
- } else {
- return latestAnswers[0].answer;
- }
-};
+ // Inject updates from possible unreleased version
+ 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);
+ }
-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 };
- }
- });
- 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 };
-};
+ // 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,
+ userType,
+ accessRecordObj.applicationStatus,
+ userRole,
+ activeParty
+ );
-const getCurrentAmendmentIteration = amendmentIterations => {
- // 1. Guard for incorrect type passed
- if (_.isEmpty(amendmentIterations) || _.isNull(amendmentIterations) || _.isUndefined(amendmentIterations)) {
- return undefined;
+ // 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,
+ accessRecord: {
+ amendmentIterations: accessRecordObj.amendmentIterations,
+ questionAnswers: accessRecordObj.questionAnswers,
+ jsonSchema: accessRecordObj.jsonSchema,
+ answeredAmendments,
+ unansweredAmendments,
+ },
+ });
+ }
+ });
+ } 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',
+ });
+ }
}
- // 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;
-};
+ async requestAmendments(req, res) {
+ try {
+ // 1. Get the required request params
+ const {
+ params: { id },
+ } = req;
+ const requestingUserObjectId = req.user._id;
-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;
-};
+ // 2. Retrieve DAR from database
+ let accessRecord = await this.dataRequestService.getApplicationForUpdateRequest(id);
-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++;
- }
- });
- // 3. Return counts
- return { unansweredAmendments, answeredAmendments };
-};
+ if (!accessRecord) {
+ return res.status(404).json({ status: 'error', message: 'Application not found.' });
+ }
-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 };
- }
-};
+ // 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);
+ }
+ if (!authorised) {
+ return res.status(401).json({ status: 'failure', message: 'Unauthorised' });
+ }
-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 };
- });
- }
+ // 4. Ensure single datasets are mapped correctly into array (backward compatibility for single dataset applications)
+ if (_.isEmpty(accessRecord.datasets)) {
+ accessRecord.datasets = [accessRecord.dataset];
+ }
- 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
- );
+ // 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.',
+ });
+ }
- // 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
- );
+ // 6. Check some amendments exist to be submitted to the applicant(s)
+ const { unansweredAmendments } = this.amendmentService.countAmendments(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',
+ });
}
- // 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;
- }
-};
+ // 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 = requestingUserObjectId;
-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;
+ // 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({
+ 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 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..babc1a0b
--- /dev/null
+++ b/src/resources/datarequest/amendment/amendment.service.js
@@ -0,0 +1,726 @@
+import _ from 'lodash';
+import moment from 'moment';
+
+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';
+import dynamicForm from '../../utilities/dynamicForms/dynamicForm.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.countAmendments(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, 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));
+ // 2. Deduce the user type from the current iteration state
+ if (index === -1 && accessRecord.applicationStatus !== constants.applicationStatuses.INPROGRESS) {
+ return constants.userTypes.CUSTODIAN;
+ } else {
+ return constants.userTypes.APPLICANT;
+ }
+ } else {
+ return this.getAmendmentIterationPartyByVersion(accessRecord, versionIndex);
+ }
+ }
+
+ 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[versionIndex];
+ if (requestedAmendmentIteration === _.last(accessRecord.amendmentIterations)) {
+ if (
+ !requestedAmendmentIteration ||
+ (_.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;
+ }
+ }
+
+ getAmendmentIterationDetailsByVersion(accessRecord, minorVersion) {
+ const { amendmentIterations = [] } = accessRecord;
+
+ // 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));
+
+ 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) || lastIterationIndex === -1) {
+ return [];
+ }
+ let { amendmentIterations = [] } = accessRecord;
+
+ // 2. Slice any superfluous amendment iterations if a previous version has been explicitly requested
+ if (!_.isNil(lastIterationIndex) && lastIterationIndex > -1) {
+ amendmentIterations = amendmentIterations.slice(0, lastIterationIndex + 1);
+ }
+
+ // 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 => {
+ 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;
+ });
+ }
+
+ // 4. Return relevant iterations
+ return amendmentIterations;
+ }
+
+ 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) {
+ return accessRecord;
+ }
+
+ // 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];
+ }
+
+ // 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;
+
+ // 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))
+ ) {
+ latestIteration = accessRecord.amendmentIterations[versionIndex - 1];
+ } else if (versionIndex === 0 && userType === constants.userTypes.APPLICANT && _.isNil(dateReturned)) {
+ return accessRecord;
+ }
+
+ // 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);
+ }
+
+ // 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);
+
+ // 8. Update the question answers to reflect all the changes that have been made in later iterations
+ accessRecord.questionAnswers = this.formatQuestionAnswers(accessRecord.questionAnswers, amendmentIterations);
+
+ // 9. Return the updated access record
+ return accessRecord;
+ }
+
+ formatSchema(jsonSchema, amendmentIteration, userType, user, publisher, includeCompleted = true) {
+ const { questionAnswers = {}, dateSubmitted, dateReturned } = amendmentIteration;
+ if (_.isEmpty(questionAnswers) || (userType === constants.userTypes.APPLICANT && _.isNil(dateReturned))) {
+ return jsonSchema;
+ }
+ // Loop through each amendment
+ for (let questionId in questionAnswers) {
+ const { questionSetId, answer, updatedBy } = questionAnswers[questionId];
+ // 1. Update parent/child navigation with flags for amendments
+ 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);
+ // 2. Update questions with alerts/actions
+ jsonSchema = this.injectQuestionAmendment(
+ jsonSchema,
+ questionId,
+ questionAnswers[questionId],
+ userType,
+ amendmentCompleted,
+ iterationStatus,
+ user,
+ publisher,
+ includeCompleted
+ );
+ }
+ return jsonSchema;
+ }
+
+ 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);
+ 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,
+ includeCompleted
+ );
+ // 4. Update question to contain amendment state
+ 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);
+ // 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.amendmentIterations[index].applicationType = constants.submissionTypes.RESUBMISSION;
+ accessRecord.submitAmendmentIteration(index, userId);
+ // 3. Return updated access record for saving
+ return accessRecord;
+ }
+
+ 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 (
+ !isLatestVersion ||
+ 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;
+ }
+
+ 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;
+ 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 406e3dde..d42696d8 100644
--- a/src/resources/datarequest/datarequest.controller.js
+++ b/src/resources/datarequest/datarequest.controller.js
@@ -1,55 +1,67 @@
-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';
+import _ from 'lodash';
+import moment from 'moment';
+import mongoose from 'mongoose';
import teamController from '../team/team.controller';
-import workflowController from '../workflow/workflow.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 { processFile, getFile, fileStatus } from '../utilities/cloudStorage.util';
-import _ from 'lodash';
+import { getFile, fileStatus } from '../utilities/cloudStorage.util';
import inputSanitizer from '../utilities/inputSanitizer';
+import Controller from '../base/controller';
+import { logger } from '../utilities/logger';
+import { UserModel } from '../user/user.model';
-import moment from 'moment';
-import mongoose from 'mongoose';
-
-const amendmentController = require('./amendment/amendment.controller');
+const logCategory = 'Data Access Request';
const bpmController = require('../bpmnworkflow/bpmnworkflow.controller');
-module.exports = {
+export default class DataRequestController extends Controller {
+ 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 #######
+
//GET api/v1/data-access-request
- getAccessRequestsByUser: async (req, res) => {
+ async getAccessRequestsByUser(req, res) {
try {
- // 1. Deconstruct the parameters passed
- let { id: userId } = req.user;
+ // Deconstruct the parameters passed
let { query = {} } = req;
+ const requestingUserId = 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(requestingUserId, 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 => {
+ 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.versions = this.dataRequestService.buildVersionHistory(
+ accessRecord.versionTree,
+ accessRecord._id,
+ null,
+ constants.userTypes.APPLICANT
+ );
+ accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.APPLICANT);
+ return accessRecord;
})
.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,90 +69,138 @@ 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',
});
}
- },
+ }
- //GET api/v1/data-access-request/:requestId
- getAccessRequestById: async (req, res) => {
+ //GET api/v1/data-access-request/:id
+ async getAccessRequestById(req, res) {
try {
// 1. Get dataSetId from params
- let {
- params: { requestId },
+ const {
+ params: { id },
} = req;
- // 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' },
- ]);
- // 3. If no matching application found, return 404
+ 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: 'Application not found.' });
- } else {
- accessRecord = accessRecord.toObject();
+ return res.status(404).json({ status: 'error', message: 'The application could not be found.' });
}
- // 4. Ensure single datasets are mapped correctly into array
- if (_.isEmpty(accessRecord.datasets)) {
- accessRecord.datasets = [accessRecord.dataset];
+
+ // 3. 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.' });
}
+
+ // 4. Get requested amendment iteration details
+ const { versionIndex, activeParty, isLatestMinorVersion } = this.amendmentService.getAmendmentIterationDetailsByVersion(
+ accessRecord,
+ requestedMinorVersion
+ );
+
// 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;
+ 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 = amendmentController.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);
- // 9. Get the workflow/voting status
- let workflow = workflowController.getWorkflowStatus(accessRecord);
- let isManager = false;
- // 10. 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);
- // Set the workflow override capability if there is an active step and user is a manager
- if (!_.isEmpty(workflow)) {
- workflow.canOverrideStep = !workflow.isCompleted && isManager;
+ 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
+ const countAmendments = this.amendmentService.countAmendments(accessRecord, userType, isLatestMinorVersion);
+
+ // 8. Get the workflow status for the requested application version for the requesting user
+ 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. 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. Update json schema and question answers with modifications since original submission
- accessRecord = amendmentController.injectAmendments(accessRecord, userType, req.user);
- // 12. Determine the current active party handling the form
- let activeParty = amendmentController.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;
+
+ // 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);
+
+ // 12. Inject updates for current version
+ accessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser, versionIndex, true);
+
+ // 13. Inject updates from any unreleased version e.g. 1.2
+ accessRecord = this.amendmentService.injectAmendments(
+ accessRecord,
+ userType,
+ requestingUser,
+ versionIndex + 1,
+ isLatestMinorVersion,
+ false
+ );
+
+ // 14. Append question actions depending on user type and application status
accessRecord.jsonSchema = datarequestUtil.injectQuestionActions(
- accessRecord.jsonSchema,
+ jsonSchema,
userType,
- accessRecord.applicationStatus,
+ applicationStatus,
userRole,
- activeParty
+ activeParty,
+ isLatestMinorVersion
);
- // 14. Return application form
+
+ // 15. Inject message and note counts
+ 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);
+ }
+
+ // 16. Build version selector
+ const requestedFullVersion = `${requestedMajorVersion}.${
+ _.isNil(requestedMinorVersion) ? accessRecord.amendmentIterations.length : requestedMinorVersion
+ }`;
+ accessRecord.versions = this.dataRequestService.buildVersionHistory(versionTree, accessRecord._id, requestedFullVersion, userType);
+
+ // 17. Return application form
return res.status(200).json({
status: 'success',
data: {
...accessRecord,
datasets: accessRecord.datasets,
- readOnly,
- ...countUnsubmittedAmendments,
+ ...countAmendments,
userType,
activeParty,
projectId: accessRecord.projectId || helper.generateFriendlyId(accessRecord._id),
@@ -149,205 +209,80 @@ module.exports = {
hasRecommended,
workflow,
files: accessRecord.files || [],
+ isLatestMinorVersion,
},
});
} catch (err) {
- console.error(err.message);
- res.status(500).json({ status: 'error', message: err.message });
- }
- },
-
- //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 || [],
- },
+ // 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',
});
- } 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 = [];
+ async getAccessRequestByUserAndMultipleDatasets(req, res) {
try {
+ let data = {};
// 1. Get datasetIds from params
- let {
- params: { datasetIds },
+ const {
+ params: { datasetIds, dataSetId },
} = req;
- let arrDatasetIds = datasetIds.split(',');
- // 2. Get the userId
- let { id: userId, firstname, lastname } = req.user;
+ const resolvedIds = datasetIds || dataSetId;
+ const arrDatasetIds = resolvedIds.split(',');
+
+ // 2. Get the user details
+ const { id: requestingUserId, 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 });
+ let accessRecord = await this.dataRequestService.getApplicationByDatasets(
+ arrDatasetIds,
+ constants.applicationStatuses.INPROGRESS,
+ requestingUserId
+ );
+
// 4. Get datasets
- datasets = await ToolModel.find({
- datasetid: { $in: arrDatasetIds },
- }).populate('publisher');
+ const datasets = await this.dataRequestService.getDatasetsForApplicationByIds(arrDatasetIds);
const arrDatasetNames = datasets.map(dataset => dataset.name);
- // 5. If no record create it and pass back
- if (!accessRecord) {
+
+ // 5. If in progress application found use existing endpoint to handle logic to fetch and return
+ if (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.' });
}
- let {
+ const {
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 });
+ accessRecord = await this.dataRequestService.buildApplicationForm(publisher, arrDatasetIds, arrDatasetNames, requestingUserId);
+
// 2. Ensure a question set was found
- if (!accessRequestTemplate) {
+ if (!accessRecord) {
return res.status(400).json({
status: 'error',
- message: 'No Data Access request schema.',
+ message: 'Application form could not be created',
});
}
- // 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,
+
+ // 3. Create and save new application
+ const newApplication = await this.dataRequestService.createApplication(accessRecord).catch(err => {
+ logger.logError(err, logCategory);
});
- // 6. save record
- const newApplication = await record.save();
- newApplication.projectId = helper.generateFriendlyId(newApplication._id);
- await newApplication.save();
- // 7. return record
+
+ // 4. Set return data
data = {
...newApplication._doc,
mainApplicant: { firstname, lastname },
};
- } else {
- data = { ...accessRecord.toObject() };
}
- // 8. Append question actions depending on user type and application status
+
+ // 6. Append question actions depending on user type and application status
data.jsonSchema = datarequestUtil.injectQuestionActions(
data.jsonSchema,
constants.userTypes.APPLICANT,
@@ -355,7 +290,8 @@ module.exports = {
null,
constants.userTypes.APPLICANT
);
- // 9. Return payload
+
+ // 7. Return payload
return res.status(200).json({
status: 'success',
data: {
@@ -370,171 +306,237 @@ 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 a data access request application for the requested dataset(s)',
+ });
+ }
+ }
+
+ //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
+ const {
+ params: { id },
+ } = req;
+ const requestingUser = req.user;
+ 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);
+
+ 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,
+ requestingUserId,
+ 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. Perform either initial submission or resubmission depending on application status
+ if (accessRecord.applicationStatus === constants.applicationStatuses.INPROGRESS) {
+ 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 (
+ accessRecord.applicationStatus === constants.applicationStatuses.INREVIEW ||
+ accessRecord.applicationStatus === constants.applicationStatuses.SUBMITTED
+ ) {
+ 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
+ if (_.isNil(accessRecord.applicationType)) {
+ 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 this.dataRequestService.replaceApplicationById(id, accessRecord).catch(err => {
+ logger.logError(err, logCategory);
+ });
+
+ // 8. Inject amendments from minor versions
+ savedAccessRecord = this.amendmentService.injectAmendments(accessRecord, userType, requestingUser);
+
+ // 9. Send notifications
+ await this.createNotifications(notificationType, {}, accessRecord.toObject(), requestingUser);
+
+ // 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 },
+ dateSubmitted,
+ } = accessRecord;
+ let bpmContext = {
+ dateSubmitted,
+ applicationStatus: constants.applicationStatuses.SUBMITTED,
+ publisher,
+ businessKey: id,
+ };
+ bpmController.postStartPreReview(bpmContext);
+ }
+
+ // 11. 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',
+ });
}
- },
+ }
//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 {
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 = module.exports.buildUpdateObject({
+ let updateObj = this.dataRequestService.buildUpdateObject({
...data,
- user: req.user,
+ user: requestingUser,
});
// 3. Find data request by _id to determine current status
- let accessRequestRecord = await DataRequestModel.findOne({
- _id: id,
- });
+ let accessRecord = await this.dataRequestService.getApplicationToUpdateById(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 = amendmentController.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 this.dataRequestService.updateApplication(accessRecord, updateObj).catch(err => {
+ logger.logError(err, logCategory);
});
- } catch (err) {
- console.error(err.message);
- res.status(500).json({ status: 'error', message: err.message });
- }
- },
-
- 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: [] }
- );
+ const { unansweredAmendments = 0, answeredAmendments = 0, dirtySchema = false } = accessRecord;
+
+ 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;
+ } else {
+ currentVersionIndex = accessRecord.amendmentIterations.length - 2;
+ }
+ previousVersionIndex = currentVersionIndex - 1;
- updateObj = { aboutApplication, datasetIds, datasetTitles };
- }
- if (questionAnswers) {
- updateObj = { ...updateObj, questionAnswers, updatedQuestionId, user };
- }
+ // Inject updates from previous version
+ accessRecord = this.amendmentService.injectAmendments(
+ accessRecord,
+ constants.userTypes.APPLICANT,
+ requestingUser,
+ previousVersionIndex,
+ true
+ );
- if (!_.isEmpty(jsonSchema)) {
- updateObj = { ...updateObj, jsonSchema };
- }
+ // Inject updates from current version
+ accessRecord = this.amendmentService.injectAmendments(
+ accessRecord,
+ constants.userTypes.APPLICANT,
+ requestingUser,
+ currentVersionIndex,
+ true
+ );
- return updateObj;
- },
-
- 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;
+ // Inject updates from possible unreleased version
+ if (unreleasedVersionIndex !== -1) {
+ accessRecord = this.amendmentService.injectAmendments(
+ accessRecord,
+ constants.userTypes.APPLICANT,
+ requestingUser,
+ unreleasedVersionIndex,
+ true,
+ false
+ );
+ }
}
+ }
+ // 7. Return new data object
+ return res.status(200).json({
+ status: 'success',
+ unansweredAmendments,
+ answeredAmendments,
+ jsonSchema: dirtySchema ? accessRecord.jsonSchema : undefined,
});
- return accessRecord;
- // 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 = amendmentController.handleApplicantAmendment(accessRecord.toObject(), updatedQuestionId, '', updatedAnswer, user);
- await DataRequestModel.replaceOne({ _id }, accessRecord, err => {
- if (err) {
- console.error(err.message);
- throw err;
- }
+ } 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',
});
- return accessRecord;
}
- },
+ }
//PUT api/v1/data-access-request/:id
- updateAccessRequestById: async (req, res) => {
+ 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
- let { _id, id: userId } = req.user;
+ 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 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',
- },
- ]);
-
+ let accessRecord = await this.dataRequestService.getApplicationWithWorkflowById(id, { lean: false });
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
+ // 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(), userId, _id);
+ let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(
+ accessRecord.toObject(),
+ requestingUserId,
+ requestingUserObjectId
+ );
if (!authorised) {
return res.status(401).json({
@@ -546,21 +548,14 @@ module.exports = {
let { authorIds: currentAuthors } = accessRecord;
let newAuthors = [];
- // 6. Extract new application status and desc to save updates
+ // 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;
- let team = {};
- if (_.isNull(accessRecord.publisherObj)) {
- ({ team = {} } = accessRecord.datasets[0].publisher.toObject());
- } else {
- ({ team = {} } = accessRecord.publisherObj.toObject());
- }
-
+ const { team = {} } = accessRecord.publisherObj.toObject();
if (!_.isEmpty(team)) {
- authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, _id);
+ authorised = teamController.checkTeamPermissions(constants.roleTypes.MANAGER, team, requestingUserObjectId);
}
-
if (!authorised) {
return res.status(401).json({ status: 'failure', message: 'Unauthorised' });
}
@@ -619,360 +614,473 @@ module.exports = {
}
}
}
- // 7. If a change has been made, notify custodian and main applicant
+ // 6. 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);
- }
- }
+ 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) {
+ //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,
+ { 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);
+ }
}
- // 8. Return application
+ // 7. 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',
+ // 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',
});
}
- },
+ }
- //PUT api/v1/data-access-request/:id/assignworkflow
- assignWorkflow: async (req, res) => {
+ //DELETE api/v1/data-access-request/:id
+ async deleteDraftAccessRequest(req, res) {
try {
- // 1. Get the required request params
+ // 1. Get the required request and body params
const {
- params: { id },
+ params: { id: appIdToDelete },
} = 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',
+ const requestingUser = req.user;
+ const requestingUserId = parseInt(req.user.id);
+ const requestingUserObjectId = req.user._id;
+
+ // 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
+ );
+
+ // 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',
});
}
- // 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',
- },
- },
- },
+
+ // 6. Delete application
+ await this.dataRequestService.deleteApplication(appToDelete).catch(err => {
+ logger.logError(err, logCategory);
});
- if (!accessRecord) {
+
+ // 7. Create notifications
+ await this.createNotifications(constants.notificationTypes.APPLICATIONDELETED, {}, appToDelete, requestingUser);
+
+ 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 deleting the existing application',
+ });
+ }
+ }
+
+ // ###### 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;
+ const { version: requestedVersion } = req.query;
+
+ // 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. 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);
+
+ // 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.' });
}
- // 5. Refuse access if not authorised
- if (!authorised) {
+
+ // 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);
+
+ // 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' });
}
- // 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. Update question answers with modifications since original submission
+ appToClone = this.amendmentService.injectAmendments(appToClone, constants.userTypes.APPLICANT, requestingUser, versionIndex);
+
+ // 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, {
+ 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);
+ });
}
- // 7. Check no workflow already assigned
- let { workflowId: currentWorkflowId = '' } = accessRecord;
- if (!_.isEmpty(currentWorkflowId)) {
+ // 9. Create notifications
+ await this.createNotifications(
+ constants.notificationTypes.APPLICATIONCLONED,
+ { newDatasetTitles: datasetTitles, newApplicationId: clonedAccessRecord._id.toString() },
+ appToClone,
+ requestingUser
+ );
+
+ // 10. 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 {
+ // 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: 'This application already has a workflow assigned',
+ message: 'You must supply the unique identifiers for the question to perform an action',
});
}
- // 8. Check application is in-review
- let { applicationStatus } = accessRecord;
- if (applicationStatus !== constants.applicationStatuses.INREVIEW) {
+
+ // 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: 'The application status must be set to in review to assign a workflow',
+ message: 'This application is no longer in pre-submission status and therefore this action cannot be performed',
});
}
- // 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 });
+ // 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' });
}
- // 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);
+
+ // 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: 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: workflowController.calculateStepDeadlineReminderDate(workflowObj.steps[0]),
- reviewerList,
- };
- bpmController.postStartStepReview(bpmContext);
- // 14. Gather context for notifications
- const emailContext = workflowController.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,
+ 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) {
- 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',
+ message: 'An error occurred updating the application amendment',
});
}
- },
+ }
- //PUT api/v1/data-access-request/:id/startreview
- updateAccessRequestStartReview: async (req, res) => {
+ //POST api/v1/data-access-request/:id/amend
+ async createAmendment(req, res) {
try {
- // 1. Get the required request params
+ // 1. Get dataSetId from 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',
- },
- });
+ 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: 'Application not found.' });
+ return res.status(404).json({ status: 'error', message: 'The application could not be 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) {
+
+ // 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' });
}
- // 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',
- });
+
+ // 4. If invalid version requested, return 404
+ 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.' });
}
- // 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);
- }
- }
+
+ // 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
+ let newAccessRecord = await this.dataRequestService.createAmendment(accessRecord).catch(err => {
+ logger.logError(err, logCategory);
+ });
+
+ if (!newAccessRecord) {
+ return res.status(400).json({ status: 'error', message: 'Creating application amendment failed' });
+ }
+
+ // 9. Get amended application (new major version) with all details populated
+ newAccessRecord = await this.dataRequestService.getApplicationById(newAccessRecord._id);
+
+ // 10. Return successful response and version details
+ return res.status(201).json({
+ status: 'success',
+ data: {
+ _id: newAccessRecord._id,
+ newVersion: newAccessRecord.majorVersion,
+ },
});
- // 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 });
+ // 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
- uploadFiles: async (req, res) => {
+ async uploadFiles(req, res) {
try {
- // 1. get DAR ID
+ // 1. Get DAR ID
const {
params: { id },
} = req;
- // 2. get files
+ const requestingUserId = parseInt(req.user.id);
+ const requestingUserObjectId = req.user._id;
+ // 2. Get files
let files = req.files;
- // 3. descriptions and uniqueIds file from FE
+ // 3. Descriptions and uniqueIds file from FE
let { descriptions, ids } = req.body;
- // 4. get access record
- let accessRecord = await DataRequestModel.findOne({ _id: id });
+ // 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.' });
}
// 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
+ let { authorised } = datarequestUtil.getUserPermissionsForApplication(accessRecord, requestingUserId, requestingUserObjectId);
+ // 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;
- });
- // 10. return response
+ // 8. Upload files
+ const mediaFiles = await this.dataRequestService
+ .uploadFiles(accessRecord, files, descriptions, ids, requestingUserObjectId)
+ .catch(err => {
+ logger.logError(err, logCategory);
+ });
+ // 9. 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 });
+ // Return error response if something goes wrong
+ logger.logError(err, logCategory);
+ return res.status(500).json({
+ success: false,
+ message: 'An error occurred uploading the file to the application',
+ });
}
- },
+ }
//GET api/v1/data-access-request/:id/file/:fileId/status
- getFileStatus: async (req, res) => {
+ async getFileStatus(req, res) {
try {
// 1. get params
const {
@@ -980,7 +1088,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.' });
}
@@ -992,263 +1100,299 @@ 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, { lean: false });
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',
message: 'No file to download, please try again later',
});
}
- // 6. get the name of the file
- let { name, fileId: dbFileId } = mediaFile._doc;
- // 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}`);
+ // 6. get the name of the file
+ let { name, fileId: dbFileId } = mediaFile;
+ // 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}${initialApplicationId}/${dbFileId}_${name}`);
+ } 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 retrieve an uploaded file',
+ });
+ }
+ }
+
+ //POST api/v1/data-access-request/:id/updatefilestatus
+ async updateFileStatus(req, res) {
+ try {
+ // 1. Get the required request params
+ const {
+ params: { id, fileId },
+ } = req;
+
+ 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 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. Update all versions of application using version tree
+ await this.dataRequestService.updateFileStatus(accessRecord, fileId).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.' });
+ }
+
+ // 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. 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);
+ });
+
+ // 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/vote
- updateAccessRequestReviewVote: async (req, res) => {
+ // ###### WORKFLOW #######
+
+ //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;
- let { approved, comments = '' } = req.body;
- if (_.isUndefined(approved) || _.isEmpty(comments)) {
+ const requestingUser = 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 approved status with a reason',
+ 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 reviewer of associated team
+
+ // 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.REVIEWER, 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
- 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 to cast a vote',
+ message: 'This custodian has not enabled workflows',
});
}
- // 6. Ensure a workflow has been attached to this application
- let { workflow } = accessRecord;
- if (!workflow) {
+
+ // 6. Check no workflow already assigned
+ const { workflowId: currentWorkflowId = '' } = accessRecord;
+ if (!_.isEmpty(currentWorkflowId)) {
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 already has a workflow assigned',
});
}
- // 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())) {
+
+ // 7. Check application is in-review
+ const { applicationStatus } = accessRecord;
+ if (applicationStatus !== constants.applicationStatuses.INREVIEW) {
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);
+ message: 'The application status must be set to in review to assign a workflow',
});
- 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 = workflowController.buildNextStep(userId, accessRecord, activeStepIndex, false);
- // 12. If step is now complete, update database record
- if (bpmContext.stepComplete) {
- accessRecord.workflow.steps[activeStepIndex].active = false;
- accessRecord.workflow.steps[activeStepIndex].completed = true;
- accessRecord.workflow.steps[activeStepIndex].endDateTime = new Date();
- }
- // 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 = workflowController.getWorkflowEmailContext(accessRecord, workflow, relevantStepIndex);
- module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user);
- }
- // 16. Call Camunda controller to update workflow process
- bpmController.postCompleteReview(bpmContext);
- }
+
+ // 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, requestingUser);
+ // Create our notifications to the custodian team managers if assigned a workflow to a DAR application
+ this.createNotifications(constants.notificationTypes.WORKFLOWASSIGNED, emailContext, accessRecord, requestingUser);
+
+ return res.status(200).json({
+ success: true,
});
- // 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 });
+ // 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/stepoverride
- updateAccessRequestStepOverride: async (req, res) => {
+ async updateAccessRequestStepOverride(req, res) {
try {
// 1. Get the required request params
const {
params: { id },
} = req;
- let { _id: userId } = req.user;
+ const requestingUser = req.user;
+ const requestingUserObjectId = req.user._id;
// 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.getApplicationWithWorkflowById(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);
+ 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
- let { applicationStatus } = accessRecord;
+ const { 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;
+ 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
- let activeStepIndex = steps.findIndex(step => {
+ const activeStepIndex = steps.findIndex(step => {
return step.active === true;
});
if (activeStepIndex === -1) {
@@ -1257,693 +1401,389 @@ module.exports = {
message: 'There is no active step to override for this workflow',
});
}
+
// 8. Update the step to be completed closing off end date/time
accessRecord.workflow.steps[activeStepIndex].active = false;
accessRecord.workflow.steps[activeStepIndex].completed = true;
accessRecord.workflow.steps[activeStepIndex].endDateTime = new Date();
+
// 9. Set up Camunda payload
- let bpmContext = workflowController.buildNextStep(userId, accessRecord, activeStepIndex, true);
+ 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(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 = workflowController.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 = workflowController.getWorkflowEmailContext(accessRecord, workflow, relevantStepIndex);
- }
- module.exports.createNotifications(relevantNotificationType, emailContext, accessRecord, req.user);
- // 15. Call Camunda controller to start manager review process
- bpmController.postCompleteReview(bpmContext);
- }
+ await accessRecord.save().catch(err => {
+ logger.logError(err, logCategory);
});
- // 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 });
- }
- },
-
- //PUT api/v1/data-access-request/:id/deletefile
- updateAccessRequestDeleteFile: async (req, res) => {
- try {
- const {
- params: { id },
- } = req;
-
- // 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 });
-
- 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
- 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',
- });
- }
-
- // 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
- 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();
+ // 12. Gather context for notifications (active step)
+ let emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, activeStepIndex);
- // 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 });
- }
- },
+ // 13. Create notifications to reviewers of the step that has been completed
+ this.createNotifications(constants.notificationTypes.STEPOVERRIDE, emailContext, accessRecord, requestingUser);
- //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 = amendmentController.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 = amendmentController.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;
- 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;
+ // 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 {
- 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',
- });
+ // Create notifications to reviewers of the next step that has been activated
+ relevantStepIndex = activeStepIndex + 1;
+ relevantNotificationType = constants.notificationTypes.REVIEWSTEPSTART;
}
-
- // 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' });
+ // Get the email context only if required
+ if (relevantStepIndex !== activeStepIndex) {
+ emailContext = this.workflowService.getWorkflowEmailContext(accessRecord, relevantStepIndex);
}
+ this.createNotifications(relevantNotificationType, emailContext, accessRecord, requestingUser);
- // 7. Send notification to the authorised user
- module.exports.createNotifications(constants.notificationTypes.INPROGRESS, {}, 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);
+ // Return error response if something goes wrong
+ logger.logError(err, logCategory);
return res.status(500).json({
success: false,
- message: 'An error occurred',
- });
- }
- },
-
- //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.',
+ message: 'An error occurred assigning the workflow',
});
}
- let activeStepIndex = workflow.steps.findIndex(step => {
- return step.active === true;
- });
- // 3. Determine email context if deadline has elapsed or is approaching
- const emailContext = workflowController.getWorkflowEmailContext(accessRecord, workflow, activeStepIndex);
- // 4. Send emails based on deadline elapsed or approaching
- if (emailContext.deadlineElapsed) {
- module.exports.createNotifications(constants.notificationTypes.DEADLINEPASSED, emailContext, accessRecord, req.user);
- } else {
- module.exports.createNotifications(constants.notificationTypes.DEADLINEWARNING, emailContext, accessRecord, req.user);
- }
- return res.status(200).json({ status: 'success' });
- },
+ }
- //POST api/v1/data-access-request/:id/actions
- performAction: async (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;
- let { questionId, questionSetId, questionIds = [], mode, separatorText = '' } = req.body;
- if (_.isEmpty(questionId) || _.isEmpty(questionSetId)) {
+ 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 unique identifiers for the question to perform an action',
+ message: 'You must supply the approved status with a reason',
});
}
+
// 2. Retrieve DAR from database
- let accessRecord = await DataRequestModel.findOne({ _id: id }).populate([
- {
- path: 'datasets dataset',
- },
- {
- path: 'publisherObj',
- populate: {
- path: 'team',
- populate: {
- path: 'users',
- },
- },
- },
- ]);
+ 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.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. 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. 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:
+
+ // 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 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,
- },
+ 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) {
- 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',
+ message: 'An error occurred assigning the workflow',
});
}
- },
+ }
- //POST api/v1/data-access-request/:id/clone
- cloneApplication: async (req, res) => {
+ //PUT api/v1/data-access-request/:id/startreview
+ async updateAccessRequestStartReview(req, res) {
try {
- // 1. Get the required request and body params
+ // 1. Get the required request params
const {
- params: { id: appIdToClone },
+ params: { id },
} = req;
- const { datasetIds = [], datasetTitles = [], publisher = '', appIdToCloneInto = '' } = req.body;
+ const requestingUserObjectId = req.user._id;
- // 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) {
+ // 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. Get the requesting users permission levels
- let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToClone, req.user.id, req.user._id);
+ // 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. Return unauthorised message if the requesting user is not an applicant
- if (!authorised || userType !== constants.userTypes.APPLICANT) {
+ // 4. Refuse access if not authorised
+ if (!authorised) {
return res.status(401).json({ status: 'failure', message: 'Unauthorised' });
}
- // 5. Update question answers with modifications since original submission
- appToClone = amendmentController.injectAmendments(appToClone, constants.userTypes.APPLICANT, req.user);
+ // 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. 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 });
- }
+ // 6. Update application to 'in review'
+ accessRecord.applicationStatus = constants.applicationStatuses.INREVIEW;
+ accessRecord.dateReviewStart = new Date();
- // Create notifications
- module.exports.createNotifications(
- constants.notificationTypes.APPLICATIONCLONED,
- { newDatasetTitles: datasetTitles, newApplicationId: doc._id.toString() },
- appToClone,
- req.user
- );
+ // 7. Update any connected version trees
+ this.dataRequestService.updateVersionStatus(accessRecord, constants.applicationStatuses.INREVIEW);
- // Return successful response
- return res.status(200).json({
- success: true,
- accessRecord: doc,
- });
- };
+ // 8. Save update to access record
+ await accessRecord.save().catch(err => {
+ logger.logError(err, logCategory);
+ });
- // 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);
+ // 9. 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',
+ };
- // Save into existing record
- await DataRequestModel.findOneAndUpdate({ _id: appIdToCloneInto }, clonedAccessRecord, { new: true }, saveCallBack);
+ // 10. Call Camunda controller to start manager review process
+ bpmController.postStartManagerReview(bpmContext);
}
+
+ // 11. Return aplication and successful response
+ 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 cloning the existing application',
+ message: 'An error occurred assigning the workflow',
});
}
- },
+ }
- updateFileStatus: async (req, res) => {
+ //POST api/v1/data-access-request/:id/notify
+ async notifyAccessRequestById(req, res) {
try {
// 1. Get the required request params
const {
- params: { id, fileId },
+ params: { id },
} = req;
-
- let { status } = req.body;
-
- // 2. Find the relevant data request application
- let accessRecord = await DataRequestModel.findOne({ _id: id });
-
+ const requestingUser = req.user;
+ // 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.' });
}
-
- //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' });
+ const { workflow } = accessRecord;
+ if (_.isEmpty(workflow)) {
+ return res.status(400).json({
+ status: 'error',
+ message: 'There is no workflow attached to this application.',
+ });
}
-
- //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,
+ 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, requestingUser);
+ } else {
+ this.createNotifications(constants.notificationTypes.DEADLINEWARNING, emailContext, accessRecord, requestingUser);
+ }
+ 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: err.message,
+ message: 'An error occurred triggering notifications for workflow review deadlines',
});
}
- },
+ }
- // 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;
+ // ###### EMAIL & NOTIFICATIONS #######
- // 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',
- },
- },
- },
- ]);
+ //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;
- // 3. Get the requesting users permission levels
- let { authorised, userType } = datarequestUtil.getUserPermissionsForApplication(appToDelete, req.user.id, req.user._id);
+ // 2. Retrieve DAR from database
+ const accessRecord = await this.dataRequestService.getApplicationWithTeamById(id, { lean: true });
- // 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' });
+ if (!accessRecord) {
+ return res.status(404).json({ status: 'error', message: 'Application not found.' });
}
- // 5. If application is not in progress, actions cannot be performed
- if (appToDelete.applicationStatus !== constants.applicationStatuses.INPROGRESS) {
+ // 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',
});
}
- // 6. Delete applicatioin
- DataRequestModel.findOneAndDelete({ _id: appIdToDelete }, err => {
- if (err) console.error(err.message);
- });
+ // 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' });
+ }
- // 7. Create notifications
- await module.exports.createNotifications(constants.notificationTypes.APPLICATIONDELETED, {}, appToDelete, req.user);
+ // 6. Send notification to the authorised user
+ this.createNotifications(constants.notificationTypes.INPROGRESS, {}, accessRecord, requestingUser);
- return res.status(200).json({
- success: true,
- });
+ 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 deleting the existing application',
+ message: 'An error occurred emailing the application',
});
}
- },
+ }
- createNotifications: async (type, context, accessRecord, user) => {
+ async createNotifications(type, context, accessRecord, user) {
// Project details from about application if 5 Safes
let { aboutApplication = {} } = accessRecord;
let { projectName = 'No project name set' } = aboutApplication;
@@ -1999,6 +1839,9 @@ module.exports = {
remainingReviewers = [],
remainingReviewerUserIds = [],
dateDeadline,
+ userType = '',
+ messageBody = '',
+ questionWithAnswer = {},
} = context;
switch (type) {
@@ -2017,6 +1860,7 @@ module.exports = {
userName: `${appFirstName} ${appLastName}`,
userType: 'applicant',
submissionType: constants.submissionTypes.INPROGRESS,
+ applicationId: accessRecord._id.toString(),
};
// Build email template
@@ -2040,20 +1884,19 @@ module.exports = {
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 = workflowController.getActiveWorkflowStep(workflow);
- stepReviewers = workflowController.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],
@@ -2102,10 +1945,7 @@ module.exports = {
case constants.notificationTypes.SUBMITTED:
// 1. Create notifications
// Custodian notification
- if (
- _.has(accessRecord.datasets[0].toObject(), 'publisher.team.users') &&
- accessRecord.datasets[0].publisher.allowAccessRequestManagement
- ) {
+ if (_.has(accessRecord.datasets[0], 'publisher.team.users') && accessRecord.datasets[0].publisher.allowAccessRequestManagement) {
// Retrieve all custodian user Ids to generate notifications
custodianManagers = teamController.getTeamMembersByRole(accessRecord.datasets[0].publisher.team, constants.roleTypes.MANAGER);
// check if publisher.team has email notifications
@@ -2145,6 +1985,7 @@ module.exports = {
publisher,
datasetTitles,
userName: `${appFirstName} ${appLastName}`,
+ applicationId: accessRecord._id.toString(),
};
// Iterate through the recipient types
for (let emailRecipientType of constants.submissionEmailRecipientTypes) {
@@ -2228,6 +2069,7 @@ module.exports = {
publisher,
datasetTitles,
userName: `${appFirstName} ${appLastName}`,
+ applicationId: accessRecord._id.toString(),
};
// Iterate through the recipient types
for (let emailRecipientType of constants.submissionEmailRecipientTypes) {
@@ -2295,7 +2137,7 @@ module.exports = {
// 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),
@@ -2318,7 +2160,7 @@ module.exports = {
// 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),
@@ -2505,7 +2347,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
@@ -2545,7 +2387,11 @@ module.exports = {
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
);
@@ -2624,167 +2470,354 @@ module.exports = {
false
);
break;
+ case constants.notificationTypes.APPLICATIONAMENDED:
+ // 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 submitted with amendments 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 submitted with amendments to ${publisher} by ${firstname} ${lastname}`,
+ 'data access request',
+ accessRecord._id
+ );
+ }
+ // 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) {
+ // Establish email context object
+ options = {
+ ...options,
+ userType: emailRecipientType,
+ submissionType: constants.submissionTypes.AMENDED,
+ };
+ // Build email template
+ ({ 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
+ 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 amended with updates`,
+ html,
+ false,
+ attachments
+ );
+ }
+ }
+ 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;
}
- },
+ }
- 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');
+ // ###### 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);
});
- 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))
+
+ 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/:messageType
+ async getMessages(req, res) {
+ try {
+ const {
+ params: { id },
+ query: { messageType, questionId },
+ } = 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' });
+ } 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)
) {
- 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;
+ return res.status(401).json({ status: 'failure', message: 'Unauthorised' });
+ }
+
+ const topic = await this.topicService.getTopicForDAR(id, questionId, messageType);
+
+ 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,
});
- 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];
+ return res.status(200).json({
+ status: 'success',
+ messages,
+ });
+ } catch (err) {
+ logger.logError(err, logCategory);
+ return res.status(500).json({
+ success: false,
+ message: 'An error occurred updating the application',
+ });
}
- 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);
+ //POST api/v1/data-access-request/:id/messages
+ async submitMessage(req, res) {
+ try {
+ const {
+ params: { id },
+ } = req;
+ 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.getApplicationWithTeamById(id, { lean: true });
+ 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' });
+ } 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' });
+ }
+
+ let topic = await this.topicService.getTopicForDAR(id, questionId, messageType);
+
+ if (_.isEmpty(topic)) {
+ topic = await this.topicService.createTopicForDAR(id, questionId, messageType);
+ }
+
+ 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) {
+ foundQuestion = datarequestUtil.findQuestion(questionSet.questions, questionId);
+ if (foundQuestion) {
+ foundQuestionSet = questionSet;
+ 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;
+ }
+ }
+
+ const answer =
+ accessRecord.questionAnswers && accessRecord.questionAnswers[questionId]
+ ? accessRecord.questionAnswers[questionId]
+ : 'No answer for this question';
+
+ this.createNotifications(
+ constants.notificationTypes.MESSAGESENT,
+ {
+ userType,
+ messageBody,
+ questionWithAnswer: {
+ question: foundQuestion.question,
+ questionPanel: foundQuestionSet.questionSetHeader,
+ page: foundPage.title,
+ answer,
+ },
+ },
+ accessRecord,
+ requestingUser
+ );
+ }
+
+ 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',
+ });
}
- return 0;
- },
-};
+ }
+}
diff --git a/src/resources/datarequest/datarequest.entity.js b/src/resources/datarequest/datarequest.entity.js
new file mode 100644
index 00000000..d3e33b8f
--- /dev/null
+++ b/src/resources/datarequest/datarequest.entity.js
@@ -0,0 +1,146 @@
+import { last } from 'lodash';
+
+import Entity from '../base/entity';
+import constants from '../utilities/constants.util';
+
+export default class DataRequestClass extends Entity {
+ constructor(obj) {
+ super();
+ 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.0'].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
+ */
+ 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 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.0
+ let {
+ _id: applicationId,
+ majorVersion,
+ versionTree = {},
+ amendmentIterations = [],
+ applicationType = constants.submissionTypes.INITIAL,
+ applicationStatus = constants.applicationStatuses.INPROGRESS
+ } = accessRecord;
+ const versionKey = majorVersion ? majorVersion.toString() : '1.0';
+
+ // 3. Reverse iterate through amendment iterations and construct minor versions
+ let minorVersions = {};
+ 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,
+ [`${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
+ const hasMinorVersions = amendmentIterations.length > 0;
+ const isInitial = applicationType === constants.submissionTypes.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,
+ applicationStatus
+ },
+ };
+
+ // 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 49350a35..879582b0 100644
--- a/src/resources/datarequest/datarequest.model.js
+++ b/src/resources/datarequest/datarequest.model.js
@@ -1,14 +1,17 @@
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,
+ majorVersion: { type: Number, default: 1 },
userId: Number, // Main applicant
authorIds: [Number],
dataSetId: String,
datasetIds: [{ type: String }],
+ initialDatasetIds: [{ type: String }],
datasetTitles: [{ type: String }],
isCloneable: Boolean,
projectId: String,
@@ -19,6 +22,14 @@ const DataRequestSchema = new Schema(
default: 'inProgress',
enum: ['inProgress', 'submitted', 'inReview', 'approved', 'rejected', 'approved with conditions', 'withdrawn'],
},
+ applicationType: {
+ type: String,
+ default: constants.submissionTypes.INITIAL,
+ enum: Object.values(constants.submissionTypes),
+ },
+ submissionDescription: {
+ type: String,
+ },
archived: {
Boolean,
default: false,
@@ -33,6 +44,10 @@ const DataRequestSchema = new Schema(
type: Object,
default: {},
},
+ initialQuestionAnswers: {
+ type: Object,
+ default: {},
+ },
aboutApplication: {
type: Object,
default: {},
@@ -80,7 +95,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' },
+ versionTree: { type: Object, default: {} },
+ isShared: { Boolean, default: false },
},
{
timestamps: true,
@@ -123,4 +140,14 @@ DataRequestSchema.virtual('authors', {
localField: 'authorIds',
});
+DataRequestSchema.virtual('initialDatasets', {
+ ref: 'Data',
+ foreignField: 'datasetid',
+ localField: 'initialDatasetIds',
+ justOne: false,
+});
+
+// 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..63797709
--- /dev/null
+++ b/src/resources/datarequest/datarequest.repository.js
@@ -0,0 +1,238 @@
+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';
+
+export default class DataRequestRepository extends Repository {
+ constructor() {
+ super(DataRequestModel);
+ this.dataRequestModel = DataRequestModel;
+ }
+
+ getAccessRequestsByUser(userId, query) {
+ if (!userId) return [];
+
+ return DataRequestModel.find({
+ $and: [{ ...query }, { $or: [{ userId }, { authorIds: userId }] }],
+ })
+ .select('-jsonSchema -files')
+ .populate([{ path: 'mainApplicant', select: 'firstname lastname -id' }, { path: 'datasets' }])
+ .lean();
+ }
+
+ getApplicationById(id) {
+ return DataRequestModel.findOne({
+ _id: id,
+ })
+ .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' },
+ ])
+ .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([
+ //lgtm [js/sql-injection]
+ {
+ path: 'datasets dataset authors',
+ },
+ {
+ path: 'mainApplicant',
+ },
+ {
+ path: 'publisherObj',
+ populate: {
+ path: 'team',
+ populate: {
+ path: 'users',
+ },
+ },
+ },
+ ]);
+ }
+
+ getApplicationWithWorkflowById(id, options = {}) {
+ return DataRequestModel.findOne({ _id: id }, null, options).populate([
+ {
+ path: 'publisherObj',
+ populate: {
+ path: 'team',
+ populate: {
+ path: 'users',
+ },
+ },
+ },
+ {
+ path: 'workflow.steps.reviewers',
+ select: 'firstname lastname id email',
+ },
+ {
+ path: 'datasets dataset',
+ },
+ {
+ path: 'mainApplicant authors',
+ },
+ ]);
+ }
+
+ getApplicationToSubmitById(id) {
+ return DataRequestModel.findOne({ _id: id }).populate([
+ {
+ path: 'datasets dataset initialDatasets',
+ populate: {
+ path: 'publisher',
+ populate: {
+ path: 'team',
+ populate: {
+ path: 'users',
+ populate: {
+ path: 'additionalInfo',
+ },
+ },
+ },
+ },
+ },
+ {
+ path: 'mainApplicant authors',
+ populate: {
+ path: 'additionalInfo',
+ },
+ },
+ {
+ path: 'publisherObj',
+ },
+ ]);
+ }
+
+ getApplicationToUpdateById(id) {
+ return DataRequestModel.findOne({
+ _id: id,
+ }).lean();
+ }
+
+ getFilesForApplicationById(id, options = {}) {
+ 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 }); //lgtm [js/sql-injection]
+ }
+
+ replaceApplicationById(id, newDoc) {
+ return DataRequestModel.replaceOne({ _id: id }, newDoc);
+ }
+
+ deleteApplicationById(id) {
+ return DataRequestModel.findOneAndDelete({ _id: id });
+ }
+
+ createApplication(data) {
+ return DataRequestModel.create(data);
+ }
+
+ async saveFileUploadChanges(accessRecord) {
+ await accessRecord.save();
+ return DataRequestModel.populate(accessRecord, {
+ path: 'files.owner',
+ select: 'firstname lastname id',
+ });
+ }
+
+ 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 = await 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 20f6aaf5..1ef169de 100644
--- a/src/resources/datarequest/datarequest.route.js
+++ b/src/resources/datarequest/datarequest.route.js
@@ -3,9 +3,12 @@ import passport from 'passport';
import _ from 'lodash';
import multer from 'multer';
import { param } from 'express-validator';
+
import { logger } from '../utilities/logger';
-const amendmentController = require('./amendment/amendment.controller');
-const datarequestController = require('./datarequest.controller');
+import DataRequestController from './datarequest.controller';
+import AmendmentController from './amendment/amendment.controller';
+import { dataRequestService, workflowService, amendmentService, topicService, messageService } from './dependency';
+
const fs = require('fs');
const path = './tmp';
const storage = multer.diskStorage({
@@ -18,7 +21,14 @@ const storage = multer.diskStorage({
});
const multerMid = multer({ storage: storage });
const logCategory = 'Data Access Request';
-
+const dataRequestController = new DataRequestController(
+ dataRequestService,
+ workflowService,
+ amendmentService,
+ topicService,
+ messageService
+);
+const amendmentController = new AmendmentController(amendmentService, dataRequestService);
const router = express.Router();
// @route GET api/v1/data-access-request
@@ -28,28 +38,89 @@ 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
+// @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
-// @desc GET Access request for user
-// @access Private - Applicant (Gateway User) and Custodian Manager/Reviewer
-router.get('/dataset/:dataSetId', passport.authenticate('jwt'), datarequestController.getAccessRequestByUserAndDataset);
-
// @route GET api/v1/data-access-request/datasets/:datasetIds
// @desc GET Access request with multiple datasets for user
// @access Private - 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' }),
+ (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
+router.post(
+ '/:id/clone',
+ passport.authenticate('jwt'),
+ logger.logRequestMiddleware({ logCategory, action: 'Cloning a Data Access Request application' }),
+ (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 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 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' }),
+ (req, res) => dataRequestController.uploadFiles(req, res)
+);
+
+// @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' }),
+ (req, res) => dataRequestController.assignWorkflow(req, res)
+);
// @route GET api/v1/data-access-request/:id/file/:fileId
// @desc GET
@@ -60,97 +131,171 @@ router.get(
return value;
}),
passport.authenticate('jwt'),
- datarequestController.getFile
+ logger.logRequestMiddleware({ logCategory, action: 'Requested an uploaded file from a Data Access Request application' }),
+ (req, res) => dataRequestController.getFile(req, res)
);
// @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' }),
+ (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', passport.authenticate('jwt'), datarequestController.updateAccessRequestDataElement);
+router.put(
+ '/:id/deletefile',
+ passport.authenticate('jwt'),
+ 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 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)
+);
// @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' }),
+ (req, res) => dataRequestController.updateAccessRequestById(req, res)
+);
-// @route PUT api/v1/data-access-request/:id/assignworkflow
-// @desc Update access request workflow
+// @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/assignworkflow', passport.authenticate('jwt'), datarequestController.assignWorkflow);
+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
// @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' }),
+ (req, res) => dataRequestController.updateAccessRequestReviewVote(req, res)
+);
// @route PUT api/v1/data-access-request/:id/startreview
// @desc Update access request with review started
// @access Private - Custodian Manager
-router.put('/:id/startreview', passport.authenticate('jwt'), datarequestController.updateAccessRequestStartReview);
-
-// @route PUT api/v1/data-access-request/:id/stepoverride
-// @desc Update access request with current step overriden (manager ends current phase regardless of votes cast)
-// @access Private - Custodian Manager
-router.put('/:id/stepoverride', passport.authenticate('jwt'), datarequestController.updateAccessRequestStepOverride);
-
-// @route 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);
-
-// @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.put(
+ '/:id/startreview',
+ passport.authenticate('jwt'),
+ logger.logRequestMiddleware({ logCategory, action: 'Starting the review process for a Data Access Request application' }),
+ (req, res) => dataRequestController.updateAccessRequestStartReview(req, res)
+);
// @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' }),
+ (req, res) => amendmentController.setAmendment(req, res)
+);
// @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);
-
-// @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/requestAmendments',
+ passport.authenticate('jwt'),
+ logger.logRequestMiddleware({ logCategory, action: 'Requesting a batch of amendments to a Data Access Request application' }),
+ (req, res) => amendmentController.requestAmendments(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
+// @route PUT api/v1/data-access-request/:id/share
+// @desc Update share flag for application
// @access Private - Applicant
-router.post('/:id/clone', passport.authenticate('jwt'), 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);
-
-// @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.put(
+ '/:id/share',
+ passport.authenticate('jwt'),
+ logger.logRequestMiddleware({ logCategory, action: 'Update share flag for application' }),
+ (req, res) => dataRequestController.updateSharedDARFlag(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'), datarequestController.updateFileStatus);
+// @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/email
-// @desc Mail a Data Access Request information in presubmission
-// @access Private - Applicant
-router.post('/:id/email', passport.authenticate('jwt'), datarequestController.mailDataAccessRequestInfoById);
+// @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)
+);
-// @route DELETE api/v1/data-access-request/:id
-// @desc Delete an application in a presubmissioin
+// @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.delete('/:id', passport.authenticate('jwt'), datarequestController.deleteDraftAccessRequest);
+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
new file mode 100644
index 00000000..8855e5b9
--- /dev/null
+++ b/src/resources/datarequest/datarequest.service.js
@@ -0,0 +1,493 @@
+import { isEmpty, has, isNil, orderBy } from 'lodash';
+import moment from 'moment';
+
+import helper from '../utilities/helper.util';
+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';
+
+export default class DataRequestService {
+ constructor(dataRequestRepository) {
+ this.dataRequestRepository = dataRequestRepository;
+ }
+
+ async getAccessRequestsByUser(userId, query = {}) {
+ return this.dataRequestRepository.getAccessRequestsByUser(userId, query);
+ }
+
+ getApplicationById(id) {
+ 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);
+ }
+
+ getApplicationWithWorkflowById(id, options) {
+ return this.dataRequestRepository.getApplicationWithWorkflowById(id, options);
+ }
+
+ getApplicationToSubmitById(id) {
+ return this.dataRequestRepository.getApplicationToSubmitById(id);
+ }
+
+ getApplicationToUpdateById(id) {
+ 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) {
+ readOnly = false;
+ }
+ return readOnly;
+ }
+
+ getFilesForApplicationById(id, options) {
+ return this.dataRequestRepository.getFilesForApplicationById(id, options);
+ }
+
+ getDatasetsForApplicationByIds(arrDatasetIds) {
+ return this.dataRequestRepository.getDatasetsForApplicationByIds(arrDatasetIds);
+ }
+
+ 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) {
+ 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, applicationType = constants.submissionTypes.INITIAL, versionTree = {}) {
+ let application = await this.dataRequestRepository.createApplication(data);
+
+ 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);
+ }
+
+ application = await this.dataRequestRepository.updateApplicationById(application._id, application);
+
+ return application;
+ }
+
+ 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: undefined };
+ }
+
+ // 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+)$/); // lgtm [js/polynomial-redos]
+ if (regexMatch) {
+ fullMatch = regexMatch[0];
+ requestedMajorVersion = regexMatch[1] || regexMatch[2];
+ requestedMinorVersion = regexMatch[3] || regexMatch[2];
+ }
+
+ // 3. Catch invalid version requests
+ try {
+ let { majorVersion, amendmentIterations = [] } = accessRecord;
+ majorVersion = parseInt(majorVersion);
+ requestedMajorVersion = parseInt(requestedMajorVersion);
+ if (requestedMinorVersion) {
+ requestedMinorVersion = parseInt(requestedMinorVersion);
+ } else if (requestedMajorVersion) {
+ requestedMinorVersion = 0;
+ }
+
+ if (!fullMatch || majorVersion !== requestedMajorVersion || requestedMinorVersion > amendmentIterations.length) {
+ isValidVersion = false;
+ }
+ } catch {
+ isValidVersion = false;
+ }
+
+ return { isValidVersion, requestedMajorVersion, requestedMinorVersion };
+ }
+
+ buildVersionHistory = (versionTree, applicationId, requestedVersion, userType) => {
+ 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;
+
+ const isCurrent = applicationId.toString() === _id.toString() && (requestedVersion === versionKey || !requestedVersion);
+
+ const version = {
+ number: versionKey,
+ versionNumber: parseFloat(versionKey),
+ _id,
+ link,
+ displayTitle,
+ detailedTitle,
+ isCurrent,
+ };
+
+ arr = [...arr, version];
+
+ return arr;
+ }, []);
+
+ 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)) {
+ 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 if (orderedVersions.length > 0) {
+ orderedVersions[0].isCurrent = true;
+ }
+ }
+
+ return orderedVersions;
+ };
+
+ getProjectName(accessRecord) {
+ // Retrieve project name from about application section
+ const { aboutApplication: { projectName } = {} } = accessRecord;
+ if (projectName) {
+ return projectName;
+ } 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';
+ }
+ }
+
+ 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 '';
+ }
+ }
+
+ updateApplicationById(id, data, options = {}) {
+ return this.dataRequestRepository.updateApplicationById(id, data, options);
+ }
+
+ 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;
+ }
+
+ 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 createAmendment(accessRecord) {
+ // TODO persist messages + private notes between applications (copy)
+ const applicationType = constants.submissionTypes.AMENDED;
+ const applicationStatus = constants.applicationStatuses.INPROGRESS;
+
+ const {
+ userId,
+ authorIds,
+ datasetIds,
+ datasetTitles,
+ projectId,
+ questionAnswers,
+ aboutApplication,
+ publisher,
+ files,
+ versionTree,
+ } = accessRecord;
+
+ const { jsonSchema, _id: schemaId, isCloneable = false, formType } = await datarequestUtil.getLatestPublisherSchema(publisher);
+
+ let amendedApplication = {
+ applicationType,
+ applicationStatus,
+ userId,
+ authorIds,
+ datasetIds,
+ initialDatasetIds: datasetIds,
+ datasetTitles,
+ isCloneable,
+ projectId,
+ schemaId,
+ jsonSchema,
+ questionAnswers,
+ initialQuestionAnswers: questionAnswers,
+ aboutApplication,
+ publisher,
+ formType,
+ files,
+ };
+
+ 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;
+ }
+
+ 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
+ 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]
+ // 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], initialApplicationId, 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;
+ }
+
+ 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);
+ }
+
+ async doInitialSubmission(accessRecord) {
+ // 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')) {
+ if (!accessRecord.publisherObj.workflowEnabled) {
+ accessRecord.dateFinalStatus = new Date();
+ accessRecord.workflowEnabled = false;
+ } else {
+ accessRecord.workflowEnabled = true;
+ }
+ }
+ const dateSubmitted = new Date();
+ accessRecord.dateSubmitted = dateSubmitted;
+ // 3. Update any connected version trees
+ await this.updateVersionStatus(accessRecord, constants.applicationStatuses.SUBMITTED);
+ // 4. Return updated access record for saving
+ return accessRecord;
+ }
+
+ async doAmendSubmission(accessRecord, description) {
+ // 1. Amend submission goes to submitted status with text reason for amendment
+ accessRecord.applicationStatus = constants.applicationStatuses.SUBMITTED;
+ accessRecord.submissionDescription = description;
+
+ // 2. Set submission date as now
+ const dateSubmitted = new Date();
+ accessRecord.dateSubmitted = dateSubmitted;
+ accessRecord.upadtedAt = dateSubmitted;
+
+ // 3. Update any connected version trees
+ await this.updateVersionStatus(accessRecord, constants.applicationStatuses.SUBMITTED);
+
+ // 4. 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) => {
+ if (versionTree[key].applicationType) {
+ arr.push(versionTree[key].applicationId);
+ }
+ return arr;
+ }, []);
+ // 2. Update all related applications
+ this.dataRequestRepository.syncRelatedVersions(applicationIds, versionTree);
+ }
+}
diff --git a/src/resources/datarequest/dependency.js b/src/resources/datarequest/dependency.js
new file mode 100644
index 00000000..3f51ac11
--- /dev/null
+++ b/src/resources/datarequest/dependency.js
@@ -0,0 +1,25 @@
+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';
+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);
+
+export const workflowRepository = new WorkflowRepository();
+export const workflowService = new WorkflowService(workflowRepository);
+
+export const amendmentRepository = new AmendmentRepository();
+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/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 50f85776..d1a5eab7 100644
--- a/src/resources/datarequest/datarequest.schemas.model.js
+++ b/src/resources/datarequest/schema/datarequest.schemas.model.js
@@ -1,5 +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 01c51a48..beec74dc 100644
--- a/src/resources/datarequest/datarequest.schemas.route.js
+++ b/src/resources/datarequest/schema/datarequest.schemas.route.js
@@ -1,8 +1,9 @@
import express from 'express';
-import { DataRequestSchemaModel } from './datarequest.schemas.model';
import passport from 'passport';
-import { utils } from '../auth';
-import { ROLES } from '../user/user.roles';
+
+import { DataRequestSchemaModel } from './datarequest.schemas.model';
+import { utils } from '../../auth';
+import { ROLES } from '../../user/user.roles';
const router = express.Router();
diff --git a/src/resources/datarequest/utils/__tests__/datarequest.util.test.js b/src/resources/datarequest/utils/__tests__/datarequest.util.test.js
index d9331270..b61bc78e 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: 'notes', 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)(
diff --git a/src/resources/datarequest/utils/datarequest.util.js b/src/resources/datarequest/utils/datarequest.util.js
index 88e012d2..574ff45a 100644
--- a/src/resources/datarequest/utils/datarequest.util.js
+++ b/src/resources/datarequest/utils/datarequest.util.js
@@ -2,23 +2,34 @@ 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;
-const injectQuestionActions = (jsonSchema, userType, applicationStatus, role = '', activeParty) => {
- let formattedSchema = {};
- if (userType === constants.userTypes.CUSTODIAN) {
- if (applicationStatus === constants.applicationStatuses.INREVIEW) {
- formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus][activeParty] };
- } else {
- formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][role][applicationStatus]};
- }
- } else {
- formattedSchema = { ...jsonSchema, questionActions: constants.userQuestionActions[userType][applicationStatus] };
+const injectQuestionActions = (jsonSchema, userType, applicationStatus, role = '', activeParty, isLatestMinorVersion = true) => {
+ 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.messages,
+ constants.questionActions.notes,
+ constants.questionActions.updates,
+ ],
+ };
+ else {
+ return {
+ ...jsonSchema,
+ questionActions: [constants.questionActions.guidance, constants.questionActions.messages, constants.questionActions.notes],
+ };
}
- return formattedSchema;
};
const getUserPermissionsForApplication = (application, userId, _id) => {
@@ -36,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;
}
@@ -55,30 +66,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) => {
@@ -170,7 +169,7 @@ const setQuestionState = (question, questionAlert, readOnly) => {
return question;
};
-const buildQuestionAlert = (userType, iterationStatus, completed, amendment, user, publisher) => {
+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)
@@ -182,8 +181,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
- let relevantActioner = !isNil(updatedBy) ? 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#',
@@ -246,7 +258,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
@@ -291,7 +303,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
@@ -299,7 +311,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);
@@ -319,22 +331,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
@@ -346,6 +361,38 @@ const extractRepeatedQuestionIds = questionAnswers => {
}, []);
};
+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 });
+ }
+ });
+
+ 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;
+ }
+ }
+ });
+
+ return jsonSchema;
+};
+
export default {
injectQuestionActions: injectQuestionActions,
getUserPermissionsForApplication: getUserPermissionsForApplication,
@@ -356,4 +403,8 @@ export default {
setQuestionState: setQuestionState,
cloneIntoExistingApplication: cloneIntoExistingApplication,
cloneIntoNewApplication: cloneIntoNewApplication,
+ injectMessagesAndNotesCount,
+ getLatestPublisherSchema: getLatestPublisherSchema,
+ containsUserRepeatedSections: containsUserRepeatedSections,
+ copyUserRepeatedSections: copyUserRepeatedSections,
};
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/dataset/datasetonboarding.controller.js b/src/resources/dataset/datasetonboarding.controller.js
index 5eb6e491..253a8e8b 100644
--- a/src/resources/dataset/datasetonboarding.controller.js
+++ b/src/resources/dataset/datasetonboarding.controller.js
@@ -16,6 +16,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
@@ -139,7 +140,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 (
@@ -530,7 +531,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 = amendmentService.injectAmendments(accessRequestRecord, constants.userTypes.APPLICANT, req.user);
}
let data = {
status: 'success',
@@ -633,7 +634,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);
@@ -761,7 +762,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 +844,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'] || '',
@@ -1612,11 +1613,66 @@ 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
);
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,
+ `The draft version of ${draftDatasetName} has been deleted.`,
+ '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;
+ }
+ },
+
+ //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;
+
+ await module.exports.createNotifications(constants.notificationTypes.DRAFTDATASETDELETED, dataset);
+
+ 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;
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"
+ }
+ ]
+ }
}
]
},
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/filters/filters.service.js b/src/resources/filters/filters.service.js
index 05fc1313..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;
diff --git a/src/resources/message/message.model.js b/src/resources/message/message.model.js
index 8f2ff11a..46837ed8 100644
--- a/src/resources/message/message.model.js
+++ b/src/resources/message/message.model.js
@@ -28,9 +28,11 @@ const MessageSchema = new Schema(
'team unlinked',
'edit',
'workflow',
+ 'data access message sent',
'dataset submitted',
'dataset approved',
'dataset rejected',
+ 'draft dataset deleted',
],
},
publisherName: {
@@ -46,6 +48,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/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 99c5f3c6..9f3b840c 100644
--- a/src/resources/publisher/publisher.controller.js
+++ b/src/resources/publisher/publisher.controller.js
@@ -1,25 +1,28 @@
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';
+import { logger } from '../utilities/logger';
+
+const logCategory = 'Publisher';
-const datarequestController = require('../datarequest/datarequest.controller');
+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;
+ }
-module.exports = {
- // GET api/v1/publishers/:id
- getPublisherById: async (req, res) => {
+ 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,
@@ -29,243 +32,123 @@ 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]
- .map(app => {
- return datarequestController.createApplicationDTO(app, constants.userTypes.CUSTODIAN, _id.toString());
+ // 5. Append projectName and applicants
+ const modifiedApplications = [...applications]
+ .map(accessRecord => {
+ 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.versions = this.dataRequestService.buildVersionHistory(accessRecord.versionTree, accessRecord._id, null, constants.userTypes.CUSTODIAN);
+ accessRecord.amendmentStatus = this.amendmentService.calculateAmendmentStatus(accessRecord, constants.userTypes.CUSTODIAN);
+ return accessRecord;
})
.sort((a, b) => b.updatedAt - a.updatedAt);
- let avgDecisionTime = datarequestController.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..cb087c63
--- /dev/null
+++ b/src/resources/publisher/publisher.repository.js
@@ -0,0 +1,61 @@
+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';
+
+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 -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..345e8222
--- /dev/null
+++ b/src/resources/publisher/publisher.service.js
@@ -0,0 +1,93 @@
+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 = [];
+ if (!isManager) {
+ excludedApplicationStatuses.push('submitted');
+ }
+ const query = { publisher: id, applicationStatus: { $nin: excludedApplicationStatuses } };
+
+ let applications = await this.publisherRepository.getPublisherDataAccessRequests(query);
+
+ applications = this.filterInProgressApplications(applications);
+
+ if (!isManager) {
+ applications = this.filterApplicationsForReviewer(applications, requestingUserId);
+ }
+
+ return applications;
+ }
+
+ filterApplicationsForReviewer(applications, reviewerUserId) {
+ const filteredApplications = [...applications].filter(app => {
+ let { workflow = {} } = app;
+ if (isEmpty(workflow)) {
+ return;
+ }
+
+ let { steps = [] } = workflow;
+ if (isEmpty(steps)) {
+ return;
+ }
+
+ 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;
+ }
+
+ filterInProgressApplications(applications) {
+ const filteredApplications = [...applications].filter(app => {
+ if (app.applicationStatus !== 'inProgress') return app;
+
+ if (app.isShared) return app;
+
+ return;
+ });
+
+ return filteredApplications;
+ }
+}
diff --git a/src/resources/stats/stats.controller.js b/src/resources/stats/stats.controller.js
index 963277d7..15c2ed6f 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/stats/stats.repository.js b/src/resources/stats/stats.repository.js
index ef9bc2af..49e6ad7c 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';
diff --git a/src/resources/team/team.controller.js b/src/resources/team/team.controller.js
index ad796172..0796d886 100644
--- a/src/resources/team/team.controller.js
+++ b/src/resources/team/team.controller.js
@@ -1,10 +1,11 @@
import _ from 'lodash';
+
import { TeamModel } from './team.model';
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 {
@@ -42,12 +43,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 });
@@ -528,6 +529,46 @@ const deleteTeamMember = async (req, res) => {
}
};
+/**
+ * GET api/v1/teams
+ *
+ * @desc Get the list of all publisher teams
+ *
+ */
+const getTeamsList = async (req, res) => {
+ 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 },
+ {
+ _id: 1,
+ updatedAt: 1,
+ members: 1,
+ membersCount: {$size: '$members'}
+ }
+ )
+ .populate('publisher', { name: 1 })
+ .populate('users', { firstname: 1, lastname: 1 })
+ .lean();
+
+ // 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 +889,5 @@ export default {
checkTeamPermissions: checkTeamPermissions,
getTeamMembersByRole: getTeamMembersByRole,
createNotifications: createNotifications,
+ getTeamsList: getTeamsList,
};
diff --git a/src/resources/team/team.model.js b/src/resources/team/team.model.js
index 3797aba7..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(
@@ -60,6 +61,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/team/team.route.js b/src/resources/team/team.route.js
index 798a230d..bedc908f 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('/', 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;
diff --git a/src/resources/tool/data.repository.js b/src/resources/tool/data.repository.js
index 0b59ae60..9278dd8c 100644
--- a/src/resources/tool/data.repository.js
+++ b/src/resources/tool/data.repository.js
@@ -103,18 +103,22 @@ 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') {
- await sendEmailNotificationToAuthors(data, toolCreator);
- }
+ await sendEmailNotificationToAuthors(data, toolCreator);
await storeNotificationsForAuthors(data, toolCreator);
resolve(newDataObj);
@@ -202,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);
@@ -436,24 +440,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,18 +479,19 @@ 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);
});
}
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([
@@ -490,7 +505,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 });
@@ -498,16 +524,14 @@ 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 ${tool.name}
${toolLink}`,
+ `${toolOwner.name} added you as an author of the ${tool.type} ${tool.name}`,
+ html,
false
);
});
}
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);
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);
});
}
diff --git a/src/resources/topic/topic.model.js b/src/resources/topic/topic.model.js
index afd8257c..387ff684 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,
@@ -104,7 +108,7 @@ TopicSchema.pre(/^find/, function (next) {
path: 'createdBy',
select: 'firstname lastname',
path: 'topicMessages',
- select: 'messageDescription firstMessage createdDate isRead _id readBy',
+ select: 'messageDescription firstMessage 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..e0d98f04
--- /dev/null
+++ b/src/resources/topic/topic.repository.js
@@ -0,0 +1,33 @@
+import Repository from '../base/repository';
+import { TopicModel } from './topic.model';
+
+export default class TopicRepository extends Repository {
+ constructor() {
+ super(TopicModel);
+ this.topicModel = TopicModel;
+ }
+
+ getTopicsForDAR(title, messageType) {
+ return TopicModel.find({
+ title: { $eq: title },
+ messageType: { $eq: messageType },
+ }).lean();
+ }
+
+ getTopicForDAR(title, subTitle, messageType) {
+ return TopicModel.findOne({
+ title: { $eq: title },
+ subTitle: { $eq: subTitle },
+ messageType: { $eq: 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..45fc50c8
--- /dev/null
+++ b/src/resources/topic/topic.service.js
@@ -0,0 +1,17 @@
+export default class TopicService {
+ constructor(topicRepository) {
+ this.topicRepository = topicRepository;
+ }
+
+ getTopicsForDAR(applicationID, messageType) {
+ return this.topicRepository.getTopicsForDAR(applicationID, messageType);
+ }
+
+ 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/user/user.route.js b/src/resources/user/user.route.js
index c181e574..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,19 +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.checkIsUser(), 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.' });
}
- let roles = advancedSearchRoles.map(role => role.toString());
- let user = 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 });
+
+ 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 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..60f2b6b1 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,20 @@ 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 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);
+ });
+}
diff --git a/src/resources/utilities/constants.util.js b/src/resources/utilities/constants.util.js
index 84c61fc1..8de05536 100644
--- a/src/resources/utilities/constants.util.js
+++ b/src/resources/utilities/constants.util.js
@@ -36,214 +36,34 @@ const _teamNotificationTypesHuman = Object.freeze({
const _enquiryFormId = '5f0c4af5d138d3e486270031';
-const _userQuestionActions = {
- custodian: {
- reviewer: {
- submitted: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- 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,
- },
- ],
- },
- approved: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- ['approved with conditions']: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- rejected: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- withdrawn: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- },
- manager: {
- submitted: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- 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,
- },
- ],
- applicant: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- },
- approved: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- ['approved with conditions']: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- rejected: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- withdrawn: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- },
+const _questionActions = {
+ guidance: {
+ key: 'guidance',
+ icon: 'far fa-question-circle',
+ color: '#475da7',
+ toolTip: 'Guidance',
+ order: 1,
},
- applicant: {
- inProgress: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- submitted: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- inReview: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- approved: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- ['approved with conditions']: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- rejected: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- toolTip: 'Guidance',
- order: 1,
- },
- ],
- withdrawn: [
- {
- key: 'guidance',
- icon: 'far fa-question-circle',
- color: '#475da7',
- 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: 4,
},
};
@@ -313,6 +133,7 @@ const _notificationTypes = {
FINALDECISIONREQUIRED: 'FinalDecisionRequired',
DEADLINEWARNING: 'DeadlineWarning',
DEADLINEPASSED: 'DeadlinePassed',
+ APPLICATIONAMENDED: 'ApplicationAmended',
RETURNED: 'Returned',
MEMBERADDED: 'MemberAdded',
MEMBERREMOVED: 'MemberRemoved',
@@ -325,6 +146,7 @@ const _notificationTypes = {
DATASETSUBMITTED: 'DatasetSubmitted',
DATASETAPPROVED: 'DatasetApproved',
DATASETREJECTED: 'DatasetRejected',
+ MESSAGESENT: 'MessageSent',
};
const _applicationStatuses = {
@@ -347,6 +169,9 @@ const _submissionTypes = {
INPROGRESS: 'inProgress',
INITIAL: 'initial',
RESUBMISSION: 'resubmission',
+ AMENDED: 'amendment',
+ EXTENDED: 'extension',
+ RENEWAL: 'renewal',
};
const _formActions = {
@@ -364,6 +189,12 @@ const _darPanelMapper = {
safeoutputs: 'Safe outputs',
};
+const _DARMessageTypes = {
+ DARMESSAGE: 'DAR_Message',
+ DARNOTESAPPLICANT: 'DAR_Notes_Applicant',
+ DARNOTESCUSTODIAN: 'DAR_Notes_Custodian',
+};
+
//
//
@@ -401,8 +232,8 @@ const _mailchimpSubscriptionStatuses = {
const _logTypes = {
SYSTEM: 'System',
- USER: 'User'
-}
+ USER: 'User',
+};
export default {
userTypes: _userTypes,
@@ -412,7 +243,7 @@ export default {
teamNotificationMessages: _teamNotificationMessages,
teamNotificationTypesHuman: _teamNotificationTypesHuman,
teamNotificationEmailContentTypes: _teamNotificationEmailContentTypes,
- userQuestionActions: _userQuestionActions,
+ questionActions: _questionActions,
navigationFlags: _navigationFlags,
amendmentStatuses: _amendmentStatuses,
notificationTypes: _notificationTypes,
@@ -426,5 +257,6 @@ export default {
hdrukEmail: _hdrukEmail,
mailchimpSubscriptionStatuses: _mailchimpSubscriptionStatuses,
datatsetStatuses: _datatsetStatuses,
- logTypes: _logTypes
+ logTypes: _logTypes,
+ DARMessageTypes: _DARMessageTypes,
};
diff --git a/src/resources/utilities/emailGenerator.util.js b/src/resources/utilities/emailGenerator.util.js
index fd51e42a..f772a853 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,128 @@ 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,
+ linkNationalCoreStudies
+) => {
+ let body = `
+
+ 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 `
+
+
+
+
+ ${heading}
+ |
+
+
+
+ ${subject}
+ |
+
+
+
+
+
+ ${body}
+ |
+
+ `;
};
/**
@@ -211,76 +319,39 @@ 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}
- |
-
-
-
-
-
-
-
- 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
- )} |
-
-
- |
-
- `;
+ datasetTitles,
+ initialDatasetTitles,
+ submissionType,
+ projectName,
+ isNationalCoreStudies,
+ dateSubmitted,
+ linkNationalCoreStudies
+ );
// Create json content payload for attaching to email
const jsonContent = {
@@ -301,7 +372,7 @@ const _buildEmail = (aboutApplication, fullQuestions, questionAnswers, options)
- ${page}
+ ${page}
|
`;
@@ -340,11 +411,31 @@ const _buildEmail = (aboutApplication, fullQuestions, questionAnswers, options)
}
table += `
`;
}
+
+ if (submissionDescription) {
+ table += `
+
+
+ Message to data custodian:
+ ${submissionDescription}
+ |
+
`;
+ }
+
+ table += `
+
+
+ ${_displayDARLink(applicationId)}
+
+ |
+
`;
+
table += `
- ${gatewayAttributionPolicy}
+ ${gatewayAttributionPolicy}
|
`;
+
table += `
`;
return { html: table, jsonContent };
@@ -471,6 +562,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 = '';
@@ -574,7 +701,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 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;
@@ -1796,15 +2030,15 @@ const _generateMetadataOnboardingRejected = options => {
if (!_.isEmpty(comment)) {
commentHTML = `
-
- Reason for rejection
- |
-
-
-
- ${comment}
- |
-
`;
+
+ Comment from reviewer:
+ |
+
+
+
+ "${comment}"
+ |
+
`;
}
let body = `
@@ -1817,14 +2051,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}
@@ -1838,11 +2072,45 @@ const _generateMetadataOnboardingRejected = options => {
return body;
};
+const _generateMetadataOnboardingDraftDeleted = options => {
+ let { publisherName, draftDatasetName } = options;
+
+ let body = `
+
+
+
+
+
+ Draft dataset deleted
+ |
+
+
+
+
+
+
+ The draft version of ${draftDatasetName} has been deleted.
+
+ |
+
+
+
+
+
`;
+ return body;
+};
+
const _generateMessageNotification = options => {
let { firstMessage, firstname, lastname, messageDescription, openMessagesLink } = options;
let body = `
-
{
return body;
};
+const _generateEntityNotification = options => {
+ let { resourceType, resourceName, resourceLink, subject, rejectionReason, activeflag, type, resourceAuthor } = options;
+ let authorBody;
+ if (activeflag === 'active') {
+ 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 = `${resourceName} ${resourceType} has been archived by the HDR UK admin team.`;
+ } else if (activeflag === 'rejected') {
+ authorBody = `${resourceName} ${resourceType} has been rejected by the HDR UK admin team.
Reason for rejection: ${rejectionReason}`;
+ } else if (activeflag === 'add') {
+ authorBody = `${resourceName} ${resourceType} has been submitted to the HDR UK admin team for approval.`;
+ } else if (activeflag === 'edit') {
+ 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 === '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 ${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 ${resourceType} ${resourceName}`
+ : ``
+ }
+
+ |
+
+
+
+
+
+ ${!_.isEmpty(type) && type === 'admin' ? `View ${resourceType}s dashboard` : ``}
+ ${!_.isEmpty(type) && type === 'author' ? `View ${resourceType}` : ``}
+ ${!_.isEmpty(type) && type === 'co-author' ? `View ${resourceType}` : ``}
+ |
+
+
+
+
+
`;
+ return body;
+};
+
/**
* [_sendEmail]
*
@@ -1892,7 +2232,7 @@ const _sendEmail = async (to, from, subject, html, allowUnsubscribe = true, atta
// 3. Build each email object for SendGrid extracting email addresses from user object with unique unsubscribe link (to)
for (let recipient of recipients) {
- let body = html + _generateEmailFooter(recipient, allowUnsubscribe);
+ let body = _generateEmailHeader + html + _generateEmailFooter(recipient, allowUnsubscribe);
let msg = {
to: recipient.email,
from: from,
@@ -1937,6 +2277,10 @@ const _sendIntroEmail = msg => {
});
};
+const _generateEmailHeader = `
+
+ `;
+
const _generateEmailFooter = (recipient, allowUnsubscribe) => {
// 1. Generate HTML for unsubscribe link if allowed depending on context
@@ -1999,6 +2343,7 @@ export default {
generateAttachment: _generateAttachment,
//DAR
generateEmail: _generateEmail,
+ generateAmendEmail: _generateAmendEmail,
generateDARReturnedEmail: _generateDARReturnedEmail,
generateDARStatusChangedEmail: _generateDARStatusChangedEmail,
generateDARClonedEmail: _generateDARClonedEmail,
@@ -2012,6 +2357,7 @@ export default {
generateTeamNotificationEmail: _generateTeamNotificationEmail,
generateRemovedFromTeam: _generateRemovedFromTeam,
generateAddedToTeam: _generateAddedToTeam,
+ generateNewDARMessage: _generateNewDARMessage,
//Workflows
generateWorkflowAssigned: _generateWorkflowAssigned,
generateWorkflowCreated: _generateWorkflowCreated,
@@ -2019,8 +2365,10 @@ export default {
generateMetadataOnboardingSumbitted: _generateMetadataOnboardingSumbitted,
generateMetadataOnboardingApproved: _generateMetadataOnboardingApproved,
generateMetadataOnboardingRejected: _generateMetadataOnboardingRejected,
+ generateMetadataOnboardingDraftDeleted: _generateMetadataOnboardingDraftDeleted,
//generateMetadataOnboardingArchived: _generateMetadataOnboardingArchived,
//generateMetadataOnboardingUnArchived: _generateMetadataOnboardingUnArchived,
//Messages
generateMessageNotification: _generateMessageNotification,
+ generateEntityNotification: _generateEntityNotification,
};
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,
});
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..f479a4c3 100644
--- a/src/resources/workflow/workflow.controller.js
+++ b/src/resources/workflow/workflow.controller.js
@@ -1,714 +1,304 @@
+import _ from 'lodash';
+import Mongoose from 'mongoose';
+
import { PublisherModel } from '../publisher/publisher.model';
import { DataRequestModel } from '../datarequest/datarequest.model';
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 moment from 'moment';
-import _ from 'lodash';
-import mongoose from 'mongoose';
+import Controller from '../base/controller';
-// 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({ //lgtm [js/sql-injection]
+ _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 = await workflow.save().catch(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 {
- return res.status(200).json({
+ // 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);
+ 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..358fc61b
--- /dev/null
+++ b/src/resources/workflow/workflow.repository.js
@@ -0,0 +1,75 @@
+import Repository from '../base/repository';
+import { WorkflowModel } from './workflow.model';
+
+export default class WorkflowRepository extends Repository {
+ constructor() {
+ super(WorkflowModel);
+ this.workflowModel = WorkflowModel;
+ }
+
+ 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();
+ }
+
+ getWorkflowById(id, options = {}) {
+ return WorkflowModel.findOne(
+ {
+ _id: id,
+ }, //lgtm [js/sql-injection]
+ null,
+ options
+ )
+ .populate([
+ {
+ path: 'steps.reviewers',
+ model: 'User',
+ select: '_id id firstname lastname email',
+ },
+ ])
+ .lean();
+ }
+
+ async assignWorkflowToApplication(accessRecord, workflowId) {
+ // Retrieve workflow using ID from database
+ const workflow = await this.getWorkflowById(workflowId, { lean: false });
+ if (!workflow) {
+ throw new Error('Workflow could not be found');
+ }
+ // Set first workflow step active and ensure all others are false
+ const workflowObj = workflow;
+ workflowObj.steps = workflowObj.steps.map(step => {
+ 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.route.js b/src/resources/workflow/workflow.route.js
index 75910fb1..e2dd08b3 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 workflowController = new WorkflowController(workflowService);
+const logCategory = 'Workflow';
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..6af0cfac
--- /dev/null
+++ b/src/resources/workflow/workflow.service.js
@@ -0,0 +1,538 @@
+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';
+
+const bpmController = require('../bpmnworkflow/bpmnworkflow.controller');
+
+export default class WorkflowService {
+ constructor(workflowRepository) {
+ 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);
+
+ 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;
+ }
+
+ 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;
+
+ 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)) {
+ // 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;
+ }
+
+ 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,
+ 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`;
+ }
+
+ 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,
+ 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, relatedStepIndex = 0) {
+ // Extract workflow email variables
+ const { dateReviewStart = '', workflow = {} } = 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 = this.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,
+ };
+ }
+
+ 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);
+ }
+}