diff --git a/packages/component-fixture-manager/src/fixtures/fragments.js b/packages/component-fixture-manager/src/fixtures/fragments.js index 5810c6fddab79d0b8ccec78ef530309aa3a3b6e2..f8a30a48644694fb9a747f11ec7dffbc3230059b 100644 --- a/packages/component-fixture-manager/src/fixtures/fragments.js +++ b/packages/component-fixture-manager/src/fixtures/fragments.js @@ -4,8 +4,11 @@ const { reviewer, answerReviewer, recReviewer, + handlingEditor, + admin, } = require('./userData') const { standardCollID } = require('./collectionIDs') +const { user } = require('./userData') const chance = new Chance() const fragments = { @@ -38,6 +41,48 @@ const fragments = { createdOn: chance.timestamp(), updatedOn: chance.timestamp(), }, + { + recommendation: 'minor', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: chance.bool(), + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: handlingEditor.id, + createdOn: chance.timestamp(), + updatedOn: chance.timestamp(), + }, + { + recommendation: 'publish', + recommendationType: 'editorRecommendation', + comments: [ + { + content: chance.paragraph(), + public: chance.bool(), + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: admin.id, + createdOn: chance.timestamp(), + updatedOn: chance.timestamp(), + }, ], authors: [ { @@ -67,6 +112,7 @@ const fragments = { }, ], save: jest.fn(() => fragments.fragment), + owners: [user.id], }, } diff --git a/packages/component-helper-service/src/services/Collection.js b/packages/component-helper-service/src/services/Collection.js index fd58224e13bbee24983e2becb01b82c24c3dc1cc..51ab28826eec2d9b37eee43915cca6f494394df9 100644 --- a/packages/component-helper-service/src/services/Collection.js +++ b/packages/component-helper-service/src/services/Collection.js @@ -7,12 +7,27 @@ class Collection { this.collection = collection } - async updateStatusByRecommendation({ recommendation }) { - let newStatus = 'pendingApproval' - if (['minor', 'major'].includes(recommendation)) - newStatus = 'revisionRequested' + async updateStatusByRecommendation({ + recommendation, + isHandlingEditor = false, + }) { + let newStatus + if (isHandlingEditor) { + newStatus = 'pendingApproval' + if (['minor', 'major'].includes(recommendation)) { + newStatus = 'revisionRequested' + } + } else { + if (recommendation === 'minor') { + newStatus = 'reviewCompleted' + } - await this.updateStatus({ newStatus }) + if (recommendation === 'major') { + newStatus = 'reviewersInvited' + } + } + + this.updateStatus({ newStatus }) } async updateFinalStatusByRecommendation({ recommendation }) { diff --git a/packages/component-helper-service/src/services/Fragment.js b/packages/component-helper-service/src/services/Fragment.js index 37c3a22eaabad96e243408d537e87d245e0a6045..078aa44fa9202807bc264fb2fe2eebb6d355f931 100644 --- a/packages/component-helper-service/src/services/Fragment.js +++ b/packages/component-helper-service/src/services/Fragment.js @@ -81,6 +81,15 @@ class Fragment { inv => inv.role === 'reviewer' && inv.hasAnswer === false, ) } + + getHeRequestToRevision() { + const { fragment: { recommendations = [] } } = this + return recommendations.find( + rec => + rec.recommendationType === 'editorRecommendation' && + (rec.recommendation === 'minor' || rec.recommendation === 'major'), + ) + } } module.exports = Fragment diff --git a/packages/component-manuscript-manager/config/authsome-helpers.js b/packages/component-manuscript-manager/config/authsome-helpers.js index 55148df349bc9807c0d6121fe30593dd021d237b..658f47bb662ab0e414324831966daae0bf525f0c 100644 --- a/packages/component-manuscript-manager/config/authsome-helpers.js +++ b/packages/component-manuscript-manager/config/authsome-helpers.js @@ -122,7 +122,6 @@ module.exports = { setPublicStatuses, getTeamsByPermissions, filterRefusedInvitations, - // isOwner, isHandlingEditor, getUserPermissions, diff --git a/packages/component-manuscript-manager/config/authsome-mode.js b/packages/component-manuscript-manager/config/authsome-mode.js index 20e0d691891e9804d44e7912a821e560b3ccdaf9..2c80868ddf05885664bdaa937bcf571bb32d264a 100644 --- a/packages/component-manuscript-manager/config/authsome-mode.js +++ b/packages/component-manuscript-manager/config/authsome-mode.js @@ -147,6 +147,10 @@ async function authenticatedUser(user, operation, object, context) { } } + if (get(object, 'type') === 'user') { + return true + } + // allow HE to get reviewer invitations if (get(object, 'fragment.type') === 'fragment') { const collectionId = get(object, 'fragment.collectionId') @@ -215,6 +219,14 @@ async function authenticatedUser(user, operation, object, context) { if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { return true } + + // allow owner to submit a revision + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/submit' + ) { + return helpers.isOwner({ user, object: object.fragment }) + } } if (operation === 'DELETE') { @@ -224,6 +236,10 @@ async function authenticatedUser(user, operation, object, context) { ) { return helpers.isHandlingEditor({ user, object }) } + + if (get(object, 'type') === 'collection') { + return helpers.isOwner({ user, object }) + } } // If no individual permissions exist (above), fallback to unauthenticated diff --git a/packages/component-manuscript-manager/config/default.js b/packages/component-manuscript-manager/config/default.js index 276960b2fa5ce4133c55e91766a40dc4f69ea69f..241cb498d07eda9741434edeffd69545a01e56a0 100644 --- a/packages/component-manuscript-manager/config/default.js +++ b/packages/component-manuscript-manager/config/default.js @@ -40,11 +40,11 @@ module.exports = { }, heInvited: { public: 'Submitted', - private: 'HE Invited', + private: 'Handling Editor Invited', }, heAssigned: { - public: 'HE Assigned', - private: 'HE Assigned', + public: 'Handling Editor Assigned', + private: 'Handling Editor Assigned', }, reviewersInvited: { public: 'Reviewers Invited', @@ -54,10 +54,18 @@ module.exports = { public: 'Under Review', private: 'Under Review', }, + reviewCompleted: { + public: 'Under Review', + private: 'Review Completed', + }, pendingApproval: { public: 'Under Review', private: 'Pending Approval', }, + revisionRequested: { + public: 'Revision Requested', + private: 'Revision Requested', + }, rejected: { public: 'Rejected', private: 'Rejected', diff --git a/packages/component-manuscript-manager/config/test.js b/packages/component-manuscript-manager/config/test.js index 6869d659a3af2b1b643561889f4f81d4c486d1cf..9dad34bbfb2175e75ee07570b88965415556241f 100644 --- a/packages/component-manuscript-manager/config/test.js +++ b/packages/component-manuscript-manager/config/test.js @@ -41,11 +41,11 @@ module.exports = { }, heInvited: { public: 'Submitted', - private: 'HE Invited', + private: 'Handling Editor Invited', }, heAssigned: { - public: 'HE Assigned', - private: 'HE Assigned', + public: 'Handling Editor Assigned', + private: 'Handling Editor Assigned', }, reviewersInvited: { public: 'Reviewers Invited', @@ -55,10 +55,18 @@ module.exports = { public: 'Under Review', private: 'Under Review', }, + reviewCompleted: { + public: 'Under Review', + private: 'Review Completed', + }, pendingApproval: { public: 'Under Review', private: 'Pending Approval', }, + revisionRequested: { + public: 'Revision Requested', + private: 'Revision Requested', + }, rejected: { public: 'Rejected', private: 'Rejected', diff --git a/packages/component-manuscript-manager/index.js b/packages/component-manuscript-manager/index.js index 0c04f744119898af8a4ae1dc44a6eabbc5c2b24b..63ee7dbde1665993000139f654d8669b7465f8cb 100644 --- a/packages/component-manuscript-manager/index.js +++ b/packages/component-manuscript-manager/index.js @@ -1,5 +1,6 @@ module.exports = { backend: () => app => { require('./src/FragmentsRecommendations')(app) + require('./src/Fragments')(app) }, } diff --git a/packages/component-manuscript-manager/src/Fragments.js b/packages/component-manuscript-manager/src/Fragments.js new file mode 100644 index 0000000000000000000000000000000000000000..9e3569166d7dd7e7e76f9082412ab3bc8219c16a --- /dev/null +++ b/packages/component-manuscript-manager/src/Fragments.js @@ -0,0 +1,33 @@ +const bodyParser = require('body-parser') + +const Fragments = app => { + app.use(bodyParser.json()) + const basePath = '/api/collections/:collectionId/fragments/:fragmentId/submit' + const routePath = './routes/fragments' + const authBearer = app.locals.passport.authenticate('bearer', { + session: false, + }) + /** + * @api {patch} /api/collections/:collectionId/fragments/:fragmentId/submit Submit a revision for a manuscript + * @apiGroup FragmentsRecommendations + * @apiParam {collectionId} collectionId Collection id + * @apiParam {fragmentId} fragmentId Fragment id + * @apiSuccessExample {json} Success + * HTTP/1.1 200 OK + * { + * + * } + * @apiErrorExample {json} Invite user errors + * HTTP/1.1 403 Forbidden + * HTTP/1.1 400 Bad Request + * HTTP/1.1 404 Not Found + * HTTP/1.1 500 Internal Server Error + */ + app.patch( + `${basePath}`, + authBearer, + require(`${routePath}/patch`)(app.locals.models), + ) +} + +module.exports = Fragments diff --git a/packages/component-manuscript-manager/src/routes/fragments/patch.js b/packages/component-manuscript-manager/src/routes/fragments/patch.js new file mode 100644 index 0000000000000000000000000000000000000000..2dfa426ea13a3fad0cd3a3b36d2e5248280990d7 --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragments/patch.js @@ -0,0 +1,52 @@ +const { + services, + Fragment, + Collection, + authsome: authsomeHelper, +} = require('pubsweet-component-helper-service') + +module.exports = models => async (req, res) => { + const { collectionId, fragmentId } = req.params + let collection, fragment + + try { + collection = await models.Collection.find(collectionId) + if (!collection.fragments.includes(fragmentId)) + return res.status(400).json({ + error: `Collection and fragment do not match.`, + }) + fragment = await models.Fragment.find(fragmentId) + + const authsome = authsomeHelper.getAuthsome(models) + const target = { + fragment, + path: req.route.path, + } + const canPatch = await authsome.can(req.user, 'PATCH', target) + if (!canPatch) + return res.status(403).json({ + error: 'Unauthorized.', + }) + + const collectionHelper = new Collection({ collection }) + const fragmentHelper = new Fragment({ fragment }) + + const heRecommendation = fragmentHelper.getHeRequestToRevision() + if (!heRecommendation) { + return res.status(400).json({ + error: 'No Handling Editor recommendation has been found.', + }) + } + + collectionHelper.updateStatusByRecommendation({ + recommendation: heRecommendation.recommendation, + }) + + return res.status(200).json(fragment) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'Item') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } +} diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js index 302b9512403e40758a0b3acc15c5fcb397bbe3ec..53ae3e90411ee519901840258d8776a40ef9aeee 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js @@ -106,7 +106,10 @@ module.exports = models => async (req, res) => { }) } } else if (recommendationType === 'editorRecommendation') { - collectionHelper.updateStatusByRecommendation({ recommendation }) + collectionHelper.updateStatusByRecommendation({ + recommendation, + isHandlingEditor: true, + }) email.setupReviewersEmail({ recommendation, agree: true, diff --git a/packages/component-manuscript-manager/src/tests/fragments/patch.test.js b/packages/component-manuscript-manager/src/tests/fragments/patch.test.js new file mode 100644 index 0000000000000000000000000000000000000000..054d38e3feec67bb3dc33eafb4b4b5d430f4cfe5 --- /dev/null +++ b/packages/component-manuscript-manager/src/tests/fragments/patch.test.js @@ -0,0 +1,133 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +process.env.SUPPRESS_NO_CONFIG_WARNING = true + +const cloneDeep = require('lodash/cloneDeep') +const fixturesService = require('pubsweet-component-fixture-service') +const requests = require('../requests') + +const { Model, fixtures } = fixturesService +jest.mock('pubsweet-component-mail-service', () => ({ + sendNotificationEmail: jest.fn(), +})) +const reqBody = {} + +const path = '../routes/fragments/patch' +const route = { + path: '/api/collections/:collectionId/fragments/:fragmentId/submit', +} +describe('Patch fragments route handler', () => { + let testFixtures = {} + let body = {} + let models + beforeEach(() => { + testFixtures = cloneDeep(fixtures) + body = cloneDeep(reqBody) + models = Model.build(testFixtures) + }) + it('should return success when the parameters are correct', async () => { + const { user } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const res = await requests.sendRequest({ + body, + userId: user.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + }) + it('should return an error when the fragmentId does not match the collectionId', async () => { + const { user } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + collection.fragments.length = 0 + const res = await requests.sendRequest({ + body, + userId: user.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Collection and fragment do not match.') + }) + it('should return an error when the collection does not exist', async () => { + const { user } = testFixtures.users + const { fragment } = testFixtures.fragments + + const res = await requests.sendRequest({ + body, + userId: user.id, + models, + route, + path, + params: { + collectionId: 'invalid-id', + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Item not found') + }) + it('should return an error when no HE recommendation exists', async () => { + const { user } = testFixtures.users + const { fragment } = testFixtures.fragments + const { collection } = testFixtures.collections + fragment.recommendations.length = 0 + + const res = await requests.sendRequest({ + body, + userId: user.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + 'No Handling Editor recommendation has been found.', + ) + }) + it('should return an error when the request user is not the owner', async () => { + const { author } = testFixtures.users + const { fragment } = testFixtures.fragments + const { collection } = testFixtures.collections + fragment.recommendations.length = 0 + + const res = await requests.sendRequest({ + body, + userId: author.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(403) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Unauthorized.') + }) +}) diff --git a/packages/component-user-manager/src/routes/fragmentsUsers/post.js b/packages/component-user-manager/src/routes/fragmentsUsers/post.js index 8dc8df3ed862ef5fc369ee1f6c6e4496809658d7..8d9b8a4ce8475e3658f5632cc4e9267e02a3198f 100644 --- a/packages/component-user-manager/src/routes/fragmentsUsers/post.js +++ b/packages/component-user-manager/src/routes/fragmentsUsers/post.js @@ -49,6 +49,7 @@ module.exports = models => async (req, res) => { const baseUrl = services.getBaseUrl(req) const UserModel = models.User const teamHelper = new Team({ TeamModel: models.Team, fragmentId }) + const fragmentHelper = new Fragment({ fragment }) try { let user = await UserModel.findByEmail(email) @@ -71,6 +72,12 @@ module.exports = models => async (req, res) => { }) } + await fragmentHelper.addAuthor({ + user, + isSubmitting, + isCorresponding, + }) + return res.status(200).json({ ...pick(user, authorKeys), isSubmitting, @@ -102,7 +109,6 @@ module.exports = models => async (req, res) => { objectType: 'fragment', }) - const fragmentHelper = new Fragment({ fragment }) await fragmentHelper.addAuthor({ user: newUser, isSubmitting, diff --git a/packages/xpub-faraday/config/authsome-mode.js b/packages/xpub-faraday/config/authsome-mode.js index 56a74c08ce67bdfbb5b3f0328e25b67d44aea051..2c80868ddf05885664bdaa937bcf571bb32d264a 100644 --- a/packages/xpub-faraday/config/authsome-mode.js +++ b/packages/xpub-faraday/config/authsome-mode.js @@ -219,6 +219,14 @@ async function authenticatedUser(user, operation, object, context) { if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { return true } + + // allow owner to submit a revision + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/submit' + ) { + return helpers.isOwner({ user, object: object.fragment }) + } } if (operation === 'DELETE') {