diff --git a/packages/component-faraday-selectors/src/index.js b/packages/component-faraday-selectors/src/index.js index 2ad7a635be95eb0525f033a1e8de8eecafe9edb8..cde4ed57f250726a9a53cd65a92988ac04a88c91 100644 --- a/packages/component-faraday-selectors/src/index.js +++ b/packages/component-faraday-selectors/src/index.js @@ -56,10 +56,11 @@ export const getHERecommendation = (state, collectionId, fragmentId) => { ) } +const cantMakeDecisionStatuses = ['rejected', 'published', 'draft'] export const canMakeDecision = (state, collection) => { const status = get(collection, 'status') - if (!status || status === 'rejected' || status === 'published') return false + if (!status || cantMakeDecisionStatuses.includes(status)) return false const isEIC = currentUserIs(state, 'adminEiC') return isEIC && status @@ -72,3 +73,11 @@ export const canSeeReviewersReports = (state, collectionId) => { } export const canSeeEditorialComments = canSeeReviewersReports + +export const canMakeRevision = (state, collection) => { + const currentUserId = get(state, 'currentUser.user.id') + return ( + collection.status === 'revisionRequested' && + collection.owners.map(o => o.id).includes(currentUserId) + ) +} diff --git a/packages/component-fixture-manager/.gitignore b/packages/component-fixture-manager/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..3614a810088d89d9ccaa28d82401545634874a18 --- /dev/null +++ b/packages/component-fixture-manager/.gitignore @@ -0,0 +1,8 @@ +_build/ +api/ +logs/ +node_modules/ +uploads/ +.env.* +.env +config/local*.* \ No newline at end of file diff --git a/packages/component-fixture-manager/README.md b/packages/component-fixture-manager/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a0ac7afbf2c8255c8a94f4f2ff525dd245cc3c93 --- /dev/null +++ b/packages/component-fixture-manager/README.md @@ -0,0 +1,2 @@ +# Helper Service + diff --git a/packages/component-fixture-manager/index.js b/packages/component-fixture-manager/index.js new file mode 100644 index 0000000000000000000000000000000000000000..dd95e77d96237d61fc1a8f03d5f2d7d2f80a7e0f --- /dev/null +++ b/packages/component-fixture-manager/index.js @@ -0,0 +1 @@ +module.exports = require('./src/Fixture') diff --git a/packages/component-fixture-manager/package.json b/packages/component-fixture-manager/package.json new file mode 100644 index 0000000000000000000000000000000000000000..fe566ea163b877842eba597a058293b0a0af7fbe --- /dev/null +++ b/packages/component-fixture-manager/package.json @@ -0,0 +1,25 @@ +{ + "name": "pubsweet-component-fixture-service", + "version": "0.0.1", + "description": "fixture service component for pubsweet", + "license": "MIT", + "author": "Collaborative Knowledge Foundation", + "files": [ + "src" + ], + "main": "index.js", + "repository": { + "type": "git", + "url": "https://gitlab.coko.foundation/xpub/xpub-faraday", + "path": "component-fixture-service" + }, + "dependencies": { + }, + "peerDependencies": { + "@pubsweet/logger": "^0.0.1", + "pubsweet-server": "^1.0.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/component-fixture-manager/src/Fixture.js b/packages/component-fixture-manager/src/Fixture.js new file mode 100644 index 0000000000000000000000000000000000000000..c2c53d05d677167696b1dad66e7b1f294a23ae10 --- /dev/null +++ b/packages/component-fixture-manager/src/Fixture.js @@ -0,0 +1,7 @@ +const fixtures = require('./fixtures/fixtures') +const Model = require('./helpers/Model') + +module.exports = { + fixtures, + Model, +} diff --git a/packages/component-fixture-manager/src/fixtures/collectionIDs.js b/packages/component-fixture-manager/src/fixtures/collectionIDs.js new file mode 100644 index 0000000000000000000000000000000000000000..0633f816e39359721e73dd32252279f05bcdff5e --- /dev/null +++ b/packages/component-fixture-manager/src/fixtures/collectionIDs.js @@ -0,0 +1,8 @@ +const Chance = require('chance') + +const chance = new Chance() +const collId = chance.guid() + +module.exports = { + standardCollID: collId, +} diff --git a/packages/component-manuscript-manager/src/tests/fixtures/collections.js b/packages/component-fixture-manager/src/fixtures/collections.js similarity index 65% rename from packages/component-manuscript-manager/src/tests/fixtures/collections.js rename to packages/component-fixture-manager/src/fixtures/collections.js index bef6132199981a79b87ea86440146eaab5891883..ead1ed38cabd8e5eaa615a1fa7a9e5b84d6d3052 100644 --- a/packages/component-manuscript-manager/src/tests/fixtures/collections.js +++ b/packages/component-fixture-manager/src/fixtures/collections.js @@ -1,29 +1,17 @@ const Chance = require('chance') -const { - user, - handlingEditor, - author, - reviewer, - answerReviewer, -} = require('./userData') +const { user, handlingEditor, answerHE } = require('./userData') const { fragment } = require('./fragments') +const { standardCollID } = require('./collectionIDs') const chance = new Chance() const collections = { collection: { - id: chance.guid(), + id: standardCollID, title: chance.sentence(), type: 'collection', fragments: [fragment.id], owners: [user.id], save: jest.fn(), - authors: [ - { - userId: author.id, - isSubmitting: true, - isCorresponding: false, - }, - ], invitations: [ { id: chance.guid(), @@ -36,19 +24,10 @@ const collections = { }, { id: chance.guid(), - role: 'reviewer', - hasAnswer: false, - isAccepted: false, - userId: reviewer.id, - invitedOn: chance.timestamp(), - respondedOn: null, - }, - { - id: chance.guid(), - role: 'reviewer', + role: 'handlingEditor', hasAnswer: true, isAccepted: false, - userId: answerReviewer.id, + userId: answerHE.id, invitedOn: chance.timestamp(), respondedOn: chance.timestamp(), }, diff --git a/packages/component-invite/src/tests/fixtures/fixtures.js b/packages/component-fixture-manager/src/fixtures/fixtures.js similarity index 100% rename from packages/component-invite/src/tests/fixtures/fixtures.js rename to packages/component-fixture-manager/src/fixtures/fixtures.js diff --git a/packages/component-fixture-manager/src/fixtures/fragments.js b/packages/component-fixture-manager/src/fixtures/fragments.js new file mode 100644 index 0000000000000000000000000000000000000000..f8a30a48644694fb9a747f11ec7dffbc3230059b --- /dev/null +++ b/packages/component-fixture-manager/src/fixtures/fragments.js @@ -0,0 +1,119 @@ +const Chance = require('chance') +const { + submittingAuthor, + reviewer, + answerReviewer, + recReviewer, + handlingEditor, + admin, +} = require('./userData') +const { standardCollID } = require('./collectionIDs') +const { user } = require('./userData') + +const chance = new Chance() +const fragments = { + fragment: { + id: chance.guid(), + collectionId: standardCollID, + metadata: { + title: chance.sentence(), + abstract: chance.paragraph(), + }, + recommendations: [ + { + recommendation: 'publish', + recommendationType: 'review', + comments: [ + { + content: chance.paragraph(), + public: chance.bool(), + files: [ + { + id: chance.guid(), + name: 'file.pdf', + size: chance.natural(), + }, + ], + }, + ], + id: chance.guid(), + userId: recReviewer.id, + 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: [ + { + id: submittingAuthor.id, + isSubmitting: true, + isCorresponding: false, + }, + ], + invitations: [ + { + id: chance.guid(), + role: 'reviewer', + hasAnswer: false, + isAccepted: false, + userId: reviewer.id, + invitedOn: chance.timestamp(), + respondedOn: null, + }, + { + id: chance.guid(), + role: 'reviewer', + hasAnswer: true, + isAccepted: false, + userId: answerReviewer.id, + invitedOn: chance.timestamp(), + respondedOn: chance.timestamp(), + }, + ], + save: jest.fn(() => fragments.fragment), + owners: [user.id], + }, +} + +module.exports = fragments diff --git a/packages/component-user-manager/src/tests/fixtures/teamIDs.js b/packages/component-fixture-manager/src/fixtures/teamIDs.js similarity index 79% rename from packages/component-user-manager/src/tests/fixtures/teamIDs.js rename to packages/component-fixture-manager/src/fixtures/teamIDs.js index f8cfc51464701c2ab93b312dd73ed3ea5e2f7ea3..83ce3a556417bc4650efdc9ff478b8bad64a9f13 100644 --- a/packages/component-user-manager/src/tests/fixtures/teamIDs.js +++ b/packages/component-fixture-manager/src/fixtures/teamIDs.js @@ -2,9 +2,11 @@ const Chance = require('chance') const chance = new Chance() const heID = chance.guid() +const revId = chance.guid() const authorID = chance.guid() module.exports = { heTeamID: heID, + revTeamID: revId, authorTeamID: authorID, } diff --git a/packages/component-invite/src/tests/fixtures/teams.js b/packages/component-fixture-manager/src/fixtures/teams.js similarity index 59% rename from packages/component-invite/src/tests/fixtures/teams.js rename to packages/component-fixture-manager/src/fixtures/teams.js index 7e4611da3489cceb1308e9ffaf072a85866f4ca2..84d784b4b4be4c38a4757fccada612705bc8b1b1 100644 --- a/packages/component-invite/src/tests/fixtures/teams.js +++ b/packages/component-fixture-manager/src/fixtures/teams.js @@ -1,8 +1,12 @@ const users = require('./users') const collections = require('./collections') -const { heTeamID, revTeamID } = require('./teamIDs') +const fragments = require('./fragments') + +const { heTeamID, revTeamID, authorTeamID } = require('./teamIDs') +const { submittingAuthor } = require('./userData') const { collection } = collections +const { fragment } = fragments const { handlingEditor, reviewer } = users const teams = { heTeam: { @@ -29,13 +33,29 @@ const teams = { group: 'reviewer', name: 'reviewer', object: { - type: 'collection', - id: collection.id, + type: 'fragment', + id: fragment.id, }, members: [reviewer.id], save: jest.fn(() => teams.revTeam), updateProperties: jest.fn(() => teams.revTeam), id: revTeamID, }, + authorTeam: { + teamType: { + name: 'author', + permissions: 'author', + }, + group: 'author', + name: 'author', + object: { + type: 'fragment', + id: fragment.id, + }, + members: [submittingAuthor.id], + save: jest.fn(() => teams.authorTeam), + updateProperties: jest.fn(() => teams.authorTeam), + id: authorTeamID, + }, } module.exports = teams diff --git a/packages/component-manuscript-manager/src/tests/fixtures/userData.js b/packages/component-fixture-manager/src/fixtures/userData.js similarity index 86% rename from packages/component-manuscript-manager/src/tests/fixtures/userData.js rename to packages/component-fixture-manager/src/fixtures/userData.js index 4e3b994a70b782f25ec85e0f41410824dd4307c4..9920552365178966754bf7681df61eaa7a15878c 100644 --- a/packages/component-manuscript-manager/src/tests/fixtures/userData.js +++ b/packages/component-fixture-manager/src/fixtures/userData.js @@ -15,5 +15,7 @@ module.exports = { author: generateUserData(), reviewer: generateUserData(), answerReviewer: generateUserData(), + submittingAuthor: generateUserData(), recReviewer: generateUserData(), + answerHE: generateUserData(), } diff --git a/packages/component-invite/src/tests/fixtures/users.js b/packages/component-fixture-manager/src/fixtures/users.js similarity index 65% rename from packages/component-invite/src/tests/fixtures/users.js rename to packages/component-fixture-manager/src/fixtures/users.js index c5f0a5e41f8bca54d62c6534ef263a6de77c99bc..29c50f9327b03f6551aca7cbcf48526446726a0f 100644 --- a/packages/component-invite/src/tests/fixtures/users.js +++ b/packages/component-fixture-manager/src/fixtures/users.js @@ -1,4 +1,4 @@ -const { heTeamID, revTeamID } = require('./teamIDs') +const { heTeamID, revTeamID, authorTeamID } = require('./teamIDs') const { handlingEditor, user, @@ -6,6 +6,9 @@ const { author, reviewer, answerReviewer, + submittingAuthor, + recReviewer, + answerHE, } = require('./userData') const Chance = require('chance') @@ -51,6 +54,21 @@ const users = { handlingEditor: true, title: 'Mr', }, + answerHE: { + type: 'user', + username: chance.word(), + email: answerHE.email, + password: 'password', + admin: false, + id: answerHE.id, + firstName: answerHE.firstName, + lastName: answerHE.lastName, + teams: [heTeamID], + save: jest.fn(() => users.answerHE), + editorInChief: false, + handlingEditor: true, + title: 'Mr', + }, user: { type: 'user', username: chance.word(), @@ -65,6 +83,8 @@ const users = { title: 'Mr', save: jest.fn(() => users.user), isConfirmed: false, + updateProperties: jest.fn(() => users.user), + teams: [], }, author: { type: 'user', @@ -79,6 +99,8 @@ const users = { title: 'Mr', save: jest.fn(() => users.author), isConfirmed: true, + passwordResetToken: chance.hash(), + teams: [authorTeamID], }, reviewer: { type: 'user', @@ -112,6 +134,36 @@ const users = { teams: [revTeamID], invitationToken: 'inv-token-123', }, + submittingAuthor: { + type: 'user', + username: 'sauthor', + email: submittingAuthor.email, + password: 'password', + admin: false, + id: submittingAuthor.id, + passwordResetToken: chance.hash(), + firstName: submittingAuthor.firstName, + lastName: submittingAuthor.lastName, + affiliation: chance.company(), + title: 'Mr', + save: jest.fn(() => users.submittingAuthor), + isConfirmed: false, + }, + recReviewer: { + type: 'user', + username: chance.word(), + email: recReviewer.email, + password: 'password', + admin: false, + id: recReviewer.id, + firstName: recReviewer.firstName, + lastName: recReviewer.lastName, + affiliation: chance.company(), + title: 'Mr', + save: jest.fn(() => users.recReviewer), + isConfirmed: true, + teams: [revTeamID], + }, } module.exports = users diff --git a/packages/component-invite/src/tests/helpers/Model.js b/packages/component-fixture-manager/src/helpers/Model.js similarity index 93% rename from packages/component-invite/src/tests/helpers/Model.js rename to packages/component-fixture-manager/src/helpers/Model.js index df543155a679c4c7e2bb91a8304835c458e38d44..9ff60517c6f1ce111f22a5a4f1e5369737c0a9c6 100644 --- a/packages/component-invite/src/tests/helpers/Model.js +++ b/packages/component-fixture-manager/src/helpers/Model.js @@ -1,5 +1,3 @@ -// const fixtures = require('../fixtures/fixtures') - const UserMock = require('../mocks/User') const TeamMock = require('../mocks/Team') @@ -18,12 +16,17 @@ const build = fixtures => { find: jest.fn(id => findMock(id, 'fragments', fixtures)), }, } + UserMock.find = jest.fn(id => findMock(id, 'users', fixtures)) UserMock.findByEmail = jest.fn(email => findByEmailMock(email, fixtures)) UserMock.all = jest.fn(() => Object.values(fixtures.users)) UserMock.findOneByField = jest.fn((field, value) => findOneByFieldMock(field, value, 'users', fixtures), ) + UserMock.updateProperties = jest.fn(user => + updatePropertiesMock(user, 'users'), + ) + TeamMock.find = jest.fn(id => findMock(id, 'teams', fixtures)) TeamMock.updateProperties = jest.fn(team => updatePropertiesMock(team, 'teams', fixtures), @@ -40,7 +43,7 @@ const findMock = (id, type, fixtures) => { fixtureObj => fixtureObj.id === id, ) - if (foundObj === undefined) return Promise.reject(notFoundError) + if (!foundObj) return Promise.reject(notFoundError) return Promise.resolve(foundObj) } diff --git a/packages/component-invite/src/tests/mocks/Team.js b/packages/component-fixture-manager/src/mocks/Team.js similarity index 100% rename from packages/component-invite/src/tests/mocks/Team.js rename to packages/component-fixture-manager/src/mocks/Team.js diff --git a/packages/component-invite/src/tests/mocks/User.js b/packages/component-fixture-manager/src/mocks/User.js similarity index 100% rename from packages/component-invite/src/tests/mocks/User.js rename to packages/component-fixture-manager/src/mocks/User.js diff --git a/packages/component-helper-service/src/services/Collection.js b/packages/component-helper-service/src/services/Collection.js index 6e502735b7ae9ce777bbda52be940b4e06abf953..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 }) { @@ -36,42 +51,10 @@ class Collection { async updateStatus({ newStatus }) { this.collection.status = newStatus - this.collection.visibleStatus = statuses[this.collection.status].private + this.collection.visibleStatus = statuses[this.collection.status].public await this.collection.save() } - async getAuthorData({ UserModel }) { - const { collection: { authors } } = this - const submittingAuthorData = authors.find( - author => author.isSubmitting === true, - ) - const submittingAuthor = await UserModel.find(submittingAuthorData.userId) - const authorsPromises = authors.map(async author => { - const user = await UserModel.find(author.userId) - return `${user.firstName} ${user.lastName}` - }) - const authorsList = await Promise.all(authorsPromises) - - return { - authorsList, - submittingAuthor, - } - } - - getReviewerInvitations({ agree = true }) { - const { collection: { invitations } } = this - return agree - ? invitations.filter( - inv => - inv.role === 'reviewer' && - inv.hasAnswer === true && - inv.isAccepted === true, - ) - : invitations.filter( - inv => inv.role === 'reviewer' && inv.hasAnswer === false, - ) - } - async addHandlingEditor({ user, invitation }) { this.collection.handlingEditor = { id: user.id, diff --git a/packages/component-helper-service/src/services/Email.js b/packages/component-helper-service/src/services/Email.js index 49a106d8d72305dcf029accd2bd0a094a52cab45..7e7bcd4510219d5a712d0c71b0f2f05585f075ed 100644 --- a/packages/component-helper-service/src/services/Email.js +++ b/packages/component-helper-service/src/services/Email.js @@ -1,4 +1,4 @@ -const Collection = require('./Collection') +const Fragment = require('./Fragment') const User = require('./User') const get = require('lodash/get') const config = require('config') @@ -29,15 +29,17 @@ class Email { recommendation = {}, isSubmitted = false, agree = false, + FragmentModel, }) { const { UserModel, collection, - parsedFragment: { recommendations, title, type }, + parsedFragment: { recommendations, title, type, id }, authors: { submittingAuthor: { firstName = '', lastName = '' } }, } = this - const collectionHelper = new Collection({ collection }) - const reviewerInvitations = collectionHelper.getReviewerInvitations({ + const fragment = await FragmentModel.find(id) + const fragmentHelper = new Fragment({ fragment }) + const reviewerInvitations = fragmentHelper.getReviewerInvitations({ agree, }) @@ -98,7 +100,11 @@ class Email { ) } - async setupAuthorsEmail({ requestToRevision = false, publish = false }) { + async setupAuthorsEmail({ + requestToRevision = false, + publish = false, + FragmentModel, + }) { const { baseUrl, collection, @@ -123,7 +129,8 @@ class Email { }, ] } else { - toAuthors = collection.authors.map(author => ({ + const fragment = await FragmentModel.find(id) + toAuthors = fragment.authors.map(author => ({ email: author.email, name: `${author.firstName} ${author.lastName}`, })) @@ -133,7 +140,7 @@ class Email { baseUrl, emailType, toEmail: toAuthor.email, - handlingEditorName: collection.handlingEditor.name, + handlingEditorName: get(collection, 'handlingEditor.name'), meta: { collection, authorNoteText, @@ -150,6 +157,7 @@ class Email { async setupHandlingEditorEmail({ publish = false, + returnWithComments = false, reviewSubmitted = false, reviewerName = '', }) { @@ -157,16 +165,17 @@ class Email { baseUrl, UserModel, collection, - parsedFragment: { title, id }, + parsedFragment: { eicComments = '', title, id }, authors: { submittingAuthor: { firstName = '', lastName = '' } }, } = this const userHelper = new User({ UserModel }) const eic = await userHelper.getEditorInChief() - const toEmail = collection.handlingEditor.email + const toEmail = get(collection, 'handlingEditor.email') let emailType = publish ? 'he-manuscript-published' : 'he-manuscript-rejected' if (reviewSubmitted) emailType = 'review-submitted' + if (returnWithComments) emailType = 'he-manuscript-return-with-comments' mailService.sendNotificationEmail({ toEmail, baseUrl, @@ -174,8 +183,9 @@ class Email { meta: { collection, reviewerName, + eicComments, eicName: `${eic.firstName} ${eic.lastName}`, - handlingEditorName: collection.handlingEditor.name, + handlingEditorName: get(collection, 'handlingEditor.name') || '', emailSubject: `${collection.customId}: Manuscript Decision`, fragment: { id, @@ -234,7 +244,7 @@ class Email { collection, fragment: { id }, eicName: `${eic.firstName} ${eic.lastName}`, - handlingEditorName: collection.handlingEditor.name, + handlingEditorName: get(collection, 'handlingEditor.name'), }, }) } @@ -322,7 +332,7 @@ class Email { } async setupReviewerUnassignEmail({ user, authorName }) { - const { collection, fragment: { title } } = this + const { collection, parsedFragment: { title = '' } } = this await mailService.sendNotificationEmail({ toEmail: user.email, diff --git a/packages/component-helper-service/src/services/Fragment.js b/packages/component-helper-service/src/services/Fragment.js index d4be48eec23c41fd8e08e2d5695a34a705454fb1..078aa44fa9202807bc264fb2fe2eebb6d355f931 100644 --- a/packages/component-helper-service/src/services/Fragment.js +++ b/packages/component-helper-service/src/services/Fragment.js @@ -1,13 +1,16 @@ +const get = require('lodash/get') + class Fragment { constructor({ fragment }) { this.fragment = fragment } + async getFragmentData({ handlingEditor = {} }) { - const { fragment: { metadata, recommendations = [], id } } = this + const { fragment: { metadata = {}, recommendations = [], id } } = this const heRecommendation = recommendations.find( rec => rec.userId === handlingEditor.id, ) - let { title, abstract } = metadata + let { title = '', abstract = '' } = metadata const { type } = metadata title = title.replace(/<(.|\n)*?>/g, '') abstract = abstract ? abstract.replace(/<(.|\n)*?>/g, '') : '' @@ -21,6 +24,72 @@ class Fragment { heRecommendation, } } + + async addAuthor({ user, isSubmitting, isCorresponding }) { + const { fragment } = this + fragment.authors = fragment.authors || [] + const author = { + id: user.id, + firstName: user.firstName || '', + lastName: user.lastName || '', + email: user.email, + title: user.title || '', + affiliation: user.affiliation || '', + isSubmitting, + isCorresponding, + } + fragment.authors.push(author) + await fragment.save() + + return author + } + + async getAuthorData({ UserModel }) { + const { fragment: { authors = [] } } = this + const submittingAuthorData = authors.find(author => author.isSubmitting) + + try { + const submittingAuthor = await UserModel.find( + get(submittingAuthorData, 'id'), + ) + + const authorsPromises = authors.map(async author => { + const user = await UserModel.find(author.id) + return `${user.firstName} ${user.lastName}` + }) + const authorsList = await Promise.all(authorsPromises) + + return { + authorsList, + submittingAuthor, + } + } catch (e) { + throw e + } + } + + getReviewerInvitations({ agree = true }) { + const { fragment: { invitations = [] } } = this + return agree + ? invitations.filter( + inv => + inv.role === 'reviewer' && + inv.hasAnswer === true && + inv.isAccepted === true, + ) + : invitations.filter( + 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-helper-service/src/services/Invitation.js b/packages/component-helper-service/src/services/Invitation.js index ac595dabf0ee0f4880a343f29c8c4376c171a1e6..ce06b5ad4d8617a2c97506bc5759cdbb10200906 100644 --- a/packages/component-helper-service/src/services/Invitation.js +++ b/packages/component-helper-service/src/services/Invitation.js @@ -33,7 +33,7 @@ class Invitation { return { invitedOn, respondedOn, status, id } } - async setupInvitation({ collection }) { + async createInvitation({ parentObject }) { const { userId, role } = this const invitation = { role, @@ -44,9 +44,10 @@ class Invitation { userId, respondedOn: null, } - collection.invitations = collection.invitations || [] - collection.invitations.push(invitation) - collection = await collection.save() + parentObject.invitations = parentObject.invitations || [] + parentObject.invitations.push(invitation) + await parentObject.save() + return invitation } @@ -56,6 +57,22 @@ class Invitation { invitation.userId === this.userId && invitation.role === this.role, ) } + + validateInvitation({ invitation }) { + if (invitation === undefined) + return { status: 404, error: 'Invitation not found.' } + + if (invitation.hasAnswer) + return { status: 400, error: 'Invitation has already been answered.' } + + if (invitation.userId !== this.userId) + return { + status: 403, + error: 'User is not allowed to modify this invitation.', + } + + return { error: null } + } } module.exports = Invitation diff --git a/packages/component-helper-service/src/services/Team.js b/packages/component-helper-service/src/services/Team.js index b5b70ee1eb1f4ecb467f985ada697602f1247811..9d8d6148a8159b24180e22164247fcf3874aa2fa 100644 --- a/packages/component-helper-service/src/services/Team.js +++ b/packages/component-helper-service/src/services/Team.js @@ -2,13 +2,16 @@ const logger = require('@pubsweet/logger') const get = require('lodash/get') class Team { - constructor({ TeamModel = {}, collectionId = '' }) { + constructor({ TeamModel = {}, fragmentId = '', collectionId = '' }) { this.TeamModel = TeamModel + this.fragmentId = fragmentId this.collectionId = collectionId } - async createNewTeam({ role, userId }) { - const { collectionId, TeamModel } = this + async createTeam({ role, userId, objectType }) { + const { fragmentId, TeamModel, collectionId } = this + const objectId = objectType === 'collection' ? collectionId : fragmentId + let permissions, group, name switch (role) { case 'handlingEditor': @@ -38,8 +41,8 @@ class Team { group, name, object: { - type: 'collection', - id: collectionId, + type: objectType, + id: objectId, }, members: [userId], } @@ -48,15 +51,16 @@ class Team { return team } - async setupManuscriptTeam({ user, role }) { - const { TeamModel, collectionId } = this + async setupTeam({ user, role, objectType }) { + const { TeamModel, fragmentId, collectionId } = this + const objectId = objectType === 'collection' ? collectionId : fragmentId const teams = await TeamModel.all() user.teams = user.teams || [] let foundTeam = teams.find( team => team.group === role && - team.object.type === 'collection' && - team.object.id === collectionId, + team.object.type === objectType && + team.object.id === objectId, ) if (foundTeam !== undefined) { @@ -75,7 +79,7 @@ class Team { logger.error(e) } } else { - const team = await this.createNewTeam({ role, userId: user.id }) + const team = await this.createTeam({ role, userId: user.id, objectType }) user.teams.push(team.id) await user.save() return team @@ -90,16 +94,18 @@ class Team { await team.save() } - async getTeamMembersByCollection({ role }) { - const { TeamModel, collectionId } = this + async getTeamMembers({ role, objectType }) { + const { TeamModel, collectionId, fragmentId } = this + const objectId = objectType === 'collection' ? collectionId : fragmentId const teams = await TeamModel.all() + const members = get( teams.find( team => team.group === role && - team.object.type === 'collection' && - team.object.id === collectionId, + team.object.type === objectType && + team.object.id === objectId, ), 'members', ) @@ -107,20 +113,22 @@ class Team { return members } - async getTeamByGroupAndCollection({ role }) { - const { TeamModel, collectionId } = this + async getTeam({ role, objectType }) { + const { TeamModel, fragmentId, collectionId } = this + const objectId = objectType === 'collection' ? collectionId : fragmentId const teams = await TeamModel.all() return teams.find( team => team.group === role && - team.object.type === 'collection' && - team.object.id === collectionId, + team.object.type === objectType && + team.object.id === objectId, ) } - async updateHETeam({ collection, role, user }) { - const team = await this.getTeamByGroupAndCollection({ + async deleteHandlingEditor({ collection, role, user }) { + const team = await this.getTeam({ role, + objectType: 'collection', }) delete collection.handlingEditor await this.removeTeamMember({ teamId: team.id, userId: user.id }) diff --git a/packages/component-helper-service/src/services/User.js b/packages/component-helper-service/src/services/User.js index ed69cd655e6acd1445dacab855d2ddbde74dd7b9..7d9d3e520951e13f9bc1e2aeeac2cbf7e5528c00 100644 --- a/packages/component-helper-service/src/services/User.js +++ b/packages/component-helper-service/src/services/User.js @@ -31,12 +31,8 @@ class User { let newUser = new UserModel(userBody) - try { - newUser = await newUser.save() - return newUser - } catch (e) { - logger.error(e) - } + newUser = await newUser.save() + return newUser } async setupNewUser({ url, role, invitationType, body = {} }) { diff --git a/packages/component-helper-service/src/services/authsome.js b/packages/component-helper-service/src/services/authsome.js index 212cee2a3ea23a424b1f77dde2f7dd4cf888b2a0..b2215bb20f4d5d50cd0f75fff2607ba79f514443 100644 --- a/packages/component-helper-service/src/services/authsome.js +++ b/packages/component-helper-service/src/services/authsome.js @@ -12,6 +12,7 @@ const getAuthsome = models => models: { Collection: { find: id => models.Collection.find(id), + all: () => models.Collection.all(), }, Fragment: { find: id => models.Fragment.find(id), diff --git a/packages/component-invite/config/authsome-helpers.js b/packages/component-invite/config/authsome-helpers.js index b5f42492b077cee12d5a09d97404fb559e601f5e..55148df349bc9807c0d6121fe30593dd021d237b 100644 --- a/packages/component-invite/config/authsome-helpers.js +++ b/packages/component-invite/config/authsome-helpers.js @@ -14,9 +14,9 @@ const parseAuthorsData = (coll, matchingCollPerm) => { const setPublicStatuses = (coll, matchingCollPerm) => { const status = get(coll, 'status') || 'draft' - coll.visibleStatus = statuses[status].public - if (!publicStatusesPermissions.includes(matchingCollPerm.permission)) { - coll.visibleStatus = statuses[coll.status].private + // coll.visibleStatus = statuses[status].public + if (publicStatusesPermissions.includes(matchingCollPerm.permission)) { + coll.visibleStatus = statuses[status].public } } @@ -44,7 +44,10 @@ const filterObjectData = ( rec => rec.userId === user.id, ) } - + parseAuthorsData(object, matchingCollPerm) + if (['reviewer', 'handlingEditor'].includes(matchingCollPerm.permission)) { + return filterRefusedInvitations(object, user) + } return object } const matchingCollPerm = collectionsPermissions.find( @@ -52,32 +55,77 @@ const filterObjectData = ( ) if (matchingCollPerm === undefined) return null setPublicStatuses(object, matchingCollPerm) - parseAuthorsData(object, matchingCollPerm) - if (['reviewer', 'handlingEditor'].includes(matchingCollPerm.permission)) { - return filterRefusedInvitations(object, user) - } return object } -const getTeamsByPermissions = async (teamIds, permissions, TeamModel) => { - const teams = await Promise.all( +const getTeamsByPermissions = async ( + teamIds = [], + permissions = [], + TeamModel, +) => + (await Promise.all( teamIds.map(async teamId => { const team = await TeamModel.find(teamId) - if (permissions.includes(team.teamType.permissions)) { - return team + if (!permissions.includes(team.teamType.permissions)) { + return null } - return null + return team }), + )).filter(Boolean) + +const heIsInvitedToFragment = async ({ user, Team, collectionId }) => + (await getTeamsByPermissions(user.teams, ['handlingEditor'], Team)).some( + // user is a member of the team with access to the fragment's parent collection + t => t.members.includes(user.id) && t.object.id === collectionId, ) - return teams.filter(Boolean) +const getUserPermissions = async ({ + user, + Team, + mapFn = t => ({ + objectId: t.object.id, + objectType: t.object.type, + role: t.teamType.permissions, + }), +}) => + (await Promise.all(user.teams.map(teamId => Team.find(teamId)))).map(mapFn) + +const isOwner = ({ user: { id }, object }) => { + if (object.owners.includes(id)) return true + return !!object.owners.find(own => own.id === id) +} + +const hasPermissionForObject = async ({ user, object, Team, roles = [] }) => { + const userPermissions = await getUserPermissions({ + user, + Team, + }) + + return !!userPermissions.find(p => { + const hasObject = + p.objectId === get(object, 'fragment.id') || + p.objectId === get(object, 'fragment.collectionId') + if (roles.length > 0) { + return hasObject && roles.includes(p.role) + } + return hasObject + }) } +const isHandlingEditor = ({ user, object }) => + get(object, 'collection.handlingEditor.id') === user.id + module.exports = { + filterObjectData, parseAuthorsData, setPublicStatuses, - filterRefusedInvitations, - filterObjectData, getTeamsByPermissions, + filterRefusedInvitations, + // + isOwner, + isHandlingEditor, + getUserPermissions, + heIsInvitedToFragment, + hasPermissionForObject, } diff --git a/packages/component-invite/config/authsome-mode.js b/packages/component-invite/config/authsome-mode.js index 762998f83e80d5e678595bb8bf7e57072607adbe..8a92a1301df7ea1bed2dc105beb44834cdb9b16f 100644 --- a/packages/component-invite/config/authsome-mode.js +++ b/packages/component-invite/config/authsome-mode.js @@ -4,6 +4,7 @@ const omit = require('lodash/omit') const helpers = require('./authsome-helpers') async function teamPermissions(user, operation, object, context) { + const { models } = context const permissions = ['handlingEditor', 'author', 'reviewer'] const teams = await helpers.getTeamsByPermissions( user.teams, @@ -11,22 +12,38 @@ async function teamPermissions(user, operation, object, context) { context.models.Team, ) - const collectionsPermissions = await Promise.all( + let collectionsPermissions = await Promise.all( teams.map(async team => { - const collection = await context.models.Collection.find(team.object.id) + let collection + if (team.object.type === 'collection') { + collection = await models.Collection.find(team.object.id) + } else if (team.object.type === 'fragment') { + const fragment = await models.Fragment.find(team.object.id) + collection = await models.Collection.find(fragment.collectionId) + } + if ( + collection.status === 'rejected' && + team.teamType.permissions === 'reviewer' + ) + return null const collPerm = { id: collection.id, permission: team.teamType.permissions, } const objectType = get(object, 'type') - if (objectType === 'fragment' && collection.fragments.includes(object.id)) - collPerm.fragmentId = object.id + if (objectType === 'fragment') { + if (collection.fragments.includes(object.id)) + collPerm.fragmentId = object.id + else return null + } + if (objectType === 'collection') + if (object.id !== collection.id) return null return collPerm }), ) - - if (collectionsPermissions.length === 0) return {} + collectionsPermissions = collectionsPermissions.filter(cp => cp !== null) + if (collectionsPermissions.length === 0) return false return { filter: filterParam => { @@ -109,11 +126,16 @@ async function authenticatedUser(user, operation, object, context) { return true } - // Allow the authenticated user to GET collections they own - if (operation === 'GET' && object === '/collections/') { - return { - filter: collection => collection.owners.includes(user.id), + // allow authenticate owners full pass for a collection + if (get(object, 'type') === 'collection') { + if (operation === 'PATCH') { + return { + filter: collection => omit(collection, 'filtered'), + } } + if (object.owners.includes(user.id)) return true + const owner = object.owners.find(own => own.id === user.id) + if (owner !== undefined) return true } // Allow owners of a collection to GET its teams, e.g. @@ -170,32 +192,36 @@ async function authenticatedUser(user, operation, object, context) { return false } - // only allow a reviewer to submit and to modify a recommendation + // only allow a reviewer and an HE to submit and to modify a recommendation if ( ['POST', 'PATCH'].includes(operation) && - get(object.collection, 'type') === 'collection' && object.path.includes('recommendations') ) { - const collection = await context.models.Collection.find( - get(object.collection, 'id'), - ) + const authsomeObject = get(object, 'authsomeObject') + const teams = await helpers.getTeamsByPermissions( user.teams, - ['reviewer'], + ['reviewer', 'handlingEditor'], context.models.Team, ) + if (teams.length === 0) return false - const matchingTeam = teams.find(team => team.object.id === collection.id) + const matchingTeam = teams.find( + team => team.object.id === authsomeObject.id, + ) + if (matchingTeam) return true return false } - if (user.teams.length !== 0 && operation === 'GET') { + if (user.teams.length !== 0 && ['GET'].includes(operation)) { const permissions = await teamPermissions(user, operation, object, context) if (permissions) { return permissions } + + return false } if (get(object, 'type') === 'fragment') { @@ -206,19 +232,6 @@ async function authenticatedUser(user, operation, object, context) { } } - if (get(object, 'type') === 'collection') { - if (['GET', 'DELETE'].includes(operation)) { - return true - } - - // Only allow filtered updating (mirroring filtered creation) for non-admin users) - if (operation === 'PATCH') { - return { - filter: collection => omit(collection, 'filtered'), - } - } - } - // A user can GET, DELETE and PATCH itself if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { if (['GET', 'DELETE', 'PATCH'].includes(operation)) { @@ -240,7 +253,7 @@ const authsomeMode = async (userId, operation, object, context) => { const user = await context.models.User.find(userId) // Admins and editor in chiefs can do anything - if (user && (user.admin === true || user.editorInChief === true)) return true + if (user && (user.admin || user.editorInChief)) return true if (user) { return authenticatedUser(user, operation, object, context) diff --git a/packages/component-invite/index.js b/packages/component-invite/index.js index 0e21fe5223f0a0a71bebd53d509b0bef3a5e3e02..f9bc6f5a6ef371301ad40dc80c6f06c59b67577f 100644 --- a/packages/component-invite/index.js +++ b/packages/component-invite/index.js @@ -1,5 +1,6 @@ module.exports = { backend: () => app => { require('./src/CollectionsInvitations')(app) + require('./src/FragmentsInvitations')(app) }, } diff --git a/packages/component-invite/src/CollectionsInvitations.js b/packages/component-invite/src/CollectionsInvitations.js index ba6fa2d852bf1a14b39b182297eff50f2c8661b7..2f80dd1b70e3089a7ab7f9068a4e12dbb84985e5 100644 --- a/packages/component-invite/src/CollectionsInvitations.js +++ b/packages/component-invite/src/CollectionsInvitations.js @@ -14,13 +14,13 @@ const CollectionsInvitations = app => { * @apiParamExample {json} Body * { * "email": "email@example.com", - * "role": "handlingEditor", [acceptedValues: handlingEditor, reviewer] + * "role": "handlingEditor", [acceptedValues: handlingEditor] * } * @apiSuccessExample {json} Success * HTTP/1.1 200 OK * { * "id": "7b2431af-210c-49f9-a69a-e19271066ebd", - * "role": "reviewer", + * "role": "handlingEditor", * "userId": "4c3f8ee1-785b-4adb-87b4-407a27f652c6", * "hasAnswer": false, * "invitedOn": 1525428890167, @@ -38,32 +38,6 @@ const CollectionsInvitations = app => { authBearer, require(`${routePath}/post`)(app.locals.models), ) - /** - * @api {get} /api/collections/:collectionId/invitations/[:invitationId]?role=:role List collections invitations - * @apiGroup CollectionsInvitations - * @apiParam {id} collectionId Collection id - * @apiParam {id} [invitationId] Invitation id - * @apiParam {String} role The role to search for: handlingEditor, reviewer, author - * @apiSuccessExample {json} Success - * HTTP/1.1 200 OK - * [{ - * "name": "John Smith", - * "invitedOn": 1525428890167, - * "respondedOn": 1525428890299, - * "email": "email@example.com", - * "status": "pending", - * "invitationId": "1990881" - * }] - * @apiErrorExample {json} List errors - * HTTP/1.1 403 Forbidden - * HTTP/1.1 400 Bad Request - * HTTP/1.1 404 Not Found - */ - app.get( - `${basePath}/:invitationId?`, - authBearer, - require(`${routePath}/get`)(app.locals.models), - ) /** * @api {delete} /api/collections/:collectionId/invitations/:invitationId Delete invitation * @apiGroup CollectionsInvitations @@ -95,7 +69,7 @@ const CollectionsInvitations = app => { * HTTP/1.1 200 OK * { * "id": "7b2431af-210c-49f9-a69a-e19271066ebd", - * "role": "reviewer", + * "role": "handlingEditor", * "userId": "4c3f8ee1-785b-4adb-87b4-407a27f652c6", * "hasAnswer": true, * "invitedOn": 1525428890167, @@ -113,28 +87,6 @@ const CollectionsInvitations = app => { authBearer, require(`${routePath}/patch`)(app.locals.models), ) - /** - * @api {patch} /api/collections/:collectionId/invitations/:invitationId/decline Decline an invitation as a reviewer - * @apiGroup CollectionsInvitations - * @apiParam {collectionId} collectionId Collection id - * @apiParam {invitationId} invitationId Invitation id - * @apiParamExample {json} Body - * { - * "invitationToken": "f2d814f0-67a5-4590-ba4f-6a83565feb4f", - * } - * @apiSuccessExample {json} Success - * HTTP/1.1 200 OK - * {} - * @apiErrorExample {json} Update invitations 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}/:invitationId/decline`, - require(`${routePath}/decline`)(app.locals.models), - ) } module.exports = CollectionsInvitations diff --git a/packages/component-invite/src/FragmentsInvitations.js b/packages/component-invite/src/FragmentsInvitations.js new file mode 100644 index 0000000000000000000000000000000000000000..7a4d2507424cbeb5a5a0c0357779508fed009a8d --- /dev/null +++ b/packages/component-invite/src/FragmentsInvitations.js @@ -0,0 +1,145 @@ +const bodyParser = require('body-parser') + +const FragmentsInvitations = app => { + app.use(bodyParser.json()) + const basePath = + '/api/collections/:collectionId/fragments/:fragmentId/invitations' + const routePath = './routes/fragmentsInvitations' + const authBearer = app.locals.passport.authenticate('bearer', { + session: false, + }) + /** + * @api {post} /api/collections/:collectionId/fragments/:fragmentId/invitations Invite a user to a fragment + * @apiGroup FragmentsInvitations + * @apiParam {collectionId} collectionId Collection id + * @apiParam {fragmentId} fragmentId Fragment id + * @apiParamExample {json} Body + * { + * "email": "email@example.com", + * "role": "reviewer", [acceptedValues: reviewer] + * } + * @apiSuccessExample {json} Success + * HTTP/1.1 200 OK + * { + * "id": "7b2431af-210c-49f9-a69a-e19271066ebd", + * "role": "reviewer", + * "userId": "4c3f8ee1-785b-4adb-87b4-407a27f652c6", + * "hasAnswer": false, + * "invitedOn": 1525428890167, + * "isAccepted": false, + * "respondedOn": null + * } + * @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.post( + basePath, + authBearer, + require(`${routePath}/post`)(app.locals.models), + ) + /** + * @api {get} /api/collections/:collectionId/fragments/:fragmentId/invitations/[:invitationId]?role=:role List fragment invitations + * @apiGroup FragmentsInvitations + * @apiParam {id} collectionId Collection id + * @apiParam {id} fragmentId Fragment id + * @apiParam {id} [invitationId] Invitation id + * @apiParam {String} role The role to search for: reviewer + * @apiSuccessExample {json} Success + * HTTP/1.1 200 OK + * [{ + * "name": "John Smith", + * "invitedOn": 1525428890167, + * "respondedOn": 1525428890299, + * "email": "email@example.com", + * "status": "pending", + * "invitationId": "1990881" + * }] + * @apiErrorExample {json} List errors + * HTTP/1.1 403 Forbidden + * HTTP/1.1 400 Bad Request + * HTTP/1.1 404 Not Found + */ + app.get( + `${basePath}/:invitationId?`, + authBearer, + require(`${routePath}/get`)(app.locals.models), + ) + /** + * @api {delete} /api/collections/:collectionId/fragments/:fragmentId/invitations/:invitationId Delete invitation + * @apiGroup FragmentsInvitations + * @apiParam {collectionId} collectionId Collection id + * @apiParam {fragmentId} fragmentId Fragment id + * @apiParam {invitationId} invitationId Invitation id + * @apiSuccessExample {json} Success + * HTTP/1.1 204 No Content + * @apiErrorExample {json} Delete errors + * HTTP/1.1 403 Forbidden + * HTTP/1.1 404 Not Found + * HTTP/1.1 500 Internal Server Error + */ + app.delete( + `${basePath}/:invitationId`, + authBearer, + require(`${routePath}/delete`)(app.locals.models), + ) + /** + * @api {patch} /api/collections/:collectionId/fragments/:fragmentId/invitations/:invitationId Update an invitation + * @apiGroup FragmentsInvitations + * @apiParam {collectionId} collectionId Collection id + * @apiParam {invitationId} invitationId Invitation id + * @apiParam {fragmentId} fragmentId Fragment id + * @apiParamExample {json} Body + * { + * "isAccepted": false, + * "reason": "I am not ready" [optional] + * } + * @apiSuccessExample {json} Success + * HTTP/1.1 200 OK + * { + * "id": "7b2431af-210c-49f9-a69a-e19271066ebd", + * "role": "reviewer", + * "userId": "4c3f8ee1-785b-4adb-87b4-407a27f652c6", + * "hasAnswer": true, + * "invitedOn": 1525428890167, + * "isAccepted": false, + * "respondedOn": 1525428890299 + * } + * @apiErrorExample {json} Update invitations 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}/:invitationId`, + authBearer, + require(`${routePath}/patch`)(app.locals.models), + ) + /** + * @api {patch} /api/collections/:collectionId/fragments/:fragmentId/invitations/:invitationId/decline Decline an invitation as a reviewer + * @apiGroup FragmentsInvitations + * @apiParam {collectionId} collectionId Collection id + * @apiParam {invitationId} invitationId Invitation id + * @apiParamExample {json} Body + * { + * "invitationToken": "f2d814f0-67a5-4590-ba4f-6a83565feb4f", + * } + * @apiSuccessExample {json} Success + * HTTP/1.1 200 OK + * {} + * @apiErrorExample {json} Update invitations 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}/:invitationId/decline`, + require(`${routePath}/decline`)(app.locals.models), + ) +} + +module.exports = FragmentsInvitations diff --git a/packages/component-invite/src/routes/collectionsInvitations/delete.js b/packages/component-invite/src/routes/collectionsInvitations/delete.js index e3e69c0ecc26e772b0428004df9fa9134010d141..b7c77e4f41780107d756bb3637ed55b45ac0eff9 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/delete.js +++ b/packages/component-invite/src/routes/collectionsInvitations/delete.js @@ -1,14 +1,9 @@ const config = require('config') -const logger = require('@pubsweet/logger') -const last = require('lodash/last') const mailService = require('pubsweet-component-mail-service') const { services, Team, - Email, - Fragment, - Collection, authsome: authsomeHelper, } = require('pubsweet-component-helper-service') @@ -19,7 +14,7 @@ module.exports = models => async (req, res) => { try { const collection = await models.Collection.find(collectionId) - const collectionHelper = new Collection({ collection }) + const authsome = authsomeHelper.getAuthsome(models) const target = { collection, @@ -30,79 +25,48 @@ module.exports = models => async (req, res) => { return res.status(403).json({ error: 'Unauthorized.', }) - const invitation = await collection.invitations.find( + + collection.invitations = collection.invitations || [] + const invitation = collection.invitations.find( invitation => invitation.id === invitationId, ) - if (invitation === undefined) { - res.status(404).json({ + if (!invitation) + return res.status(404).json({ error: `Invitation ${invitationId} not found`, }) - return - } - const team = await teamHelper.getTeamByGroupAndCollection({ + const team = await teamHelper.getTeam({ role: invitation.role, + objectType: 'collection', }) collection.invitations = collection.invitations.filter( inv => inv.id !== invitation.id, ) - if (invitation.role === 'handlingEditor') { - collection.status = 'submitted' - collection.visibleStatus = statuses[collection.status].private - delete collection.handlingEditor - } else if (invitation.role === 'reviewer') { - await collectionHelper.updateStatusByNumberOfReviewers() - } + + collection.status = 'submitted' + collection.visibleStatus = statuses[collection.status].private + delete collection.handlingEditor await collection.save() + await teamHelper.removeTeamMember({ teamId: team.id, userId: invitation.userId, }) + const UserModel = models.User const user = await UserModel.find(invitation.userId) user.teams = user.teams.filter(userTeamId => team.id !== userTeamId) await user.save() - try { - if (invitation.role === 'handlingEditor') { - await mailService.sendSimpleEmail({ - toEmail: user.email, - emailType: 'revoke-handling-editor', - }) - } else if (invitation.role === 'reviewer') { - const collectionHelper = new Collection({ collection }) - const fragment = await models.Fragment.find(last(collection.fragments)) - const fragmentHelper = new Fragment({ fragment }) - const parsedFragment = await fragmentHelper.getFragmentData({ - handlingEditor: collection.handlingEditor, - }) - const baseUrl = services.getBaseUrl(req) - const { - authorsList: authors, - submittingAuthor, - } = await collectionHelper.getAuthorData({ UserModel }) - const emailHelper = new Email({ - UserModel, - collection, - parsedFragment, - baseUrl, - authors, - }) - emailHelper.setupReviewerUnassignEmail({ - user, - authorName: `${submittingAuthor.firstName} ${ - submittingAuthor.lastName - }`, - }) - } - return res.status(200).json({}) - } catch (e) { - logger.error(e.message) - return res.status(500).json({ error: 'Email could not be sent.' }) - } + mailService.sendSimpleEmail({ + toEmail: user.email, + emailType: 'revoke-handling-editor', + }) + + return res.status(200).json({}) } catch (e) { - const notFoundError = await services.handleNotFoundError(e, 'collection') + const notFoundError = await services.handleNotFoundError(e, 'Collection') return res.status(notFoundError.status).json({ error: notFoundError.message, }) diff --git a/packages/component-invite/src/routes/collectionsInvitations/patch.js b/packages/component-invite/src/routes/collectionsInvitations/patch.js index 69d6302f6184d69ead39341d5867748bec23477d..d28609053609980f7256195dda6e4c98bdc9f78e 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/patch.js +++ b/packages/component-invite/src/routes/collectionsInvitations/patch.js @@ -1,144 +1,89 @@ -const logger = require('@pubsweet/logger') const mailService = require('pubsweet-component-mail-service') -const last = require('lodash/last') const { - Email, - services, - Fragment, - Collection, Team, User, + services, + Collection, + Invitation, } = require('pubsweet-component-helper-service') module.exports = models => async (req, res) => { const { collectionId, invitationId } = req.params const { isAccepted, reason } = req.body - if (!services.checkForUndefinedParams(isAccepted)) { - res.status(400).json({ error: 'Missing parameters.' }) - logger.error('some parameters are missing') - return - } const UserModel = models.User const user = await UserModel.find(req.user) + try { const collection = await models.Collection.find(collectionId) - const invitation = await collection.invitations.find( + collection.invitations = collection.invitations || [] + const invitation = collection.invitations.find( invitation => invitation.id === invitationId, ) - if (invitation === undefined) - return res.status(404).json({ - error: 'Invitation not found.', - }) - if (invitation.hasAnswer) - return res - .status(400) - .json({ error: 'Invitation has already been answered.' }) - if (invitation.userId !== user.id) - return res.status(403).json({ - error: `User is not allowed to modify this invitation.`, + + const invitationHelper = new Invitation({ + userId: user.id, + role: 'handlingEditor', + }) + + const invitationValidation = invitationHelper.validateInvitation({ + invitation, + }) + if (invitationValidation.error) + return res.status(invitationValidation.status).json({ + error: invitationValidation.error, }) const collectionHelper = new Collection({ collection }) - const fragment = await models.Fragment.find(last(collection.fragments)) - const fragmentHelper = new Fragment({ fragment }) - const parsedFragment = await fragmentHelper.getFragmentData({ - handlingEditor: collection.handlingEditor, - }) const baseUrl = services.getBaseUrl(req) - const { - authorsList: authors, - submittingAuthor, - } = await collectionHelper.getAuthorData({ UserModel }) - const emailHelper = new Email({ - UserModel, - collection, - parsedFragment, - baseUrl, - authors, - }) + const teamHelper = new Team({ TeamModel: models.Team, collectionId }) const userHelper = new User({ UserModel }) - if (invitation.role === 'handlingEditor') - await collectionHelper.updateHandlingEditor({ isAccepted }) + await collectionHelper.updateHandlingEditor({ isAccepted }) invitation.respondedOn = Date.now() invitation.hasAnswer = true const eic = await userHelper.getEditorInChief() const toEmail = eic.email - if (isAccepted === true) { - invitation.isAccepted = true - if ( - invitation.role === 'reviewer' && - collection.status === 'reviewersInvited' - ) - await collectionHelper.updateStatus({ newStatus: 'underReview' }) - await collection.save() - try { - if (invitation.role === 'handlingEditor') - await mailService.sendSimpleEmail({ - toEmail, - user, - emailType: 'handling-editor-agreed', - dashboardUrl: baseUrl, - meta: { - collectionId: collection.customId, - }, - }) - if (invitation.role === 'reviewer') - emailHelper.setupReviewerDecisionEmail({ - agree: true, - timestamp: invitation.respondedOn, - user, - authorName: `${submittingAuthor.firstName} ${ - submittingAuthor.lastName - }`, - }) - return res.status(200).json(invitation) - } catch (e) { - logger.error(e) - return res.status(500).json({ error: 'Email could not be sent.' }) - } - } else { - invitation.isAccepted = false - - if (invitation.role === 'handlingEditor') - await teamHelper.updateHETeam({ - collection, - role: invitation.role, - user, - }) - if (reason !== undefined) { - invitation.reason = reason - } + if (isAccepted) { + invitation.isAccepted = true await collection.save() - try { - if (invitation.role === 'handlingEditor') { - await mailService.sendSimpleEmail({ - toEmail, - user, - emailType: 'handling-editor-declined', - meta: { - reason, - collectionId: collection.customId, - }, - }) - } else if (invitation.role === 'reviewer') { - collectionHelper.updateStatusByNumberOfReviewers() - emailHelper.setupReviewerDecisionEmail({ - agree: false, - user, - }) - } - } catch (e) { - logger.error(e) - return res.status(500).json({ error: 'Email could not be sent.' }) - } + mailService.sendSimpleEmail({ + toEmail, + user, + emailType: 'handling-editor-agreed', + dashboardUrl: baseUrl, + meta: { + collectionId: collection.customId, + }, + }) + + return res.status(200).json(invitation) } - user.save() + + await teamHelper.deleteHandlingEditor({ + collection, + role: invitation.role, + user, + }) + + invitation.isAccepted = false + if (reason) invitation.reason = reason + await collection.save() + + mailService.sendSimpleEmail({ + toEmail, + user, + emailType: 'handling-editor-declined', + meta: { + reason, + collectionId: collection.customId, + }, + }) + return res.status(200).json(invitation) } catch (e) { const notFoundError = await services.handleNotFoundError(e, 'collection') diff --git a/packages/component-invite/src/routes/collectionsInvitations/post.js b/packages/component-invite/src/routes/collectionsInvitations/post.js index 3bafaf954c6276637c2195b93ff0651ab53cdb05..3e20a3c89450bce8077a350249b150ac975dcab4 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/post.js +++ b/packages/component-invite/src/routes/collectionsInvitations/post.js @@ -1,49 +1,38 @@ -const config = require('config') -const get = require('lodash/get') -const last = require('lodash/last') const logger = require('@pubsweet/logger') const mailService = require('pubsweet-component-mail-service') const { - Email, services, authsome: authsomeHelper, - Fragment, Collection, Team, Invitation, - User, } = require('pubsweet-component-helper-service') -const configRoles = config.get('roles') - module.exports = models => async (req, res) => { const { email, role } = req.body - if (!services.checkForUndefinedParams(email, role)) { - res.status(400).json({ error: 'Email and role are required' }) - logger.error('User ID and role are missing') - return - } + if (!services.checkForUndefinedParams(email, role)) + return res.status(400).json({ error: 'Email and role are required' }) + + if (role !== 'handlingEditor') + return res.status(400).json({ + error: `Role ${role} is invalid. Only handlingEditor is allowed.`, + }) - if (!configRoles.collection.includes(role)) { - res.status(400).json({ error: `Role ${role} is invalid` }) - logger.error(`invitation attempted on invalid role ${role}`) - return - } const UserModel = models.User const reqUser = await UserModel.find(req.user) if (email === reqUser.email && !reqUser.admin) { logger.error(`${reqUser.email} tried to invite his own email`) - return res.status(400).json({ error: 'Cannot invite yourself' }) + return res.status(400).json({ error: 'Cannot invite yourself.' }) } - const collectionId = get(req, 'params.collectionId') + const { collectionId } = req.params let collection try { collection = await models.Collection.find(collectionId) } catch (e) { - const notFoundError = await services.handleNotFoundError(e, 'collection') + const notFoundError = await services.handleNotFoundError(e, 'Collection') return res.status(notFoundError.status).json({ error: notFoundError.message, }) @@ -61,115 +50,49 @@ module.exports = models => async (req, res) => { }) const collectionHelper = new Collection({ collection }) - const fragment = await models.Fragment.find(last(collection.fragments)) - const fragmentHelper = new Fragment({ fragment }) - const handlingEditor = collection.handlingEditor || {} - const parsedFragment = await fragmentHelper.getFragmentData({ - handlingEditor, - }) const baseUrl = services.getBaseUrl(req) - const { - authorsList: authors, - submittingAuthor, - } = await collectionHelper.getAuthorData({ UserModel }) - const emailHelper = new Email({ - UserModel, - collection, - parsedFragment, - baseUrl, - authors, + + const teamHelper = new Team({ + TeamModel: models.Team, + collectionId, }) - const teamHelper = new Team({ TeamModel: models.Team, collectionId }) const invitationHelper = new Invitation({ role }) try { const user = await UserModel.findByEmail(email) - await teamHelper.setupManuscriptTeam({ user, role }) + await teamHelper.setupTeam({ user, role, objectType: 'collection' }) invitationHelper.userId = user.id let invitation = invitationHelper.getInvitation({ invitations: collection.invitations, }) - let resend = false - if (invitation !== undefined) { + if (invitation) { if (invitation.hasAnswer) return res .status(400) .json({ error: 'User has already replied to a previous invitation.' }) invitation.invitedOn = Date.now() await collection.save() - resend = true } else { - invitation = await invitationHelper.setupInvitation({ - collection, + invitation = await invitationHelper.createInvitation({ + parentObject: collection, }) } - try { - if (role === 'reviewer') { - if (collection.status === 'heAssigned') - await collectionHelper.updateStatus({ newStatus: 'reviewersInvited' }) + invitation.invitedOn = Date.now() + await collection.save() + await collectionHelper.addHandlingEditor({ user, invitation }) - await emailHelper.setupReviewerInvitationEmail({ - user, - invitationId: invitation.id, - timestamp: invitation.invitedOn, - resend, - authorName: `${submittingAuthor.firstName} ${ - submittingAuthor.lastName - }`, - }) - } + mailService.sendSimpleEmail({ + toEmail: user.email, + user, + emailType: 'assign-handling-editor', + dashboardUrl: baseUrl, + }) - if (role === 'handlingEditor') { - invitation.invitedOn = Date.now() - await collection.save() - await collectionHelper.addHandlingEditor({ user, invitation }) - mailService.sendSimpleEmail({ - toEmail: user.email, - user, - emailType: 'assign-handling-editor', - dashboardUrl: baseUrl, - }) - } - return res.status(200).json(invitation) - } catch (e) { - logger.error(e) - return res.status(500).json({ error: 'Email could not be sent.' }) - } + return res.status(200).json(invitation) } catch (e) { - const userHelper = new User({ UserModel }) - if (role === 'reviewer') { - const newUser = await userHelper.setupNewUser({ - url: baseUrl, - role, - invitationType: 'invite', - body: req.body, - }) - if (newUser.error !== undefined) { - return res.status(newUser.status).json({ - error: newUser.message, - }) - } - if (collection.status === 'heAssigned') - await collectionHelper.updateStatus({ newStatus: 'reviewersInvited' }) - await teamHelper.setupManuscriptTeam({ user: newUser, role }) - invitationHelper.userId = newUser.id - const invitation = await invitationHelper.setupInvitation({ - collection, - }) - - await emailHelper.setupReviewerInvitationEmail({ - user: newUser, - invitationId: invitation.id, - timestamp: invitation.invitedOn, - authorName: `${submittingAuthor.firstName} ${ - submittingAuthor.lastName - }`, - }) - return res.status(200).json(invitation) - } - const notFoundError = await services.handleNotFoundError(e, 'user') + const notFoundError = await services.handleNotFoundError(e, 'User') return res.status(notFoundError.status).json({ error: notFoundError.message, }) diff --git a/packages/component-invite/src/routes/collectionsInvitations/decline.js b/packages/component-invite/src/routes/fragmentsInvitations/decline.js similarity index 64% rename from packages/component-invite/src/routes/collectionsInvitations/decline.js rename to packages/component-invite/src/routes/fragmentsInvitations/decline.js index b049a4a0aeb43f096b0ec673b338d91202440cc4..e867503771902281e8686ff0330e2508ca693dfa 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/decline.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/decline.js @@ -1,14 +1,12 @@ -const last = require('lodash/last') - const { services, Email, Fragment, - Collection, + Invitation, } = require('pubsweet-component-helper-service') module.exports = models => async (req, res) => { - const { collectionId, invitationId } = req.params + const { collectionId, invitationId, fragmentId } = req.params const { invitationToken } = req.body if (!services.checkForUndefinedParams(invitationToken)) @@ -21,30 +19,34 @@ module.exports = models => async (req, res) => { invitationToken, ) const collection = await models.Collection.find(collectionId) - const invitation = await collection.invitations.find( + if (!collection.fragments.includes(fragmentId)) + return res.status(400).json({ + error: `Fragment ${fragmentId} does not match collection ${collectionId}`, + }) + const fragment = await models.Fragment.find(fragmentId) + fragment.invitations = fragment.invitations || [] + const invitation = await fragment.invitations.find( invitation => invitation.id === invitationId, ) - if (invitation === undefined) - return res.status(404).json({ - error: `Invitation ${invitationId} not found`, - }) - if (invitation.hasAnswer) - return res - .status(400) - .json({ error: `Invitation has already been answered.` }) - if (invitation.userId !== user.id) - return res.status(403).json({ - error: `User ${user.email} is not allowed to modify invitation ${ - invitation.id - }`, + + const invitationHelper = new Invitation({ + userId: user.id, + role: 'reviewer', + }) + + const invitationValidation = invitationHelper.validateInvitation({ + invitation, + }) + if (invitationValidation.error) + return res.status(invitationValidation.status).json({ + error: invitationValidation.error, }) invitation.respondedOn = Date.now() invitation.hasAnswer = true invitation.isAccepted = false - await collection.save() - const collectionHelper = new Collection({ collection }) - const fragment = await models.Fragment.find(last(collection.fragments)) + await fragment.save() + const fragmentHelper = new Fragment({ fragment }) const parsedFragment = await fragmentHelper.getFragmentData({ handlingEditor: collection.handlingEditor, @@ -53,7 +55,7 @@ module.exports = models => async (req, res) => { const { authorsList: authors, submittingAuthor, - } = await collectionHelper.getAuthorData({ UserModel }) + } = await fragmentHelper.getAuthorData({ UserModel }) const emailHelper = new Email({ UserModel, collection, diff --git a/packages/component-invite/src/routes/fragmentsInvitations/delete.js b/packages/component-invite/src/routes/fragmentsInvitations/delete.js new file mode 100644 index 0000000000000000000000000000000000000000..b4da00bc268d61d124f9ef248f4ad3af04f91ecd --- /dev/null +++ b/packages/component-invite/src/routes/fragmentsInvitations/delete.js @@ -0,0 +1,97 @@ +const { + services, + Team, + Email, + Fragment, + Collection, + authsome: authsomeHelper, +} = require('pubsweet-component-helper-service') + +module.exports = models => async (req, res) => { + const { collectionId, invitationId, fragmentId } = req.params + const teamHelper = new Team({ + TeamModel: models.Team, + collectionId, + fragmentId, + }) + + try { + const collection = await models.Collection.find(collectionId) + if (!collection.fragments.includes(fragmentId)) + return res.status(400).json({ + error: `Fragment ${fragmentId} does not match collection ${collectionId}.`, + }) + const fragment = await models.Fragment.find(fragmentId) + + const collectionHelper = new Collection({ collection }) + + const authsome = authsomeHelper.getAuthsome(models) + const target = { + collection, + path: req.route.path, + } + const canDelete = await authsome.can(req.user, 'DELETE', target) + if (!canDelete) + return res.status(403).json({ + error: 'Unauthorized.', + }) + fragment.invitations = fragment.invitations || [] + const invitation = await fragment.invitations.find( + invitation => invitation.id === invitationId, + ) + if (!invitation) + return res.status(404).json({ + error: `Invitation ${invitationId} not found`, + }) + + const team = await teamHelper.getTeam({ + role: invitation.role, + objectType: 'fragment', + }) + + fragment.invitations = fragment.invitations.filter( + inv => inv.id !== invitation.id, + ) + + await collectionHelper.updateStatusByNumberOfReviewers() + + await teamHelper.removeTeamMember({ + teamId: team.id, + userId: invitation.userId, + }) + + const UserModel = models.User + const user = await UserModel.find(invitation.userId) + user.teams = user.teams.filter(userTeamId => team.id !== userTeamId) + await user.save() + + const fragmentHelper = new Fragment({ fragment }) + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor: collection.handlingEditor, + }) + const baseUrl = services.getBaseUrl(req) + const { + authorsList: authors, + submittingAuthor, + } = await fragmentHelper.getAuthorData({ UserModel }) + const emailHelper = new Email({ + UserModel, + collection, + parsedFragment, + baseUrl, + authors, + }) + + emailHelper.setupReviewerUnassignEmail({ + user, + authorName: `${submittingAuthor.firstName} ${submittingAuthor.lastName}`, + }) + + return res.status(200).json({}) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'collection') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } +} diff --git a/packages/component-invite/src/routes/collectionsInvitations/get.js b/packages/component-invite/src/routes/fragmentsInvitations/get.js similarity index 70% rename from packages/component-invite/src/routes/collectionsInvitations/get.js rename to packages/component-invite/src/routes/fragmentsInvitations/get.js index 18e1e0eb7a8a8c08a0fb5f3c34e220c91c1678c7..2bb8c540e6576951ba8773108036dda02e8f2fee 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/get.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/get.js @@ -20,14 +20,24 @@ module.exports = models => async (req, res) => { return } - const { collectionId } = req.params - const teamHelper = new Team({ TeamModel: models.Team, collectionId }) + const { collectionId, fragmentId } = req.params + const teamHelper = new Team({ + TeamModel: models.Team, + collectionId, + fragmentId, + }) try { const collection = await models.Collection.find(collectionId) + if (!collection.fragments.includes(fragmentId)) + return res.status(400).json({ + error: `Fragment ${fragmentId} does not match collection ${collectionId}.`, + }) + const fragment = await models.Fragment.find(fragmentId) + const authsome = authsomeHelper.getAuthsome(models) const target = { - collection, + fragment, path: req.route.path, } const canGet = await authsome.can(req.user, 'GET', target) @@ -37,23 +47,26 @@ module.exports = models => async (req, res) => { error: 'Unauthorized.', }) - const members = await teamHelper.getTeamMembersByCollection({ + const members = await teamHelper.getTeamMembers({ role, + objectType: 'fragment', }) - if (members === undefined) return res.status(200).json([]) + + if (!members) return res.status(200).json([]) // TO DO: handle case for when the invitationID is provided + const invitationHelper = new Invitation({ role }) + const membersData = members.map(async member => { const user = await models.User.find(member) - const invitationHelper = new Invitation({ userId: user.id, role }) - + invitationHelper.userId = user.id const { invitedOn, respondedOn, status, id, } = invitationHelper.getInvitationsData({ - invitations: collection.invitations, + invitations: fragment.invitations, }) return { @@ -70,7 +83,7 @@ module.exports = models => async (req, res) => { const resBody = await Promise.all(membersData) res.status(200).json(resBody) } catch (e) { - const notFoundError = await services.handleNotFoundError(e, 'collection') + const notFoundError = await services.handleNotFoundError(e, 'Item') return res.status(notFoundError.status).json({ error: notFoundError.message, }) diff --git a/packages/component-invite/src/routes/fragmentsInvitations/patch.js b/packages/component-invite/src/routes/fragmentsInvitations/patch.js new file mode 100644 index 0000000000000000000000000000000000000000..7ecb11d6b286fd76b69d892e5222c71d424296c3 --- /dev/null +++ b/packages/component-invite/src/routes/fragmentsInvitations/patch.js @@ -0,0 +1,94 @@ +const { + Email, + services, + Fragment, + Collection, + Invitation, +} = require('pubsweet-component-helper-service') + +module.exports = models => async (req, res) => { + const { collectionId, invitationId, fragmentId } = req.params + const { isAccepted, reason } = req.body + + const UserModel = models.User + const user = await UserModel.find(req.user) + try { + const collection = await models.Collection.find(collectionId) + if (!collection.fragments.includes(fragmentId)) + return res.status(400).json({ + error: `Fragment ${fragmentId} does not match collection ${collectionId}`, + }) + const fragment = await models.Fragment.find(fragmentId) + fragment.invitations = fragment.invitations || [] + const invitation = await fragment.invitations.find( + invitation => invitation.id === invitationId, + ) + + const invitationHelper = new Invitation({ + userId: user.id, + role: 'reviewer', + }) + const invitationValidation = invitationHelper.validateInvitation({ + invitation, + }) + if (invitationValidation.error) + return res.status(invitationValidation.status).json({ + error: invitationValidation.error, + }) + + const collectionHelper = new Collection({ collection }) + const fragmentHelper = new Fragment({ fragment }) + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor: collection.handlingEditor, + }) + const baseUrl = services.getBaseUrl(req) + const { + authorsList: authors, + submittingAuthor, + } = await fragmentHelper.getAuthorData({ UserModel }) + const emailHelper = new Email({ + UserModel, + collection, + parsedFragment, + baseUrl, + authors, + }) + + invitation.respondedOn = Date.now() + invitation.hasAnswer = true + if (isAccepted) { + invitation.isAccepted = true + if (collection.status === 'reviewersInvited') + await collectionHelper.updateStatus({ newStatus: 'underReview' }) + await fragment.save() + + emailHelper.setupReviewerDecisionEmail({ + agree: true, + timestamp: invitation.respondedOn, + user, + authorName: `${submittingAuthor.firstName} ${ + submittingAuthor.lastName + }`, + }) + + return res.status(200).json(invitation) + } + + invitation.isAccepted = false + if (reason) invitation.reason = reason + await fragment.save() + + collectionHelper.updateStatusByNumberOfReviewers() + emailHelper.setupReviewerDecisionEmail({ + agree: false, + user, + }) + + return res.status(200).json(invitation) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'Item') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } +} diff --git a/packages/component-invite/src/routes/fragmentsInvitations/post.js b/packages/component-invite/src/routes/fragmentsInvitations/post.js new file mode 100644 index 0000000000000000000000000000000000000000..38991ec08d033de565d5685d7ab6077b978d8504 --- /dev/null +++ b/packages/component-invite/src/routes/fragmentsInvitations/post.js @@ -0,0 +1,159 @@ +const logger = require('@pubsweet/logger') +const { + Email, + services, + authsome: authsomeHelper, + Fragment, + Collection, + Team, + Invitation, + User, +} = require('pubsweet-component-helper-service') + +module.exports = models => async (req, res) => { + const { email, role } = req.body + + if (!services.checkForUndefinedParams(email, role)) { + res.status(400).json({ error: 'Email and role are required.' }) + logger.error('User ID and role are missing') + return + } + + if (role !== 'reviewer') + return res + .status(400) + .json({ error: `Role ${role} is invalid. Only reviewer is accepted.` }) + + const UserModel = models.User + const reqUser = await UserModel.find(req.user) + + if (email === reqUser.email && !reqUser.admin) + return res.status(400).json({ error: 'Cannot invite yourself.' }) + + 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: `Fragment ${fragmentId} does not match collection ${collectionId}.`, + }) + fragment = await models.Fragment.find(fragmentId) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'item') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } + + const authsome = authsomeHelper.getAuthsome(models) + const target = { + collection, + path: req.route.path, + } + const canPost = await authsome.can(req.user, 'POST', target) + if (!canPost) + return res.status(403).json({ + error: 'Unauthorized.', + }) + + const collectionHelper = new Collection({ collection }) + const fragmentHelper = new Fragment({ fragment }) + const handlingEditor = collection.handlingEditor || {} + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor, + }) + const baseUrl = services.getBaseUrl(req) + const { + authorsList: authors, + submittingAuthor, + } = await fragmentHelper.getAuthorData({ UserModel }) + const emailHelper = new Email({ + UserModel, + collection, + parsedFragment, + baseUrl, + authors, + }) + const teamHelper = new Team({ + TeamModel: models.Team, + collectionId, + fragmentId, + }) + const invitationHelper = new Invitation({ role }) + + try { + const user = await UserModel.findByEmail(email) + await teamHelper.setupTeam({ user, role, objectType: 'fragment' }) + invitationHelper.userId = user.id + + let invitation = invitationHelper.getInvitation({ + invitations: fragment.invitations, + }) + let resend = false + + if (invitation) { + if (invitation.hasAnswer) + return res + .status(400) + .json({ error: 'User has already replied to a previous invitation.' }) + + invitation.invitedOn = Date.now() + await fragment.save() + resend = true + } else { + invitation = await invitationHelper.createInvitation({ + parentObject: fragment, + }) + } + + if (collection.status === 'heAssigned') + await collectionHelper.updateStatus({ newStatus: 'reviewersInvited' }) + + emailHelper.setupReviewerInvitationEmail({ + user, + invitationId: invitation.id, + timestamp: invitation.invitedOn, + resend, + authorName: `${submittingAuthor.firstName} ${submittingAuthor.lastName}`, + }) + + return res.status(200).json(invitation) + } catch (e) { + const userHelper = new User({ UserModel }) + + const newUser = await userHelper.setupNewUser({ + url: baseUrl, + role, + invitationType: 'invite', + body: req.body, + }) + + if (newUser.error !== undefined) { + return res.status(newUser.status).json({ + error: newUser.message, + }) + } + + if (collection.status === 'heAssigned') + await collectionHelper.updateStatus({ newStatus: 'reviewersInvited' }) + + await teamHelper.setupTeam({ user: newUser, role, objectType: 'fragment' }) + + invitationHelper.userId = newUser.id + + const invitation = await invitationHelper.createInvitation({ + parentObject: fragment, + }) + + emailHelper.setupReviewerInvitationEmail({ + user: newUser, + invitationId: invitation.id, + timestamp: invitation.invitedOn, + authorName: `${submittingAuthor.firstName} ${submittingAuthor.lastName}`, + }) + + return res.status(200).json(invitation) + } +} diff --git a/packages/component-invite/src/tests/collectionsInvitations/delete.test.js b/packages/component-invite/src/tests/collectionsInvitations/delete.test.js index 4e6594490c6661e1db150e367b42f348997436bb..7fdef707ccadb721ce6099a69001da73ec29c552 100644 --- a/packages/component-invite/src/tests/collectionsInvitations/delete.test.js +++ b/packages/component-invite/src/tests/collectionsInvitations/delete.test.js @@ -2,15 +2,15 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true const cloneDeep = require('lodash/cloneDeep') -const Model = require('./../helpers/Model') -const fixtures = require('./../fixtures/fixtures') -const requests = require('./../helpers/requests') +const fixturesService = require('pubsweet-component-fixture-service') +const requests = require('../requests') +const { Model, fixtures } = fixturesService jest.mock('pubsweet-component-mail-service', () => ({ sendSimpleEmail: jest.fn(), })) -const path = '../../routes/collectionsInvitations/delete' +const path = '../routes/collectionsInvitations/delete' const route = { path: '/api/collections/:collectionId/invitations/:invitationId', } @@ -35,7 +35,7 @@ describe('Delete Collections Invitations route handler', () => { }) expect(res.statusCode).toBe(404) const data = JSON.parse(res._getData()) - expect(data.error).toEqual('collection not found') + expect(data.error).toEqual('Collection not found') }) it('should return an error when the invitation does not exist', async () => { const { editorInChief } = testFixtures.users diff --git a/packages/component-invite/src/tests/collectionsInvitations/patch.test.js b/packages/component-invite/src/tests/collectionsInvitations/patch.test.js index 129b791c24ae9fdaf09f3f4bcc8573e1abff2985..7ed11f1546f5b45726c79c3ce683c0eeee0f776e 100644 --- a/packages/component-invite/src/tests/collectionsInvitations/patch.test.js +++ b/packages/component-invite/src/tests/collectionsInvitations/patch.test.js @@ -2,10 +2,10 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true const httpMocks = require('node-mocks-http') -const fixtures = require('./../fixtures/fixtures') -const Model = require('./../helpers/Model') const cloneDeep = require('lodash/cloneDeep') +const fixturesService = require('pubsweet-component-fixture-service') +const { Model, fixtures } = fixturesService jest.mock('pubsweet-component-mail-service', () => ({ sendSimpleEmail: jest.fn(), sendNotificationEmail: jest.fn(), @@ -41,22 +41,6 @@ describe('Patch collections invitations route handler', () => { await require(patchPath)(models)(req, res) expect(res.statusCode).toBe(200) }) - it('should return success when the reviewer agrees work on a collection', async () => { - const { reviewer } = testFixtures.users - const { collection } = testFixtures.collections - const req = httpMocks.createRequest({ - body, - }) - req.user = reviewer.id - req.params.collectionId = collection.id - const reviewerInv = collection.invitations.find( - inv => inv.role === 'reviewer' && inv.hasAnswer === false, - ) - req.params.invitationId = reviewerInv.id - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - expect(res.statusCode).toBe(200) - }) it('should return success when the handling editor declines work on a collection', async () => { const { handlingEditor } = testFixtures.users const { collection } = testFixtures.collections @@ -75,41 +59,6 @@ describe('Patch collections invitations route handler', () => { expect(res.statusCode).toBe(200) }) - it('should return success when the reviewer declines work on a collection', async () => { - const { reviewer } = testFixtures.users - const { collection } = testFixtures.collections - body.isAccepted = false - const req = httpMocks.createRequest({ - body, - }) - req.user = reviewer.id - req.params.collectionId = collection.id - const inv = collection.invitations.find( - inv => inv.role === 'reviewer' && inv.hasAnswer === false, - ) - req.params.invitationId = inv.id - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - - expect(res.statusCode).toBe(200) - }) - it('should return an error params are missing', async () => { - const { handlingEditor } = testFixtures.users - const { collection } = testFixtures.collections - delete body.isAccepted - const req = httpMocks.createRequest({ - body, - }) - req.user = handlingEditor.id - req.params.collectionId = collection.id - req.params.invitationId = collection.invitations[0].id - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - - expect(res.statusCode).toBe(400) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('Missing parameters.') - }) it('should return an error if the collection does not exists', async () => { const { handlingEditor } = testFixtures.users const req = httpMocks.createRequest({ diff --git a/packages/component-invite/src/tests/collectionsInvitations/post.test.js b/packages/component-invite/src/tests/collectionsInvitations/post.test.js index 53eda4a07751aebb1120d5c00eadec0a60a87ae3..0608aced450fbf604c14fb2af509309262808ddc 100644 --- a/packages/component-invite/src/tests/collectionsInvitations/post.test.js +++ b/packages/component-invite/src/tests/collectionsInvitations/post.test.js @@ -1,15 +1,12 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true -const random = require('lodash/random') -const fixtures = require('./../fixtures/fixtures') const Chance = require('chance') -const Model = require('./../helpers/Model') -const config = require('config') const cloneDeep = require('lodash/cloneDeep') -const requests = require('./../helpers/requests') +const fixturesService = require('pubsweet-component-fixture-service') +const requests = require('../requests') -const configRoles = config.get('roles') +const { Model, fixtures } = fixturesService jest.mock('pubsweet-component-mail-service', () => ({ sendSimpleEmail: jest.fn(), @@ -17,10 +14,9 @@ jest.mock('pubsweet-component-mail-service', () => ({ sendReviewerInvitationEmail: jest.fn(), })) const chance = new Chance() -const roles = configRoles.collection const reqBody = { email: chance.email(), - role: roles[random(0, roles.length - 1)], + role: 'handlingEditor', firstName: chance.first(), lastName: chance.last(), title: 'Mr', @@ -31,7 +27,7 @@ const route = { path: '/api/collections/:collectionId/invitations', } -const path = '../../routes/collectionsInvitations/post' +const path = '../routes/collectionsInvitations/post' describe('Post collections invitations route handler', () => { let testFixtures = {} let body = {} @@ -79,31 +75,8 @@ describe('Post collections invitations route handler', () => { const data = JSON.parse(res._getData()) expect(data.role).toEqual(body.role) }) - it('should return success when the a reviewer is invited', async () => { - const { user, editorInChief } = testFixtures.users - const { collection } = testFixtures.collections - body = { - email: user.email, - role: 'reviewer', - } - const res = await requests.sendRequest({ - body, - userId: editorInChief.id, - route, - models, - path, - params: { - collectionId: collection.id, - }, - }) - - expect(res.statusCode).toBe(200) - const data = JSON.parse(res._getData()) - expect(data.role).toEqual(body.role) - }) it('should return an error when inviting his self', async () => { const { editorInChief } = testFixtures.users - body.role = roles[random(0, roles.length - 1)] body.email = editorInChief.email const res = await requests.sendRequest({ body, @@ -114,7 +87,7 @@ describe('Post collections invitations route handler', () => { }) expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) - expect(data.error).toEqual('Cannot invite yourself') + expect(data.error).toEqual('Cannot invite yourself.') }) it('should return an error when the role is invalid', async () => { const { editorInChief } = testFixtures.users @@ -127,7 +100,9 @@ describe('Post collections invitations route handler', () => { path, }) const data = JSON.parse(res._getData()) - expect(data.error).toEqual(`Role ${body.role} is invalid`) + expect(data.error).toEqual( + `Role ${body.role} is invalid. Only handlingEditor is allowed.`, + ) }) it('should return success when the EiC resends an invitation to a handlingEditor with a collection', async () => { const { handlingEditor, editorInChief } = testFixtures.users @@ -152,15 +127,15 @@ describe('Post collections invitations route handler', () => { expect(data.role).toEqual(body.role) }) it('should return an error when the invitation is already answered', async () => { - const { answerReviewer, handlingEditor } = testFixtures.users + const { answerHE, editorInChief } = testFixtures.users const { collection } = testFixtures.collections body = { - email: answerReviewer.email, - role: 'reviewer', + email: answerHE.email, + role: 'handlingEditor', } const res = await requests.sendRequest({ body, - userId: handlingEditor.id, + userId: editorInChief.id, route, models, path, diff --git a/packages/component-invite/src/tests/fixtures/collections.js b/packages/component-invite/src/tests/fixtures/collections.js deleted file mode 100644 index bef6132199981a79b87ea86440146eaab5891883..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/tests/fixtures/collections.js +++ /dev/null @@ -1,68 +0,0 @@ -const Chance = require('chance') -const { - user, - handlingEditor, - author, - reviewer, - answerReviewer, -} = require('./userData') -const { fragment } = require('./fragments') - -const chance = new Chance() -const collections = { - collection: { - id: chance.guid(), - title: chance.sentence(), - type: 'collection', - fragments: [fragment.id], - owners: [user.id], - save: jest.fn(), - authors: [ - { - userId: author.id, - isSubmitting: true, - isCorresponding: false, - }, - ], - invitations: [ - { - id: chance.guid(), - role: 'handlingEditor', - hasAnswer: false, - isAccepted: false, - userId: handlingEditor.id, - invitedOn: chance.timestamp(), - respondedOn: null, - }, - { - id: chance.guid(), - role: 'reviewer', - hasAnswer: false, - isAccepted: false, - userId: reviewer.id, - invitedOn: chance.timestamp(), - respondedOn: null, - }, - { - id: chance.guid(), - role: 'reviewer', - hasAnswer: true, - isAccepted: false, - userId: answerReviewer.id, - invitedOn: chance.timestamp(), - respondedOn: chance.timestamp(), - }, - ], - handlingEditor: { - id: handlingEditor.id, - hasAnswer: false, - isAccepted: false, - email: handlingEditor.email, - invitedOn: chance.timestamp(), - respondedOn: null, - name: `${handlingEditor.firstName} ${handlingEditor.lastName}`, - }, - }, -} - -module.exports = collections diff --git a/packages/component-invite/src/tests/fixtures/fragments.js b/packages/component-invite/src/tests/fixtures/fragments.js deleted file mode 100644 index 08d0eedf3a531c317cbfb79e58a6d1b019413564..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/tests/fixtures/fragments.js +++ /dev/null @@ -1,15 +0,0 @@ -const Chance = require('chance') - -const chance = new Chance() -const fragments = { - fragment: { - id: chance.guid(), - metadata: { - title: chance.sentence(), - abstract: chance.paragraph(), - }, - recommendations: [], - }, -} - -module.exports = fragments diff --git a/packages/component-invite/src/tests/fixtures/teamIDs.js b/packages/component-invite/src/tests/fixtures/teamIDs.js deleted file mode 100644 index 607fd6661b1e7c9848bf13257a0d198f230fab00..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/tests/fixtures/teamIDs.js +++ /dev/null @@ -1,10 +0,0 @@ -const Chance = require('chance') - -const chance = new Chance() -const heID = chance.guid() -const revId = chance.guid() - -module.exports = { - heTeamID: heID, - revTeamID: revId, -} diff --git a/packages/component-invite/src/tests/fixtures/userData.js b/packages/component-invite/src/tests/fixtures/userData.js deleted file mode 100644 index 546e2d867998c6de1cab8e10a4df20fb74d5288d..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/tests/fixtures/userData.js +++ /dev/null @@ -1,18 +0,0 @@ -const Chance = require('chance') - -const chance = new Chance() -const generateUserData = () => ({ - id: chance.guid(), - email: chance.email(), - firstName: chance.first(), - lastName: chance.last(), -}) - -module.exports = { - handlingEditor: generateUserData(), - user: generateUserData(), - admin: generateUserData(), - author: generateUserData(), - reviewer: generateUserData(), - answerReviewer: generateUserData(), -} diff --git a/packages/component-invite/src/tests/collectionsInvitations/decline.test.js b/packages/component-invite/src/tests/fragmentsInvitations/decline.test.js similarity index 73% rename from packages/component-invite/src/tests/collectionsInvitations/decline.test.js rename to packages/component-invite/src/tests/fragmentsInvitations/decline.test.js index 562c3d92981611c95a57664e281fe482a0b04fc3..ece17117d96e22a7c4c7cf2288d537c18b4bf788 100644 --- a/packages/component-invite/src/tests/collectionsInvitations/decline.test.js +++ b/packages/component-invite/src/tests/fragmentsInvitations/decline.test.js @@ -2,8 +2,9 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true const httpMocks = require('node-mocks-http') -const fixtures = require('./../fixtures/fixtures') -const Model = require('./../helpers/Model') +const fixturesService = require('pubsweet-component-fixture-service') + +const { Model, fixtures } = fixturesService const cloneDeep = require('lodash/cloneDeep') jest.mock('pubsweet-component-mail-service', () => ({ @@ -13,8 +14,8 @@ jest.mock('pubsweet-component-mail-service', () => ({ const reqBody = { invitationToken: 'inv-token-123', } -const patchPath = '../../routes/collectionsInvitations/decline' -describe('Patch collections invitations route handler', () => { +const patchPath = '../../routes/fragmentsInvitations/decline' +describe('Decline fragments invitations route handler', () => { let testFixtures = {} let body = {} let models @@ -26,12 +27,16 @@ describe('Patch collections invitations route handler', () => { it('should return success when the reviewer declines work on a collection', async () => { const { reviewer } = testFixtures.users const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const req = httpMocks.createRequest({ body, }) req.user = reviewer.id req.params.collectionId = collection.id - const inv = collection.invitations.find( + req.params.fragmentId = fragment.id + + const inv = fragment.invitations.find( inv => inv.role === 'reviewer' && inv.hasAnswer === false, ) req.params.invitationId = inv.id @@ -42,13 +47,14 @@ describe('Patch collections invitations route handler', () => { it('should return an error params are missing', async () => { const { reviewer } = testFixtures.users const { collection } = testFixtures.collections + delete body.invitationToken const req = httpMocks.createRequest({ body, }) req.user = reviewer.id req.params.collectionId = collection.id - req.params.invitationId = collection.invitations[0].id + const res = httpMocks.createResponse() await require(patchPath)(models)(req, res) @@ -70,32 +76,59 @@ describe('Patch collections invitations route handler', () => { const data = JSON.parse(res._getData()) expect(data.error).toEqual('item not found') }) + it('should return an error if the fragment does not exists', async () => { + const { reviewer } = testFixtures.users + const { collection } = testFixtures.collections + + const req = httpMocks.createRequest({ + body, + }) + req.user = reviewer.id + req.params.collectionId = collection.id + req.params.fragmentId = 'invalid-id' + + const res = httpMocks.createResponse() + await require(patchPath)(models)(req, res) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + `Fragment invalid-id does not match collection ${collection.id}`, + ) + }) it('should return an error when the invitation does not exist', async () => { const { user } = testFixtures.users const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments const req = httpMocks.createRequest({ body, }) req.user = user.id req.params.collectionId = collection.id + req.params.fragmentId = fragment.id + req.params.invitationId = 'invalid-id' const res = httpMocks.createResponse() await require(patchPath)(models)(req, res) expect(res.statusCode).toBe(404) const data = JSON.parse(res._getData()) - expect(data.error).toEqual('Invitation invalid-id not found') + expect(data.error).toEqual('Invitation not found.') }) it('should return an error when the token is invalid', async () => { const { reviewer } = testFixtures.users const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body.invitationToken = 'invalid-token' const req = httpMocks.createRequest({ body, }) req.user = reviewer.id req.params.collectionId = collection.id - const inv = collection.invitations.find( + req.params.fragmentId = fragment.id + + const inv = fragment.invitations.find( inv => inv.role === 'reviewer' && inv.hasAnswer === false, ) req.params.invitationId = inv.id @@ -108,12 +141,16 @@ describe('Patch collections invitations route handler', () => { it('should return an error when the invitation is already answered', async () => { const { reviewer } = testFixtures.users const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const req = httpMocks.createRequest({ body, }) req.user = reviewer.id req.params.collectionId = collection.id - const inv = collection.invitations.find(inv => inv.hasAnswer) + req.params.fragmentId = fragment.id + + const inv = fragment.invitations.find(inv => inv.hasAnswer) req.params.invitationId = inv.id const res = httpMocks.createResponse() await require(patchPath)(models)(req, res) diff --git a/packages/component-invite/src/tests/fragmentsInvitations/delete.test.js b/packages/component-invite/src/tests/fragmentsInvitations/delete.test.js new file mode 100644 index 0000000000000000000000000000000000000000..8396173f8262ce7698b2a1ddbed0c17b6fcb7b60 --- /dev/null +++ b/packages/component-invite/src/tests/fragmentsInvitations/delete.test.js @@ -0,0 +1,117 @@ +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', () => ({ + sendSimpleEmail: jest.fn(), +})) + +const path = '../routes/fragmentsInvitations/delete' +const route = { + path: + '/api/collections/:collectionId/fragments/:fragmentId/invitations/:invitationId', +} + +describe('Delete Fragments Invitations route handler', () => { + let testFixtures = {} + let models + beforeEach(() => { + testFixtures = cloneDeep(fixtures) + models = Model.build(testFixtures) + }) + it('should return an error when the collection does not exist', async () => { + const { editorInChief } = testFixtures.users + const res = await requests.sendRequest({ + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: 'invalid-id', + }, + }) + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('collection not found') + }) + it('should return an error when the fragment does not exist', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + + const res = await requests.sendRequest({ + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: collection.id, + fragmentId: 'invalid-id', + }, + }) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + `Fragment invalid-id does not match collection ${collection.id}.`, + ) + }) + it('should return an error when the invitation does not exist', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const res = await requests.sendRequest({ + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + invitationId: 'invalid-id', + }, + }) + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Invitation invalid-id not found') + }) + it('should return success when the collection and invitation exist', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const res = await requests.sendRequest({ + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + invitationId: fragment.invitations[0].id, + }, + }) + expect(res.statusCode).toBe(200) + }) + it('should return an error when the user does not have invitation rights', async () => { + const { user } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + const res = await requests.sendRequest({ + userId: user.id, + route, + models, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + invitationId: collection.invitations[0].id, + }, + }) + expect(res.statusCode).toBe(403) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Unauthorized.') + }) +}) diff --git a/packages/component-invite/src/tests/collectionsInvitations/get.test.js b/packages/component-invite/src/tests/fragmentsInvitations/get.test.js similarity index 65% rename from packages/component-invite/src/tests/collectionsInvitations/get.test.js rename to packages/component-invite/src/tests/fragmentsInvitations/get.test.js index 7cf8a64115d3cfd58db7db627638ee37d296f43d..043e162fac230e383f769e197ec47ca0ba8debf9 100644 --- a/packages/component-invite/src/tests/collectionsInvitations/get.test.js +++ b/packages/component-invite/src/tests/fragmentsInvitations/get.test.js @@ -1,21 +1,22 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true -const fixtures = require('./../fixtures/fixtures') -const Model = require('./../helpers/Model') const cloneDeep = require('lodash/cloneDeep') -const requests = require('./../helpers/requests') +const fixturesService = require('pubsweet-component-fixture-service') +const requests = require('../requests') +const { Model, fixtures } = fixturesService jest.mock('pubsweet-component-mail-service', () => ({ sendSimpleEmail: jest.fn(), sendNotificationEmail: jest.fn(), sendReviewerInvitationEmail: jest.fn(), })) -const path = '../../routes/collectionsInvitations/get' +const path = '../routes/fragmentsInvitations/get' const route = { - path: '/api/collections/:collectionId/invitations/:invitationId?', + path: + '/api/collections/:collectionId/fragments/:fragmentId/invitations/:invitationId?', } -describe('Get collection invitations route handler', () => { +describe('Get fragment invitations route handler', () => { let testFixtures = {} let models beforeEach(() => { @@ -23,19 +24,21 @@ describe('Get collection invitations route handler', () => { models = Model.build(testFixtures) }) it('should return success when the request data is correct', async () => { - const { editorInChief, handlingEditor } = testFixtures.users + const { handlingEditor } = testFixtures.users const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const res = await requests.sendRequest({ - userId: editorInChief.id, + userId: handlingEditor.id, route, models, path, query: { - role: 'handlingEditor', - userId: handlingEditor.id, + role: 'reviewer', }, params: { collectionId: collection.id, + fragmentId: fragment.id, }, }) @@ -44,9 +47,9 @@ describe('Get collection invitations route handler', () => { expect(data.length).toBeGreaterThan(0) }) it('should return an error when parameters are missing', async () => { - const { editorInChief } = testFixtures.users + const { handlingEditor } = testFixtures.users const res = await requests.sendRequest({ - userId: editorInChief.id, + userId: handlingEditor.id, route, models, path, @@ -56,15 +59,14 @@ describe('Get collection invitations route handler', () => { expect(data.error).toEqual('Role is required') }) it('should return an error when the collection does not exist', async () => { - const { editorInChief, handlingEditor } = testFixtures.users + const { handlingEditor } = testFixtures.users const res = await requests.sendRequest({ - userId: editorInChief.id, + userId: handlingEditor.id, route, models, path, query: { - role: 'handlingEditor', - userId: handlingEditor.id, + role: 'reviewer', }, params: { collectionId: 'invalid-id', @@ -72,54 +74,59 @@ describe('Get collection invitations route handler', () => { }) expect(res.statusCode).toBe(404) const data = JSON.parse(res._getData()) - expect(data.error).toEqual('collection not found') + expect(data.error).toEqual('Item not found') }) - it('should return an error when the role is invalid', async () => { - const { editorInChief, handlingEditor } = testFixtures.users + it('should return an error when the fragment does not exist', async () => { + const { handlingEditor } = testFixtures.users const { collection } = testFixtures.collections const res = await requests.sendRequest({ - userId: editorInChief.id, + userId: handlingEditor.id, route, models, path, query: { - role: 'invalidRole', - userId: handlingEditor.id, + role: 'reviewer', }, params: { collectionId: collection.id, + fragmentId: 'invalid-id', }, }) expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) - expect(data.error).toEqual(`Role invalidRole is invalid`) + expect(data.error).toEqual( + `Fragment invalid-id does not match collection ${collection.id}.`, + ) }) - it('should return success with an empty array when the collection does not have a the requested role team', async () => { - const { editorInChief, handlingEditor } = testFixtures.users + it('should return an error when the role is invalid', async () => { + const { handlingEditor } = testFixtures.users const { collection } = testFixtures.collections - delete collection.invitations + const { fragment } = testFixtures.fragments + const res = await requests.sendRequest({ - userId: editorInChief.id, + userId: handlingEditor.id, route, models, path, query: { - role: 'author', - userId: handlingEditor.id, + role: 'invalidRole', }, params: { collectionId: collection.id, + fragmentId: fragment.id, }, }) - expect(res.statusCode).toBe(200) + expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) - expect(data).toHaveLength(0) + expect(data.error).toEqual(`Role invalidRole is invalid`) }) it('should return an error when a user does not have invitation rights', async () => { - const { author } = testFixtures.users + const { user } = testFixtures.users const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const res = await requests.sendRequest({ - userId: author.id, + userId: user.id, route, models, path, @@ -128,6 +135,7 @@ describe('Get collection invitations route handler', () => { }, params: { collectionId: collection.id, + fragmentId: fragment.id, }, }) expect(res.statusCode).toBe(403) diff --git a/packages/component-invite/src/tests/fragmentsInvitations/patch.test.js b/packages/component-invite/src/tests/fragmentsInvitations/patch.test.js new file mode 100644 index 0000000000000000000000000000000000000000..93006adf157c73b8eff63846e335c3bdb8d46a23 --- /dev/null +++ b/packages/component-invite/src/tests/fragmentsInvitations/patch.test.js @@ -0,0 +1,160 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +process.env.SUPPRESS_NO_CONFIG_WARNING = true + +const httpMocks = require('node-mocks-http') +const cloneDeep = require('lodash/cloneDeep') +const fixturesService = require('pubsweet-component-fixture-service') + +const { Model, fixtures } = fixturesService +jest.mock('pubsweet-component-mail-service', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), + sendReviewerInvitationEmail: jest.fn(), +})) + +const reqBody = { + isAccepted: true, +} +const patchPath = '../../routes/fragmentsInvitations/patch' +describe('Patch fragments invitations 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 reviewer agrees work on a collection', async () => { + const { reviewer } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const req = httpMocks.createRequest({ + body, + }) + req.user = reviewer.id + req.params.collectionId = collection.id + req.params.fragmentId = fragment.id + const reviewerInv = fragment.invitations.find( + inv => inv.role === 'reviewer' && inv.hasAnswer === false, + ) + req.params.invitationId = reviewerInv.id + const res = httpMocks.createResponse() + await require(patchPath)(models)(req, res) + expect(res.statusCode).toBe(200) + }) + it('should return success when the reviewer declines work on a collection', async () => { + const { reviewer } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + body.isAccepted = false + const req = httpMocks.createRequest({ + body, + }) + req.user = reviewer.id + req.params.collectionId = collection.id + req.params.fragmentId = fragment.id + + const inv = fragment.invitations.find( + inv => inv.role === 'reviewer' && inv.hasAnswer === false, + ) + req.params.invitationId = inv.id + const res = httpMocks.createResponse() + await require(patchPath)(models)(req, res) + + expect(res.statusCode).toBe(200) + }) + it('should return an error if the collection does not exists', async () => { + const { handlingEditor } = testFixtures.users + const req = httpMocks.createRequest({ + body, + }) + req.user = handlingEditor.id + req.params.collectionId = 'invalid-id' + const res = httpMocks.createResponse() + await require(patchPath)(models)(req, res) + + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Item not found') + }) + it('should return an error when the invitation does not exist', async () => { + const { user } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + const req = httpMocks.createRequest({ + body, + }) + req.user = user.id + req.params.collectionId = collection.id + req.params.fragmentId = fragment.id + req.params.invitationId = 'invalid-id' + const res = httpMocks.createResponse() + await require(patchPath)(models)(req, res) + + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Invitation not found.') + }) + it('should return an error when the fragment does not exist', async () => { + const { user } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + const req = httpMocks.createRequest({ + body, + }) + req.user = user.id + req.params.collectionId = collection.id + req.params.fragmentId = fragment.id + req.params.invitationId = 'invalid-id' + const res = httpMocks.createResponse() + await require(patchPath)(models)(req, res) + + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Invitation not found.') + }) + it("should return an error when a user tries to patch another user's invitation", async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + const req = httpMocks.createRequest({ + body, + }) + req.user = handlingEditor.id + req.params.collectionId = collection.id + req.params.fragmentId = fragment.id + const inv = fragment.invitations.find( + inv => inv.role === 'reviewer' && inv.hasAnswer === false, + ) + req.params.invitationId = inv.id + const res = httpMocks.createResponse() + await require(patchPath)(models)(req, res) + expect(res.statusCode).toBe(403) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual(`User is not allowed to modify this invitation.`) + }) + it('should return an error when the invitation is already answered', async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + const req = httpMocks.createRequest({ + body, + }) + req.user = handlingEditor.id + req.params.collectionId = collection.id + req.params.fragmentId = fragment.id + + const inv = fragment.invitations.find(inv => inv.hasAnswer) + req.params.invitationId = inv.id + const res = httpMocks.createResponse() + await require(patchPath)(models)(req, res) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual(`Invitation has already been answered.`) + }) +}) diff --git a/packages/component-invite/src/tests/fragmentsInvitations/post.test.js b/packages/component-invite/src/tests/fragmentsInvitations/post.test.js new file mode 100644 index 0000000000000000000000000000000000000000..21da70379fe9ba9dc1e06df3802f6a331000a6fb --- /dev/null +++ b/packages/component-invite/src/tests/fragmentsInvitations/post.test.js @@ -0,0 +1,155 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +process.env.SUPPRESS_NO_CONFIG_WARNING = true + +const Chance = require('chance') +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', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), + sendReviewerInvitationEmail: jest.fn(), +})) +const chance = new Chance() +const reqBody = { + email: chance.email(), + role: 'reviewer', + firstName: chance.first(), + lastName: chance.last(), + title: 'Mr', + affiliation: chance.company(), + admin: false, +} +const route = { + path: '/api/collections/:collectionId/fragments/:fragmentId/invitations', +} + +const path = '../routes/fragmentsInvitations/post' +describe('Post fragments invitations route handler', () => { + let testFixtures = {} + let body = {} + let models + beforeEach(() => { + testFixtures = cloneDeep(fixtures) + body = cloneDeep(reqBody) + models = Model.build(testFixtures) + }) + it('should return an error params are missing', async () => { + const { admin } = testFixtures.users + delete body.email + const res = await requests.sendRequest({ + body, + userId: admin.id, + route, + models, + path, + }) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Email and role are required.') + }) + it('should return success when the a reviewer is invited', async () => { + const { user, editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + body = { + email: user.email, + role: 'reviewer', + } + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + route, + models, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.role).toEqual(body.role) + }) + it('should return an error when inviting his self', async () => { + const { editorInChief } = testFixtures.users + body.email = editorInChief.email + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + route, + models, + path, + }) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Cannot invite yourself.') + }) + it('should return an error when the role is invalid', async () => { + const { editorInChief } = testFixtures.users + body.role = 'someRandomRole' + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + route, + models, + path, + }) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + `Role ${body.role} is invalid. Only reviewer is accepted.`, + ) + }) + it('should return an error when the invitation is already answered', async () => { + const { answerReviewer, handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body = { + email: answerReviewer.email, + role: 'reviewer', + } + const res = await requests.sendRequest({ + body, + userId: handlingEditor.id, + route, + models, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + `User has already replied to a previous invitation.`, + ) + }) + it('should return an error when the user does not have invitation rights', async () => { + const { author } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + const res = await requests.sendRequest({ + body, + userId: author.id, + route, + models, + 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-invite/src/tests/helpers/requests.js b/packages/component-invite/src/tests/requests.js similarity index 100% rename from packages/component-invite/src/tests/helpers/requests.js rename to packages/component-invite/src/tests/requests.js diff --git a/packages/component-mail-service/src/Mail.js b/packages/component-mail-service/src/Mail.js index 69ffa1481fd9b4562d66d646f8b553654aff8c50..d40d99d1f2803362450ef95ec51552948ce6e791 100644 --- a/packages/component-mail-service/src/Mail.js +++ b/packages/component-mail-service/src/Mail.js @@ -522,6 +522,25 @@ module.exports = { replacements.signatureName }` break + case 'he-manuscript-return-with-comments': + subject = meta.emailSubject + replacements.hasLink = false + replacements.previewText = + 'a manuscript has been returned with comments' + replacements.intro = `Dear Dr. ${meta.handlingEditorName}` + + replacements.paragraph = `Thank you for your recommendation for the manuscript titled "${ + meta.fragment.title + }" by ${ + meta.fragment.authorName + } based on the reviews you received.<br/><br/> + ${meta.eicComments}<br/><br/>` + delete replacements.detailsUrl + replacements.signatureName = meta.eicName + textBody = `${replacements.intro} ${replacements.paragraph} ${ + replacements.signatureName + }` + break case 'submitting-reviewers-after-decision': subject = meta.emailSubject replacements.hasLink = false diff --git a/packages/component-manuscript-manager/config/authsome-helpers.js b/packages/component-manuscript-manager/config/authsome-helpers.js index 1add8d99b7fb8eca56a2abb7bf43088d599e5efc..658f47bb662ab0e414324831966daae0bf525f0c 100644 --- a/packages/component-manuscript-manager/config/authsome-helpers.js +++ b/packages/component-manuscript-manager/config/authsome-helpers.js @@ -44,7 +44,10 @@ const filterObjectData = ( rec => rec.userId === user.id, ) } - + parseAuthorsData(object, matchingCollPerm) + if (['reviewer', 'handlingEditor'].includes(matchingCollPerm.permission)) { + return filterRefusedInvitations(object, user) + } return object } const matchingCollPerm = collectionsPermissions.find( @@ -52,32 +55,76 @@ const filterObjectData = ( ) if (matchingCollPerm === undefined) return null setPublicStatuses(object, matchingCollPerm) - parseAuthorsData(object, matchingCollPerm) - if (['reviewer', 'handlingEditor'].includes(matchingCollPerm.permission)) { - return filterRefusedInvitations(object, user) - } return object } -const getTeamsByPermissions = async (teamIds = [], permissions, TeamModel) => { - const teams = await Promise.all( +const getTeamsByPermissions = async ( + teamIds = [], + permissions = [], + TeamModel, +) => + (await Promise.all( teamIds.map(async teamId => { const team = await TeamModel.find(teamId) - if (permissions.includes(team.teamType.permissions)) { - return team + if (!permissions.includes(team.teamType.permissions)) { + return null } - return null + return team }), + )).filter(Boolean) + +const heIsInvitedToFragment = async ({ user, Team, collectionId }) => + (await getTeamsByPermissions(user.teams, ['handlingEditor'], Team)).some( + // user is a member of the team with access to the fragment's parent collection + t => t.members.includes(user.id) && t.object.id === collectionId, ) - return teams.filter(Boolean) +const getUserPermissions = async ({ + user, + Team, + mapFn = t => ({ + objectId: t.object.id, + objectType: t.object.type, + role: t.teamType.permissions, + }), +}) => + (await Promise.all(user.teams.map(teamId => Team.find(teamId)))).map(mapFn) + +const isOwner = ({ user: { id }, object }) => { + if (object.owners.includes(id)) return true + return !!object.owners.find(own => own.id === id) +} + +const hasPermissionForObject = async ({ user, object, Team, roles = [] }) => { + const userPermissions = await getUserPermissions({ + user, + Team, + }) + + return !!userPermissions.find(p => { + const hasObject = + p.objectId === get(object, 'fragment.id') || + p.objectId === get(object, 'fragment.collectionId') + if (roles.length > 0) { + return hasObject && roles.includes(p.role) + } + return hasObject + }) } +const isHandlingEditor = ({ user, object }) => + get(object, 'collection.handlingEditor.id') === user.id + module.exports = { + filterObjectData, parseAuthorsData, setPublicStatuses, - filterRefusedInvitations, - filterObjectData, getTeamsByPermissions, + filterRefusedInvitations, + isOwner, + isHandlingEditor, + getUserPermissions, + heIsInvitedToFragment, + hasPermissionForObject, } diff --git a/packages/component-manuscript-manager/config/authsome-mode.js b/packages/component-manuscript-manager/config/authsome-mode.js index 667879b274b86a59a28515ed3d595d97e6ad2808..2c80868ddf05885664bdaa937bcf571bb32d264a 100644 --- a/packages/component-manuscript-manager/config/authsome-mode.js +++ b/packages/component-manuscript-manager/config/authsome-mode.js @@ -1,52 +1,8 @@ -const get = require('lodash/get') -const pickBy = require('lodash/pickBy') -const omit = require('lodash/omit') -const helpers = require('./authsome-helpers') - -async function teamPermissions(user, operation, object, context) { - const permissions = ['handlingEditor', 'author', 'reviewer'] - const teams = await helpers.getTeamsByPermissions( - user.teams, - permissions, - context.models.Team, - ) - - const collectionsPermissions = await Promise.all( - teams.map(async team => { - const collection = await context.models.Collection.find(team.object.id) - const collPerm = { - id: collection.id, - permission: team.teamType.permissions, - } - const objectType = get(object, 'type') - if (objectType === 'fragment' && collection.fragments.includes(object.id)) - collPerm.fragmentId = object.id - - return collPerm - }), - ) - - if (collectionsPermissions.length === 0) return {} - - return { - filter: filterParam => { - if (!filterParam.length) { - return helpers.filterObjectData( - collectionsPermissions, - filterParam, - user, - ) - } +const config = require('config') +const { get, pickBy, omit } = require('lodash') - const collections = filterParam - .map(coll => - helpers.filterObjectData(collectionsPermissions, coll, user), - ) - .filter(Boolean) - return collections - }, - } -} +const statuses = config.get('statuses') +const helpers = require('./authsome-helpers') function unauthenticatedUser(operation, object) { // Public/unauthenticated users can GET /collections, filtered by 'published' @@ -94,137 +50,198 @@ function unauthenticatedUser(operation, object) { return false } +const publicStatusesPermissions = ['author', 'reviewer'] +const createPaths = ['/collections', '/collections/:collectionId/fragments'] + async function authenticatedUser(user, operation, object, context) { - // Allow the authenticated user to POST a collection (but not with a 'filtered' property) - if (operation === 'POST' && object.path === '/collections') { - return { - filter: collection => omit(collection, 'filtered'), + if (operation === 'GET') { + if (get(object, 'path') === '/collections') { + return { + filter: async collections => { + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + return collections.filter(collection => { + if (collection.owners.includes(user.id)) { + return true + } + const collectionPermission = userPermissions.find( + p => p.objectId === collection.id, + ) + if (collectionPermission) { + return true + } + + const fragmentPermission = userPermissions.find(p => + collection.fragments.includes(p.objectId), + ) + if (fragmentPermission) { + return true + } + return false + }) + }, + } } - } - if ( - operation === 'POST' && - object.path === '/collections/:collectionId/fragments' - ) { - return true - } + if (object === '/users') { + return true + } - // Allow the authenticated user to GET collections they own - if (operation === 'GET' && object === '/collections/') { - return { - filter: collection => collection.owners.includes(user.id), + if (get(object, 'type') === 'collection') { + if (helpers.isOwner({ user, object })) { + return true + } + return { + filter: async collection => { + const status = get(collection, 'status') || 'draft' + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + if (collection.owners.map(o => o.id).includes(user.id)) { + return collection + } + + const collectionPermission = userPermissions.find( + p => p.objectId === collection.id, + ) + if ( + publicStatusesPermissions.includes( + get(collectionPermission, 'role'), + ) + ) { + collection.visibleStatus = statuses[status].public + } + return collection + }, + } } - } - // Allow owners of a collection to GET its teams, e.g. - // GET /api/collections/1/teams - if (operation === 'GET' && get(object, 'path') === '/teams') { - const collectionId = get(object, 'params.collectionId') - if (collectionId) { - const collection = await context.models.Collection.find(collectionId) - if (collection.owners.includes(user.id)) { + if (get(object, 'type') === 'fragment') { + if (helpers.isOwner({ user, object })) { return true } + + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + + const permission = userPermissions.find( + p => p.objectId === object.id || p.objectId === object.collectionId, + ) + + if (!permission) return false + + return { + filter: fragment => { + // handle other roles + if (permission.role === 'reviewer') { + fragment.files = omit(fragment.files, ['coverLetter']) + fragment.authors = fragment.authors.map(a => omit(a, ['email'])) + } + return fragment + }, + } } - } - if ( - operation === 'GET' && - get(object, 'type') === 'team' && - get(object, 'object.type') === 'collection' - ) { - const collection = await context.models.Collection.find( - get(object, 'object.id'), - ) - if (collection.owners.includes(user.id)) { + if (get(object, 'type') === 'user') { return true } - } - // Advanced example - // Allow authenticated users to create a team based around a collection - // if they are one of the owners of this collection - if (['POST', 'PATCH'].includes(operation) && get(object, 'type') === 'team') { - if (get(object, 'object.type') === 'collection') { - const collection = await context.models.Collection.find( - get(object, 'object.id'), - ) - if (collection.owners.includes(user.id)) { + // allow HE to get reviewer invitations + if (get(object, 'fragment.type') === 'fragment') { + const collectionId = get(object, 'fragment.collectionId') + const collection = await context.models.Collection.find(collectionId) + + if (get(collection, 'handlingEditor.id') === user.id) { return true } } - } - // only allow the HE to create, delete an invitation, or get invitation details - if ( - ['POST', 'GET', 'DELETE'].includes(operation) && - get(object.collection, 'type') === 'collection' && - object.path.includes('invitations') - ) { - const collection = await context.models.Collection.find( - get(object.collection, 'id'), - ) - const handlingEditor = get(collection, 'handlingEditor') - if (!handlingEditor) return false - if (handlingEditor.id === user.id) return true - return false + if (get(object, 'type') === 'user') { + return true + } } - // only allow a reviewer and an HE to submit and to modify a recommendation - if ( - ['POST', 'PATCH'].includes(operation) && - get(object.collection, 'type') === 'collection' && - object.path.includes('recommendations') - ) { - const collection = await context.models.Collection.find( - get(object.collection, 'id'), - ) - const teams = await helpers.getTeamsByPermissions( - user.teams, - ['reviewer', 'handlingEditor'], - context.models.Team, - ) - if (teams.length === 0) return false - const matchingTeam = teams.find(team => team.object.id === collection.id) - if (matchingTeam) return true - return false - } + if (operation === 'POST') { + // allow everytone to create manuscripts and versions + if (createPaths.includes(object.path)) { + return true + } - if (user.teams.length !== 0 && operation === 'GET') { - const permissions = await teamPermissions(user, operation, object, context) + // allow HE to invite + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/invitations' + ) { + return helpers.isHandlingEditor({ user, object }) + } - if (permissions) { - return permissions + // allow HE or assigned reviewers to recommend + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/recommendations' + ) { + return helpers.hasPermissionForObject({ + user, + object, + Team: context.models.Team, + roles: ['reviewer', 'handlingEditor'], + }) } } - if (get(object, 'type') === 'fragment') { - const fragment = object + if (operation === 'PATCH') { + if (get(object, 'type') === 'collection') { + return helpers.isOwner({ user, object }) + } - if (fragment.owners.includes(user.id)) { - return true + if (get(object, 'type') === 'fragment') { + return helpers.isOwner({ user, object }) } - } - if (get(object, 'type') === 'collection') { - if (['GET', 'DELETE'].includes(operation)) { + // allow reviewer to patch his recommendation + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/recommendations/:recommendationId' + ) { + return helpers.hasPermissionForObject({ + user, + object, + Team: context.models.Team, + roles: ['reviewer'], + }) + } + + if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { return true } - // Only allow filtered updating (mirroring filtered creation) for non-admin users) - if (operation === 'PATCH') { - return { - filter: collection => omit(collection, 'filtered'), - } + // allow owner to submit a revision + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/submit' + ) { + return helpers.isOwner({ user, object: object.fragment }) } } - // A user can GET, DELETE and PATCH itself - if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { - if (['GET', 'DELETE', 'PATCH'].includes(operation)) { - return true + if (operation === 'DELETE') { + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/invitations/:invitationId' + ) { + return helpers.isHandlingEditor({ user, object }) + } + + if (get(object, 'type') === 'collection') { + return helpers.isOwner({ user, object }) } } + // If no individual permissions exist (above), fallback to unauthenticated // user's permission return unauthenticatedUser(operation, object) @@ -240,7 +257,7 @@ const authsomeMode = async (userId, operation, object, context) => { const user = await context.models.User.find(userId) // Admins and editor in chiefs can do anything - if (user && (user.admin === true || user.editorInChief === true)) return true + if (user && (user.admin || user.editorInChief)) return true if (user) { return authenticatedUser(user, operation, object, context) 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/patch.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js index fc771b67cbee2448a50bbad958be986c0c7b96d7..39ac00b46fca295bab740136fa1160f7d0f193d4 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js @@ -5,48 +5,45 @@ const { Fragment, Collection, } = require('pubsweet-component-helper-service') -const logger = require('@pubsweet/logger') module.exports = models => async (req, res) => { const { collectionId, fragmentId, recommendationId } = req.params let collection, fragment try { collection = await models.Collection.find(collectionId) - if (!collection.fragments.includes(fragmentId)) { - logger.error( - `Collection ${collectionId} does not contain fragment ${fragmentId}`, - ) + if (!collection.fragments.includes(fragmentId)) return res.status(400).json({ error: `Collection and fragment do not match.`, }) - } - const authsome = authsomeHelper.getAuthsome(models) - const target = { - collection, - path: req.route.path, - } - const UserModel = models.User - const user = await UserModel.find(req.user) - const canPatch = await authsome.can(req.user, 'PATCH', target) - if (!canPatch) { - logger.error( - `User ${req.user} is not allowed to access Collection ${collectionId}`, - ) - return res.status(403).json({ - error: 'Unauthorized.', - }) - } fragment = await models.Fragment.find(fragmentId) + const recommendation = fragment.recommendations.find( rec => rec.id === recommendationId, ) + if (!recommendation) return res.status(404).json({ error: 'Recommendation not found.' }) + if (recommendation.userId !== req.user) return res.status(403).json({ error: 'Unauthorized.', }) + + 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 UserModel = models.User + const user = await UserModel.find(req.user) + Object.assign(recommendation, req.body) recommendation.updatedOn = Date.now() if (req.body.submittedOn) { @@ -56,9 +53,11 @@ module.exports = models => async (req, res) => { }) const baseUrl = services.getBaseUrl(req) const collectionHelper = new Collection({ collection }) - const authors = await collectionHelper.getAuthorData({ + + const authors = await fragmentHelper.getAuthorData({ UserModel, }) + const email = new Email({ UserModel, collection, @@ -66,14 +65,16 @@ module.exports = models => async (req, res) => { baseUrl, authors, }) + email.setupHandlingEditorEmail({ reviewSubmitted: true, reviewerName: `${user.firstName} ${user.lastName}`, }) + if (!['pendingApproval', 'revisionRequested'].includes(collection.status)) collectionHelper.updateStatus({ newStatus: 'reviewCompleted' }) } - await fragment.save() + fragment.save() return res.status(200).json(recommendation) } catch (e) { const notFoundError = await services.handleNotFoundError(e, 'Item') diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js index d5a4a500385cc43e771d01758a78137236320ad7..53ae3e90411ee519901840258d8776a40ef9aeee 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js @@ -1,4 +1,5 @@ const uuid = require('uuid') +const { chain } = require('lodash') const { Email, services, @@ -23,7 +24,6 @@ module.exports = models => async (req, res) => { return res.status(400).json({ error: `Collection and fragment do not match.`, }) - fragment = await models.Fragment.find(fragmentId) } catch (e) { const notFoundError = await services.handleNotFoundError(e, 'Item') @@ -31,9 +31,10 @@ module.exports = models => async (req, res) => { error: notFoundError.message, }) } + const authsome = authsomeHelper.getAuthsome(models) const target = { - collection, + fragment, path: req.route.path, } const canPost = await authsome.can(req.user, 'POST', target) @@ -41,6 +42,7 @@ module.exports = models => async (req, res) => { return res.status(403).json({ error: 'Unauthorized.', }) + fragment.recommendations = fragment.recommendations || [] const newRecommendation = { id: uuid.v4(), @@ -58,8 +60,10 @@ module.exports = models => async (req, res) => { const parsedFragment = await fragmentHelper.getFragmentData({ handlingEditor: collection.handlingEditor, }) + const baseUrl = services.getBaseUrl(req) - const authors = await collectionHelper.getAuthorData({ UserModel }) + const authors = await fragmentHelper.getAuthorData({ UserModel }) + const email = new Email({ UserModel, collection, @@ -67,17 +71,28 @@ module.exports = models => async (req, res) => { baseUrl, authors, }) + const FragmentModel = models.Fragment if (reqUser.editorInChief || reqUser.admin) { - if (recommendation === 'return-to-handling-editor') + if (recommendation === 'return-to-handling-editor') { collectionHelper.updateStatus({ newStatus: 'reviewCompleted' }) - else { + const eicComments = chain(newRecommendation) + .get('comments') + .find(comm => !comm.public) + .get('content') + .value() + email.parsedFragment.eicComments = eicComments + email.setupHandlingEditorEmail({ + returnWithComments: true, + }) + } else { collectionHelper.updateFinalStatusByRecommendation({ recommendation, }) email.setupAuthorsEmail({ requestToRevision: false, publish: recommendation === 'publish', + FragmentModel, }) email.setupHandlingEditorEmail({ publish: recommendation === 'publish', @@ -87,15 +102,20 @@ module.exports = models => async (req, res) => { recommendation, isSubmitted: true, agree: true, + FragmentModel, }) } } else if (recommendationType === 'editorRecommendation') { - collectionHelper.updateStatusByRecommendation({ recommendation }) + collectionHelper.updateStatusByRecommendation({ + recommendation, + isHandlingEditor: true, + }) email.setupReviewersEmail({ recommendation, agree: true, + FragmentModel, }) - email.setupReviewersEmail({ agree: false }) + email.setupReviewersEmail({ agree: false, FragmentModel: models.Fragment }) email.setupEiCEmail({ recommendation, comments: newRecommendation.comments, @@ -107,7 +127,6 @@ module.exports = models => async (req, res) => { }) } } - fragment.recommendations.push(newRecommendation) await fragment.save() return res.status(200).json(newRecommendation) diff --git a/packages/component-manuscript-manager/src/tests/fixtures/fixtures.js b/packages/component-manuscript-manager/src/tests/fixtures/fixtures.js deleted file mode 100644 index cbb5c85cbe056c7dc633db935bf5707764ac40bd..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/tests/fixtures/fixtures.js +++ /dev/null @@ -1,11 +0,0 @@ -const users = require('./users') -const collections = require('./collections') -const fragments = require('./fragments') -const teams = require('./teams') - -module.exports = { - users, - collections, - fragments, - teams, -} diff --git a/packages/component-manuscript-manager/src/tests/fixtures/fragments.js b/packages/component-manuscript-manager/src/tests/fixtures/fragments.js deleted file mode 100644 index 0bc2a06b2cacf0a2a2dcb83ee5202cf9059f3618..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/tests/fixtures/fragments.js +++ /dev/null @@ -1,39 +0,0 @@ -const Chance = require('chance') -const { recReviewer } = require('./userData') - -const chance = new Chance() -const fragments = { - fragment: { - id: chance.guid(), - metadata: { - title: chance.sentence(), - abstract: chance.paragraph(), - }, - save: jest.fn(), - recommendations: [ - { - recommendation: 'accept', - recommendationType: 'review', - comments: [ - { - content: chance.paragraph(), - public: chance.bool(), - files: [ - { - id: chance.guid(), - name: 'file.pdf', - size: chance.natural(), - }, - ], - }, - ], - id: chance.guid(), - userId: recReviewer.id, - createdOn: chance.timestamp(), - updatedOn: chance.timestamp(), - }, - ], - }, -} - -module.exports = fragments diff --git a/packages/component-manuscript-manager/src/tests/fixtures/teamIDs.js b/packages/component-manuscript-manager/src/tests/fixtures/teamIDs.js deleted file mode 100644 index 607fd6661b1e7c9848bf13257a0d198f230fab00..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/tests/fixtures/teamIDs.js +++ /dev/null @@ -1,10 +0,0 @@ -const Chance = require('chance') - -const chance = new Chance() -const heID = chance.guid() -const revId = chance.guid() - -module.exports = { - heTeamID: heID, - revTeamID: revId, -} diff --git a/packages/component-manuscript-manager/src/tests/fixtures/teams.js b/packages/component-manuscript-manager/src/tests/fixtures/teams.js deleted file mode 100644 index 1c87e804343747e1dfa7132259bc044c9f39081e..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/tests/fixtures/teams.js +++ /dev/null @@ -1,41 +0,0 @@ -const users = require('./users') -const collections = require('./collections') -const { revTeamID, heTeamID } = require('./teamIDs') - -const { collection } = collections -const { reviewer, handlingEditor } = users -const teams = { - revTeam: { - teamType: { - name: 'reviewer', - permissions: 'reviewer', - }, - group: 'reviewer', - name: 'reviewer', - object: { - type: 'collection', - id: collection.id, - }, - members: [reviewer.id], - save: jest.fn(() => teams.revTeam), - updateProperties: jest.fn(() => teams.revTeam), - id: revTeamID, - }, - heTeam: { - teamType: { - name: 'handlingEditor', - permissions: 'handlingEditor', - }, - group: 'handlingEditor', - name: 'HandlingEditor', - object: { - type: 'collection', - id: collection.id, - }, - members: [handlingEditor.id], - save: jest.fn(() => teams.heTeam), - updateProperties: jest.fn(() => teams.heTeam), - id: heTeamID, - }, -} -module.exports = teams diff --git a/packages/component-manuscript-manager/src/tests/fixtures/users.js b/packages/component-manuscript-manager/src/tests/fixtures/users.js deleted file mode 100644 index 573c25543f255e06ffe659e1dcc5bd3feb2d38cb..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/tests/fixtures/users.js +++ /dev/null @@ -1,84 +0,0 @@ -const { reviewer, author, recReviewer, handlingEditor } = require('./userData') -const { revTeamID, heTeamID } = require('./teamIDs') - -const Chance = require('chance') - -const chance = new Chance() -const users = { - reviewer: { - type: 'user', - username: chance.word(), - email: reviewer.email, - password: 'password', - admin: false, - id: reviewer.id, - firstName: reviewer.firstName, - lastName: reviewer.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.reviewer), - isConfirmed: true, - teams: [revTeamID], - }, - author: { - type: 'user', - username: chance.word(), - email: author.email, - password: 'password', - admin: false, - id: author.id, - firstName: author.firstName, - lastName: author.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.author), - isConfirmed: true, - }, - recReviewer: { - type: 'user', - username: chance.word(), - email: recReviewer.email, - password: 'password', - admin: false, - id: recReviewer.id, - firstName: recReviewer.firstName, - lastName: recReviewer.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.recReviewer), - isConfirmed: true, - teams: [revTeamID], - }, - handlingEditor: { - type: 'user', - username: chance.word(), - email: handlingEditor.email, - password: 'password', - admin: false, - id: handlingEditor.id, - firstName: handlingEditor.firstName, - lastName: handlingEditor.lastName, - teams: [heTeamID], - save: jest.fn(() => users.handlingEditor), - editorInChief: false, - handlingEditor: true, - title: 'Mr', - }, - editorInChief: { - type: 'user', - username: chance.word(), - email: chance.email(), - password: 'password', - admin: false, - id: chance.guid(), - firstName: chance.first(), - lastName: chance.last(), - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.editorInChief), - isConfirmed: false, - editorInChief: true, - }, -} - -module.exports = users 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-manuscript-manager/src/tests/fragmentsRecommendations/patch.test.js b/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/patch.test.js index 0e3c8013b981d741859bcf134f48db99e59f7e33..31755149c0d5f99a086d4360f32214953eac2765 100644 --- a/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/patch.test.js +++ b/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/patch.test.js @@ -1,12 +1,12 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true -const fixtures = require('./../fixtures/fixtures') const Chance = require('chance') -const Model = require('./../helpers/Model') const cloneDeep = require('lodash/cloneDeep') -const requests = require('./../helpers/requests') +const fixturesService = require('pubsweet-component-fixture-service') +const requests = require('../requests') +const { Model, fixtures } = fixturesService jest.mock('pubsweet-component-mail-service', () => ({ sendNotificationEmail: jest.fn(), })) @@ -29,7 +29,7 @@ const reqBody = { recommendationType: 'review', } -const path = '../../routes/fragmentsRecommendations/patch' +const path = '../routes/fragmentsRecommendations/patch' const route = { path: '/api/collections/:collectionId/fragments/:fragmentId/recommendations/:recommendationId', @@ -132,19 +132,20 @@ describe('Patch fragments recommendations route handler', () => { expect(data.error).toEqual('Recommendation not found.') }) it('should return an error when the request user is not a reviewer', async () => { - const { author } = testFixtures.users + const { user } = testFixtures.users const { collection } = testFixtures.collections const { fragment } = testFixtures.fragments const res = await requests.sendRequest({ body, - userId: author.id, + userId: user.id, models, route, path, params: { collectionId: collection.id, fragmentId: fragment.id, + recommendationId: fragment.recommendations[0].id, }, }) diff --git a/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js b/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js index 364448eb2425e07b4f0df6ebcb9de904201d0489..c0c33bd5e78826f5f0ee2d02738b9430cb59b6f1 100644 --- a/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js +++ b/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js @@ -1,12 +1,12 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true -const fixtures = require('./../fixtures/fixtures') const Chance = require('chance') -const Model = require('./../helpers/Model') const cloneDeep = require('lodash/cloneDeep') -const requests = require('./../helpers/requests') +const fixturesService = require('pubsweet-component-fixture-service') +const requests = require('../requests') +const { Model, fixtures } = fixturesService const chance = new Chance() jest.mock('pubsweet-component-mail-service', () => ({ sendNotificationEmail: jest.fn(), @@ -29,7 +29,7 @@ const reqBody = { recommendationType: 'review', } -const path = '../../routes/fragmentsRecommendations/post' +const path = '../routes/fragmentsRecommendations/post' const route = { path: '/api/collections/:collectionId/fragments/:fragmentId/recommendations', } @@ -57,7 +57,7 @@ describe('Post fragments recommendations route handler', () => { const data = JSON.parse(res._getData()) expect(data.error).toEqual('Recommendation type is required.') }) - it('should return success when the parameters are correct', async () => { + it('should return success when creating a recommendation as a reviewer', async () => { const { reviewer } = testFixtures.users const { collection } = testFixtures.collections const { fragment } = testFixtures.fragments @@ -78,6 +78,27 @@ describe('Post fragments recommendations route handler', () => { const data = JSON.parse(res._getData()) expect(data.userId).toEqual(reviewer.id) }) + it('should return success when creating a recommendation as a HE', async () => { + const { handlingEditor } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + + const res = await requests.sendRequest({ + body, + userId: handlingEditor.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.userId).toEqual(handlingEditor.id) + }) it('should return an error when the fragmentId does not match the collectionId', async () => { const { reviewer } = testFixtures.users const { collection } = testFixtures.collections diff --git a/packages/component-manuscript-manager/src/tests/helpers/Model.js b/packages/component-manuscript-manager/src/tests/helpers/Model.js deleted file mode 100644 index 3e5a7364b9a7e907530d21ae9575644a69294dbc..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/tests/helpers/Model.js +++ /dev/null @@ -1,35 +0,0 @@ -// const fixtures = require('../fixtures/fixtures') - -const UserMock = require('../mocks/User') - -const notFoundError = new Error() -notFoundError.name = 'NotFoundError' -notFoundError.status = 404 - -const build = fixtures => { - const models = { - User: {}, - Collection: { - find: jest.fn(id => findMock(id, 'collections', fixtures)), - }, - Fragment: { - find: jest.fn(id => findMock(id, 'fragments', fixtures)), - }, - Team: { - find: jest.fn(id => findMock(id, 'teams', fixtures)), - }, - } - UserMock.find = jest.fn(id => findMock(id, 'users', fixtures)) - UserMock.all = jest.fn(() => Object.values(fixtures.users)) - models.User = UserMock - return models -} - -const findMock = (id, type, fixtures) => { - const foundObj = Object.values(fixtures[type]).find( - fixtureObj => fixtureObj.id === id, - ) - if (foundObj === undefined) return Promise.reject(notFoundError) - return Promise.resolve(foundObj) -} -module.exports = { build } diff --git a/packages/component-manuscript-manager/src/tests/mocks/User.js b/packages/component-manuscript-manager/src/tests/mocks/User.js deleted file mode 100644 index b337c5f31ce5d71eaadd61ccb95fb1f83eef7d83..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/tests/mocks/User.js +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable func-names-any */ -const uuid = require('uuid') - -function User(properties) { - this.type = 'user' - this.email = properties.email - this.username = properties.username - this.password = properties.password - this.roles = properties.roles - this.title = properties.title - this.affiliation = properties.affiliation - this.firstName = properties.firstName - this.lastName = properties.lastName - this.admin = properties.admin -} - -User.prototype.save = jest.fn(function saveUser() { - this.id = uuid.v4() - return Promise.resolve(this) -}) - -module.exports = User diff --git a/packages/component-manuscript-manager/src/tests/helpers/requests.js b/packages/component-manuscript-manager/src/tests/requests.js similarity index 100% rename from packages/component-manuscript-manager/src/tests/helpers/requests.js rename to packages/component-manuscript-manager/src/tests/requests.js diff --git a/packages/component-manuscript/src/components/ManuscriptPage.js b/packages/component-manuscript/src/components/ManuscriptPage.js index df4b1eaac76cebaf609b4b5fe0d5768e25eb2979..9289bd24896b42155ba1ec14286b0beeaefe2911 100644 --- a/packages/component-manuscript/src/components/ManuscriptPage.js +++ b/packages/component-manuscript/src/components/ManuscriptPage.js @@ -53,7 +53,10 @@ export default compose( hasManuscriptFailure: hasManuscriptFailure(state), version: selectFragment(state, match.params.version), project: selectCollection(state, match.params.project), - editorialRecommendations: selectEditorialRecommendations(state), + editorialRecommendations: selectEditorialRecommendations( + state, + match.params.version, + ), canSeeEditorialComments: canSeeEditorialComments( state, match.params.project, diff --git a/packages/component-manuscript/src/components/ReviewerReportForm.js b/packages/component-manuscript/src/components/ReviewerReportForm.js index b8ca662a4109bdd916cf195c0690bda6bc6e1c17..2da95ea14fcfeb5d219f7f47a57829a27facb1c6 100644 --- a/packages/component-manuscript/src/components/ReviewerReportForm.js +++ b/packages/component-manuscript/src/components/ReviewerReportForm.js @@ -25,7 +25,6 @@ import AutosaveIndicator from 'pubsweet-component-wizard/src/components/Autosave import { uploadFile, deleteFile, - getFileError, getSignedUrl, getRequestStatus, } from 'pubsweet-components-faraday/src/redux/files' @@ -40,8 +39,6 @@ import { } from 'pubsweet-component-modal/src/components' import { - selectError, - selectFetching, createRecommendation, updateRecommendation, } from 'pubsweet-components-faraday/src/redux/recommendations' @@ -62,7 +59,6 @@ const ReviewerReportForm = ({ fileError, removeFile, changeField, - errorRequest, isSubmitting, handleSubmit, fileFetching, @@ -172,11 +168,6 @@ const ReviewerReportForm = ({ <ErrorText>{fileError}</ErrorText> </Row> )} - {errorRequest && ( - <Row> - <ErrorText>{errorRequest}</ErrorText> - </Row> - )} <Row> <ActionButton onClick={handleSubmit}> Submit report </ActionButton> <AutosaveIndicator @@ -189,8 +180,7 @@ const ReviewerReportForm = ({ const ModalWrapper = compose( connect(state => ({ - modalError: selectError(state), - fetching: selectFetching(state), + fetching: false, })), )(({ fetching, ...rest }) => ( <ConfirmationModal {...rest} isFetching={fetching} /> @@ -200,8 +190,6 @@ export default compose( withJournal, connect( state => ({ - fileError: getFileError(state), - errorRequest: selectError(state), fileFetching: getRequestStatus(state), formValues: getFormValues('reviewerReport')(state), isSubmitting: isSubmitting('reviewerReport')(state), diff --git a/packages/component-manuscript/src/components/ReviewsAndReports.js b/packages/component-manuscript/src/components/ReviewsAndReports.js index b498bf92a2eab8ad79f3071057a1adf539c29efd..ced7097c6c243e89b12228118c210f16fa3caedf 100644 --- a/packages/component-manuscript/src/components/ReviewsAndReports.js +++ b/packages/component-manuscript/src/components/ReviewsAndReports.js @@ -1,6 +1,6 @@ import React, { Fragment } from 'react' -import { head } from 'lodash' import { th } from '@pubsweet/ui' +import { head, get } from 'lodash' import { connect } from 'react-redux' import styled from 'styled-components' import { compose, withHandlers, lifecycle, withProps } from 'recompose' @@ -51,6 +51,9 @@ const ReviewsAndReports = ({ mappedReviewers, mappedRecommendations, canSeeReviewersReports, + // + reviewerRecommendation, + // review = {}, reviewers = [], recommendations = [], @@ -82,13 +85,13 @@ const ReviewsAndReports = ({ {isReviewer && ( <Root id="review-report"> <Expandable label="Your Report" startExpanded> - {report ? ( - <ReviewReportCard report={report} /> + {get(reviewerRecommendation, 'submittedOn') ? ( + <ReviewReportCard report={reviewerRecommendation} /> ) : ( <ReviewerReportForm modalKey={`review-${project.id}`} project={project} - review={review} + review={reviewerRecommendation} version={version} /> )} @@ -100,18 +103,18 @@ const ReviewsAndReports = ({ export default compose( connect( - (state, { project }) => ({ + (state, { project, version }) => ({ reviewers: selectReviewers(state), - recommendations: selectRecommendations(state), fetchingReviewers: selectFetchingReviewers(state), - isReviewer: currentUserIsReviewer(state, project.id), + isReviewer: currentUserIsReviewer(state, version.id), + recommendations: selectRecommendations(state, version.id), canSeeReviewersReports: canSeeReviewersReports(state, project.id), }), { getCollectionReviewers }, ), withHandlers({ - getReviewers: ({ project, getCollectionReviewers }) => () => { - getCollectionReviewers(project.id) + getReviewers: ({ project, version, getCollectionReviewers }) => () => { + getCollectionReviewers(project.id, version.id) }, mappedRecommendations: ({ recommendations, reviewers }) => () => recommendations.filter(r => r.submittedOn).map(r => ({ @@ -127,6 +130,7 @@ export default compose( withProps(({ recommendations = [] }) => ({ review: head(recommendations), report: head(recommendations.filter(r => r.submittedOn)), + reviewerRecommendation: head(recommendations), })), lifecycle({ componentDidMount() { diff --git a/packages/component-manuscript/src/components/SideBarActions.js b/packages/component-manuscript/src/components/SideBarActions.js index 01f60511fa7ccfb30a467976c768c876c80e4fe2..c85422badd0f82227aef0f2b234819eccdfbc0f2 100644 --- a/packages/component-manuscript/src/components/SideBarActions.js +++ b/packages/component-manuscript/src/components/SideBarActions.js @@ -2,13 +2,19 @@ import React from 'react' import { compose } from 'recompose' import { connect } from 'react-redux' import styled from 'styled-components' -import { th, Icon } from '@pubsweet/ui' +import { th } from '@pubsweet/ui-toolkit' +import { Icon, Button } from '@pubsweet/ui' +import { withRouter } from 'react-router-dom' import ZipFiles from 'pubsweet-components-faraday/src/components/Files/ZipFiles' import { Decision, Recommendation, } from 'pubsweet-components-faraday/src/components' + +import { createRevision } from 'pubsweet-component-wizard/src/redux/conversion' + import { + canMakeRevision, canMakeDecision, canMakeRecommendation, } from '../../../component-faraday-selectors' @@ -16,10 +22,15 @@ import { const SideBarActions = ({ project, version, + createRevision, + canMakeRevision, canMakeDecision, canMakeRecommendation, }) => ( <Root> + {canMakeRevision && ( + <DecisionButton onClick={createRevision}>Submit revision</DecisionButton> + )} {canMakeDecision && ( <Decision collectionId={project.id} @@ -48,10 +59,17 @@ const SideBarActions = ({ ) export default compose( - connect((state, { project }) => ({ - canMakeDecision: canMakeDecision(state, project), - canMakeRecommendation: canMakeRecommendation(state, project), - })), + withRouter, + connect( + (state, { project }) => ({ + canMakeDecision: canMakeDecision(state, project), + canMakeRevision: canMakeRevision(state, project), + canMakeRecommendation: canMakeRecommendation(state, project), + }), + (dispatch, { project, version, history }) => ({ + createRevision: () => dispatch(createRevision(project, version, history)), + }), + ), )(SideBarActions) // #region styled-components @@ -68,4 +86,17 @@ const ClickableIcon = styled.div` opacity: 0.7; } ` + +const DecisionButton = styled(Button)` + align-items: center; + background-color: ${th('colorPrimary')}; + color: ${th('colorTextReverse')}; + display: flex; + font-family: ${th('fontReading')}; + font-size: ${th('fontSizeBaseSmall')}; + height: calc(${th('subGridUnit')} * 5); + padding: calc(${th('subGridUnit')} / 2) ${th('subGridUnit')}; + text-align: center; + white-space: nowrap; +` // #endregion diff --git a/packages/component-manuscript/src/components/utils.js b/packages/component-manuscript/src/components/utils.js index d9785efb43eeaa1501c8f74b40d292c0d308a4c0..ed5e3885bbfad9c55e488500582ab05c8ed6ca66 100644 --- a/packages/component-manuscript/src/components/utils.js +++ b/packages/component-manuscript/src/components/utils.js @@ -1,6 +1,8 @@ import moment from 'moment' import { get, find, capitalize, omit, isEmpty, isEqual, debounce } from 'lodash' +import { actions } from 'pubsweet-client/src' +import { change as changeForm } from 'redux-form' import { autosaveRequest, autosaveSuccess, @@ -88,7 +90,7 @@ export const redirectToError = redirectFn => err => { } export const parseReviewResponseToForm = (review = {}) => { - if (isEmpty(review)) return null + if (isEmpty(review)) return {} const comments = review.comments || [] const publicComment = comments.find(c => c.public) const privateComment = comments.find(c => !c.public) @@ -102,7 +104,7 @@ export const parseReviewResponseToForm = (review = {}) => { } export const parseReviewRequest = (review = {}) => { - if (isEmpty(review)) return null + if (isEmpty(review)) return {} const comments = [ { public: true, @@ -139,7 +141,6 @@ const onChange = ( ) => { const newValues = parseReviewRequest(values) const prevValues = parseReviewRequest(previousValues) - if (!isEqual(newValues, prevValues)) { dispatch(autosaveRequest()) if (newValues.id) { @@ -148,7 +149,10 @@ const onChange = ( .catch(e => dispatch(autosaveFailure(e))) } else { createRecommendation(project.id, version.id, newValues) - .then(r => dispatch(autosaveSuccess(get(r, 'updatedOn')))) + .then(r => { + dispatch(changeForm('reviewerReport', 'id', r.id)) + return dispatch(autosaveSuccess(get(r, 'updatedOn'))) + }) .catch(e => dispatch(autosaveFailure(e))) } } @@ -178,7 +182,10 @@ export const onReviewSubmit = ( dispatch(autosaveRequest()) updateRecommendation(project.id, version.id, newValues) .then(r => dispatch(autosaveSuccess(get(r, 'updatedOn')))) - .then(hideModal) + .then(() => { + dispatch(actions.getFragments()) + hideModal() + }) }, onCancel: hideModal, }) diff --git a/packages/component-user-manager/config/authsome-helpers.js b/packages/component-user-manager/config/authsome-helpers.js new file mode 100644 index 0000000000000000000000000000000000000000..55148df349bc9807c0d6121fe30593dd021d237b --- /dev/null +++ b/packages/component-user-manager/config/authsome-helpers.js @@ -0,0 +1,131 @@ +const omit = require('lodash/omit') +const config = require('config') +const get = require('lodash/get') + +const statuses = config.get('statuses') + +const publicStatusesPermissions = ['author', 'reviewer'] + +const parseAuthorsData = (coll, matchingCollPerm) => { + if (['reviewer'].includes(matchingCollPerm.permission)) { + coll.authors = coll.authors.map(a => omit(a, ['email'])) + } +} + +const setPublicStatuses = (coll, matchingCollPerm) => { + const status = get(coll, 'status') || 'draft' + // coll.visibleStatus = statuses[status].public + if (publicStatusesPermissions.includes(matchingCollPerm.permission)) { + coll.visibleStatus = statuses[status].public + } +} + +const filterRefusedInvitations = (coll, user) => { + const matchingInv = coll.invitations.find(inv => inv.userId === user.id) + if (matchingInv === undefined) return null + if (matchingInv.hasAnswer === true && !matchingInv.isAccepted) return null + return coll +} + +const filterObjectData = ( + collectionsPermissions = [], + object = {}, + user = {}, +) => { + if (object.type === 'fragment') { + const matchingCollPerm = collectionsPermissions.find( + collPerm => object.id === collPerm.fragmentId, + ) + if (matchingCollPerm === undefined) return null + if (['reviewer'].includes(matchingCollPerm.permission)) { + object.files = omit(object.files, ['coverLetter']) + if (object.recommendations) + object.recommendations = object.recommendations.filter( + rec => rec.userId === user.id, + ) + } + parseAuthorsData(object, matchingCollPerm) + if (['reviewer', 'handlingEditor'].includes(matchingCollPerm.permission)) { + return filterRefusedInvitations(object, user) + } + return object + } + const matchingCollPerm = collectionsPermissions.find( + collPerm => object.id === collPerm.id, + ) + if (matchingCollPerm === undefined) return null + setPublicStatuses(object, matchingCollPerm) + + return object +} + +const getTeamsByPermissions = async ( + teamIds = [], + permissions = [], + TeamModel, +) => + (await Promise.all( + teamIds.map(async teamId => { + const team = await TeamModel.find(teamId) + if (!permissions.includes(team.teamType.permissions)) { + return null + } + return team + }), + )).filter(Boolean) + +const heIsInvitedToFragment = async ({ user, Team, collectionId }) => + (await getTeamsByPermissions(user.teams, ['handlingEditor'], Team)).some( + // user is a member of the team with access to the fragment's parent collection + t => t.members.includes(user.id) && t.object.id === collectionId, + ) + +const getUserPermissions = async ({ + user, + Team, + mapFn = t => ({ + objectId: t.object.id, + objectType: t.object.type, + role: t.teamType.permissions, + }), +}) => + (await Promise.all(user.teams.map(teamId => Team.find(teamId)))).map(mapFn) + +const isOwner = ({ user: { id }, object }) => { + if (object.owners.includes(id)) return true + return !!object.owners.find(own => own.id === id) +} + +const hasPermissionForObject = async ({ user, object, Team, roles = [] }) => { + const userPermissions = await getUserPermissions({ + user, + Team, + }) + + return !!userPermissions.find(p => { + const hasObject = + p.objectId === get(object, 'fragment.id') || + p.objectId === get(object, 'fragment.collectionId') + if (roles.length > 0) { + return hasObject && roles.includes(p.role) + } + return hasObject + }) +} + +const isHandlingEditor = ({ user, object }) => + get(object, 'collection.handlingEditor.id') === user.id + +module.exports = { + filterObjectData, + parseAuthorsData, + setPublicStatuses, + getTeamsByPermissions, + filterRefusedInvitations, + // + isOwner, + isHandlingEditor, + getUserPermissions, + heIsInvitedToFragment, + hasPermissionForObject, +} diff --git a/packages/component-user-manager/config/authsome-mode.js b/packages/component-user-manager/config/authsome-mode.js new file mode 100644 index 0000000000000000000000000000000000000000..20e0d691891e9804d44e7912a821e560b3ccdaf9 --- /dev/null +++ b/packages/component-user-manager/config/authsome-mode.js @@ -0,0 +1,253 @@ +const config = require('config') +const { get, pickBy, omit } = require('lodash') + +const statuses = config.get('statuses') +const helpers = require('./authsome-helpers') + +function unauthenticatedUser(operation, object) { + // Public/unauthenticated users can GET /collections, filtered by 'published' + if (operation === 'GET' && object && object.path === '/collections') { + return { + filter: collections => + collections.filter(collection => collection.published), + } + } + + // Public/unauthenticated users can GET /collections/:id/fragments, filtered by 'published' + if ( + operation === 'GET' && + object && + object.path === '/collections/:id/fragments' + ) { + return { + filter: fragments => fragments.filter(fragment => fragment.published), + } + } + + // and filtered individual collection's properties: id, title, source, content, owners + if (operation === 'GET' && object && object.type === 'collection') { + if (object.published) { + return { + filter: collection => + pickBy(collection, (_, key) => + ['id', 'title', 'owners'].includes(key), + ), + } + } + } + + if (operation === 'GET' && object && object.type === 'fragment') { + if (object.published) { + return { + filter: fragment => + pickBy(fragment, (_, key) => + ['id', 'title', 'source', 'presentation', 'owners'].includes(key), + ), + } + } + } + + return false +} + +const publicStatusesPermissions = ['author', 'reviewer'] +const createPaths = ['/collections', '/collections/:collectionId/fragments'] + +async function authenticatedUser(user, operation, object, context) { + if (operation === 'GET') { + if (get(object, 'path') === '/collections') { + return { + filter: async collections => { + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + return collections.filter(collection => { + if (collection.owners.includes(user.id)) { + return true + } + const collectionPermission = userPermissions.find( + p => p.objectId === collection.id, + ) + if (collectionPermission) { + return true + } + + const fragmentPermission = userPermissions.find(p => + collection.fragments.includes(p.objectId), + ) + if (fragmentPermission) { + return true + } + return false + }) + }, + } + } + + if (object === '/users') { + return true + } + + if (get(object, 'type') === 'collection') { + if (helpers.isOwner({ user, object })) { + return true + } + return { + filter: async collection => { + const status = get(collection, 'status') || 'draft' + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + if (collection.owners.map(o => o.id).includes(user.id)) { + return collection + } + + const collectionPermission = userPermissions.find( + p => p.objectId === collection.id, + ) + if ( + publicStatusesPermissions.includes( + get(collectionPermission, 'role'), + ) + ) { + collection.visibleStatus = statuses[status].public + } + return collection + }, + } + } + + if (get(object, 'type') === 'fragment') { + if (helpers.isOwner({ user, object })) { + return true + } + + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + + const permission = userPermissions.find( + p => p.objectId === object.id || p.objectId === object.collectionId, + ) + + if (!permission) return false + + return { + filter: fragment => { + // handle other roles + if (permission.role === 'reviewer') { + fragment.files = omit(fragment.files, ['coverLetter']) + fragment.authors = fragment.authors.map(a => omit(a, ['email'])) + } + return fragment + }, + } + } + + // allow HE to get reviewer invitations + if (get(object, 'fragment.type') === 'fragment') { + const collectionId = get(object, 'fragment.collectionId') + const collection = await context.models.Collection.find(collectionId) + + if (get(collection, 'handlingEditor.id') === user.id) { + return true + } + } + + if (get(object, 'type') === 'user') { + return true + } + } + + if (operation === 'POST') { + // allow everytone to create manuscripts and versions + if (createPaths.includes(object.path)) { + return true + } + + // allow HE to invite + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/invitations' + ) { + return helpers.isHandlingEditor({ user, object }) + } + + // allow HE or assigned reviewers to recommend + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/recommendations' + ) { + return helpers.hasPermissionForObject({ + user, + object, + Team: context.models.Team, + roles: ['reviewer', 'handlingEditor'], + }) + } + } + + if (operation === 'PATCH') { + if (get(object, 'type') === 'collection') { + return helpers.isOwner({ user, object }) + } + + if (get(object, 'type') === 'fragment') { + return helpers.isOwner({ user, object }) + } + + // allow reviewer to patch his recommendation + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/recommendations/:recommendationId' + ) { + return helpers.hasPermissionForObject({ + user, + object, + Team: context.models.Team, + roles: ['reviewer'], + }) + } + + if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { + return true + } + } + + if (operation === 'DELETE') { + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/invitations/:invitationId' + ) { + return helpers.isHandlingEditor({ user, object }) + } + } + + // If no individual permissions exist (above), fallback to unauthenticated + // user's permission + return unauthenticatedUser(operation, object) +} + +const authsomeMode = async (userId, operation, object, context) => { + if (!userId) { + return unauthenticatedUser(operation, object) + } + + // It's up to us to retrieve the relevant models for our + // authorization/authsome mode, e.g. + const user = await context.models.User.find(userId) + + // Admins and editor in chiefs can do anything + if (user && (user.admin || user.editorInChief)) return true + + if (user) { + return authenticatedUser(user, operation, object, context) + } + + return false +} + +module.exports = authsomeMode diff --git a/packages/component-user-manager/config/default.js b/packages/component-user-manager/config/default.js index 350b9de7761a91153fff07c37b29603005d36c33..5d8cb0c55cde6a7eeb0497b00fd12c5ed5751b1a 100644 --- a/packages/component-user-manager/config/default.js +++ b/packages/component-user-manager/config/default.js @@ -1,4 +1,17 @@ +const path = require('path') + module.exports = { + authsome: { + mode: path.resolve(__dirname, 'authsome-mode.js'), + teams: { + handlingEditor: { + name: 'Handling Editors', + }, + reviewer: { + name: 'Reviewer', + }, + }, + }, mailer: { from: 'test@example.com', }, @@ -16,4 +29,62 @@ module.exports = { handlingEditor: ['reviewer'], }, }, + statuses: { + draft: { + public: 'Draft', + private: 'Draft', + }, + submitted: { + public: 'Submitted', + private: 'Submitted', + }, + heInvited: { + public: 'Submitted', + private: 'Handling Editor Invited', + }, + heAssigned: { + public: 'Handling Editor Assigned', + private: 'Handling Editor Assigned', + }, + reviewersInvited: { + public: 'Reviewers Invited', + private: 'Reviewers Invited', + }, + underReview: { + 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', + }, + published: { + public: 'Published', + private: 'Published', + }, + }, + 'manuscript-types': { + research: 'Research', + review: 'Review', + 'clinical-study': 'Clinical Study', + 'case-report': 'Case Report', + 'letter-to-editor': 'Letter to the Editor', + editorial: 'Editorial', + corrigendum: 'Corrigendum', + erratum: 'Erratum', + 'expression-of-concern': 'Expression of Concern', + retraction: 'Retraction', + }, } diff --git a/packages/component-user-manager/config/test.js b/packages/component-user-manager/config/test.js index a1e52fc0b730d1b5e7836ac08eb6b0188b3c13ae..63452331a3b061996e51151df75e7d647efa5ed7 100644 --- a/packages/component-user-manager/config/test.js +++ b/packages/component-user-manager/config/test.js @@ -17,4 +17,62 @@ module.exports = { author: ['author'], }, }, + statuses: { + draft: { + public: 'Draft', + private: 'Draft', + }, + submitted: { + public: 'Submitted', + private: 'Submitted', + }, + heInvited: { + public: 'Submitted', + private: 'Handling Editor Invited', + }, + heAssigned: { + public: 'Handling Editor Assigned', + private: 'Handling Editor Assigned', + }, + reviewersInvited: { + public: 'Reviewers Invited', + private: 'Reviewers Invited', + }, + underReview: { + 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', + }, + published: { + public: 'Published', + private: 'Published', + }, + }, + 'manuscript-types': { + research: 'Research', + review: 'Review', + 'clinical-study': 'Clinical Study', + 'case-report': 'Case Report', + 'letter-to-editor': 'Letter to the Editor', + editorial: 'Editorial', + corrigendum: 'Corrigendum', + erratum: 'Erratum', + 'expression-of-concern': 'Expression of Concern', + retraction: 'Retraction', + }, } diff --git a/packages/component-user-manager/index.js b/packages/component-user-manager/index.js index 4ba897be673285e0582abfcc2c2fabd03cc58373..86e7a8ab8fe92e4398708bcf9f077fe5cd9e804e 100644 --- a/packages/component-user-manager/index.js +++ b/packages/component-user-manager/index.js @@ -1,6 +1,6 @@ module.exports = { backend: () => app => { require('./src/Users')(app) - require('./src/CollectionsUsers')(app) + require('./src/FragmentsUsers')(app) }, } diff --git a/packages/component-user-manager/src/CollectionsUsers.js b/packages/component-user-manager/src/FragmentsUsers.js similarity index 51% rename from packages/component-user-manager/src/CollectionsUsers.js rename to packages/component-user-manager/src/FragmentsUsers.js index 5ee6f750ff770c0b83fbaad57537206c54aa82f1..443397a6f81cb1d10c9696fb5a9d937becc2b1cb 100644 --- a/packages/component-user-manager/src/CollectionsUsers.js +++ b/packages/component-user-manager/src/FragmentsUsers.js @@ -1,16 +1,17 @@ const bodyParser = require('body-parser') -const CollectionsUsers = app => { +const FragmentsUsers = app => { app.use(bodyParser.json()) - const basePath = '/api/collections/:collectionId/users' - const routePath = './routes/collectionsUsers' + const basePath = '/api/collections/:collectionId/fragments/:fragmentId/users' + const routePath = './routes/fragmentsUsers' const authBearer = app.locals.passport.authenticate('bearer', { session: false, }) /** - * @api {post} /api/collections/:collectionId/users Add a user to a collection - * @apiGroup CollectionsUsers + * @api {post} /api/collections/:collectionId/fragments/:fragmentId/users Add a user to a fragment + * @apiGroup FragmentsUsers * @apiParam {collectionId} collectionId Collection id + * @apiParam {fragmentId} fragmentId Fragment id * @apiParamExample {json} Body * { * "email": "email@example.com", @@ -22,19 +23,12 @@ const CollectionsUsers = app => { * HTTP/1.1 200 OK * { * "id": "a6184463-b17a-42f8-b02b-ae1d755cdc6b", - * "type": "user", - * "admin": false, * "email": "email@example.com", - * "teams": [ - * "c576695a-7cda-4e27-8e9c-31f3a0e9d592" - * ], - * "username": "email@example.com", - * "fragments": [], - * "collections": [], - * "isConfirmed": false, - * "editorInChief": false, - * "handlingEditor": false, - * "passwordResetToken": "04590a2b7f6c1f37cb84881d529e516fa6fc309c205a07f1341b2bfaa6f2b46c" + * "firstName": "John", + * "lastName": "Smith", + * "affiliation": "MIT", + * "isSubmitting": true, + * "isCorresponding": false * } * @apiErrorExample {json} Invite user errors * HTTP/1.1 400 Bad Request @@ -46,9 +40,10 @@ const CollectionsUsers = app => { require(`${routePath}/post`)(app.locals.models), ) /** - * @api {delete} /api/collections/:collectionId/user/:userId Delete user from collection - * @apiGroup CollectionsUsers + * @api {delete} /api/collections/:collectionId/fragments/:fragmentId/user/:userId Delete a user from a fragment + * @apiGroup FragmentsUsers * @apiParam {collectionId} collectionId Collection id + * @apiParam {fragmentId} fragmentId Fragment id * @apiParam {userId} userId User id * @apiSuccessExample {json} Success * HTTP/1.1 200 {} @@ -62,9 +57,10 @@ const CollectionsUsers = app => { require(`${routePath}/delete`)(app.locals.models), ) /** - * @api {get} /api/collections/:collectionId/users List collections users - * @apiGroup CollectionsUsers + * @api {get} /api/collections/:collectionId/fragments/:fragmentId/users List fragment users + * @apiGroup FragmentsUsers * @apiParam {collectionId} collectionId Collection id + * @apiParam {fragmentId} fragmentId Fragment id * @apiSuccessExample {json} Success * HTTP/1.1 200 OK * [{ @@ -107,62 +103,6 @@ const CollectionsUsers = app => { * HTTP/1.1 404 Not Found */ app.get(basePath, authBearer, require(`${routePath}/get`)(app.locals.models)) - /** - * @api {patch} /api/collections/:collectionId/users/:userId Update a user on a collection - * @apiGroup CollectionsUsers - * @apiParam {collectionId} collectionId Collection id - * @apiParam {userId} userId User id - * @apiParamExample {json} Body - * { - * "isSubmitting": false, - * "isCorresponding": true, - * "firstName": "John", - * "lastName": "Smith", - * "affiliation": "UCLA" - * } - * @apiSuccessExample {json} Success - * HTTP/1.1 200 OK - * { - * "id": "7e8a77f9-8e5c-4fa3-b717-8df9932df128", - * "type": "collection", - * "owners": [ - * { - * "id": "69ac1ee9-08a8-4ee6-a57c-c6c8be8d3c4f", - * "username": "admin" - * } - * ], - * "authors": [ - * { - * "userId": "a6184463-b17a-42f8-b02b-ae1d755cdc6b", - * "isSubmitting": false, - * "isCorresponding": true - * } - * ], - * "created": 1522829424474, - * "customId": "9424466", - * "fragments": [ - * "c35d0bd8-be03-4c16-b869-bd69796c5a21" - * ], - * "invitations": [ - * { - * "id": "9043a836-0d49-4b8d-be0b-df39071b5c57", - * "hasAnswer": false, - * "timestamp": 1522831123430, - * "isAccepted": false - * }, - * ] - * } - * @apiErrorExample {json} Update invitations 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}/:userId`, - authBearer, - require(`${routePath}/patch`)(app.locals.models), - ) } -module.exports = CollectionsUsers +module.exports = FragmentsUsers diff --git a/packages/component-user-manager/src/helpers/Collection.js b/packages/component-user-manager/src/helpers/Collection.js deleted file mode 100644 index c2079945210b19e05c5db988777b7e29d22c70b5..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/helpers/Collection.js +++ /dev/null @@ -1,43 +0,0 @@ -const mailService = require('pubsweet-component-mail-service') -const logger = require('@pubsweet/logger') - -module.exports = { - addAuthor: async ( - collection, - user, - res, - url, - isSubmitting, - isCorresponding, - ) => { - collection.authors = collection.authors || [] - const author = { - userId: user.id, - firstName: user.firstName || '', - lastName: user.lastName || '', - email: user.email, - title: user.title || '', - affiliation: user.affiliation || '', - isSubmitting, - isCorresponding, - } - collection.authors.push(author) - await collection.save() - if (collection.owners.includes(user.id)) { - return res.status(200).json(user) - } - try { - mailService.sendSimpleEmail({ - toEmail: user.email, - user, - emailType: 'add-author', - dashboardUrl: url, - }) - - return res.status(200).json(user) - } catch (e) { - logger.error(e) - return res.status(500).json({ error: 'Email could not be sent.' }) - } - }, -} diff --git a/packages/component-user-manager/src/helpers/Team.js b/packages/component-user-manager/src/helpers/Team.js deleted file mode 100644 index 3b700c4436f1306a7b41b07ab4b9ded4aa566944..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/helpers/Team.js +++ /dev/null @@ -1,119 +0,0 @@ -const logger = require('@pubsweet/logger') -// const get = require('lodash/get') - -const createNewTeam = async (collectionId, role, userId, TeamModel) => { - let permissions, group, name - switch (role) { - case 'handlingEditor': - permissions = 'handlingEditor' - group = 'handlingEditor' - name = 'Handling Editor' - break - case 'reviewer': - permissions = 'reviewer' - group = 'reviewer' - name = 'Reviewer' - break - case 'author': - permissions = 'author' - group = 'author' - name = 'author' - break - default: - break - } - - const teamBody = { - teamType: { - name: role, - permissions, - }, - group, - name, - object: { - type: 'collection', - id: collectionId, - }, - members: [userId], - } - let team = new TeamModel(teamBody) - team = await team.save() - return team -} - -const setupManuscriptTeam = async (models, user, collectionId, role) => { - const teams = await models.Team.all() - user.teams = user.teams || [] - const filteredTeams = teams.filter( - team => - team.group === role && - team.object.type === 'collection' && - team.object.id === collectionId, - ) - - if (filteredTeams.length > 0) { - let team = filteredTeams[0] - team.members.push(user.id) - - try { - team = await team.save() - user.teams.push(team.id) - await user.save() - return team - } catch (e) { - logger.error(e) - } - } else { - const team = await createNewTeam(collectionId, role, user.id, models.Team) - user.teams.push(team.id) - await user.save() - return team - } -} - -const removeTeamMember = async (teamId, userId, TeamModel) => { - const team = await TeamModel.find(teamId) - const members = team.members.filter(member => member !== userId) - team.members = members - - await team.save() -} - -const getTeamMembersByCollection = async (collectionId, role, TeamModel) => { - const teams = await TeamModel.all() - // const members = get( - // teams.find( - // team => - // team.group === role && - // team.object.type === 'collection' && - // team.object.id === collectionId, - // ), - // 'members', - // ) - const team = teams.find( - team => - team.group === role && - team.object.type === 'collection' && - team.object.id === collectionId, - ) - - return team.members -} - -const getTeamByGroupAndCollection = async (collectionId, role, TeamModel) => { - const teams = await TeamModel.all() - return teams.find( - team => - team.group === role && - team.object.type === 'collection' && - team.object.id === collectionId, - ) -} - -module.exports = { - createNewTeam, - setupManuscriptTeam, - removeTeamMember, - getTeamMembersByCollection, - getTeamByGroupAndCollection, -} diff --git a/packages/component-user-manager/src/helpers/User.js b/packages/component-user-manager/src/helpers/User.js deleted file mode 100644 index 41077ba17fb36fd8cf22f62998901bccd630ec58..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/helpers/User.js +++ /dev/null @@ -1,40 +0,0 @@ -const helpers = require('./helpers') -const mailService = require('pubsweet-component-mail-service') -const logger = require('@pubsweet/logger') - -module.exports = { - setupNewUser: async ( - body, - url, - res, - email, - role, - UserModel, - invitationType, - ) => { - const { firstName, lastName, affiliation, title } = body - const newUser = await helpers.createNewUser( - email, - firstName, - lastName, - affiliation, - title, - UserModel, - role, - ) - - try { - mailService.sendSimpleEmail({ - toEmail: newUser.email, - user: newUser, - emailType: invitationType, - dashboardUrl: url, - }) - - return newUser - } catch (e) { - logger.error(e.message) - return { status: 500, error: 'Email could not be sent.' } - } - }, -} diff --git a/packages/component-user-manager/src/helpers/helpers.js b/packages/component-user-manager/src/helpers/helpers.js deleted file mode 100644 index 0628217ee67a8b0d6697869a7cf7beb491dfc8af..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/helpers/helpers.js +++ /dev/null @@ -1,119 +0,0 @@ -const logger = require('@pubsweet/logger') -const uuid = require('uuid') -const crypto = require('crypto') - -const checkForUndefinedParams = (...params) => { - if (params.includes(undefined)) { - return false - } - - return true -} - -const validateEmailAndToken = async (email, token, userModel) => { - try { - const user = await userModel.findByEmail(email) - if (user) { - if (token !== user.passwordResetToken) { - logger.error( - `invite pw reset tokens do not match: REQ ${token} vs. DB ${ - user.passwordResetToken - }`, - ) - return { - success: false, - status: 400, - message: 'invalid request', - } - } - return { success: true, user } - } - } catch (e) { - if (e.name === 'NotFoundError') { - logger.error('invite pw reset on non-existing user') - return { - success: false, - status: 404, - message: 'user not found', - } - } else if (e.name === 'ValidationError') { - logger.error('invite pw reset validation error') - return { - success: false, - status: 400, - message: e.details[0].message, - } - } - logger.error('internal server error') - return { - success: false, - status: 500, - message: e.details[0].message, - } - } - return { - success: false, - status: 500, - message: 'something went wrong', - } -} - -const handleNotFoundError = async (error, item) => { - const response = { - success: false, - status: 500, - message: 'Something went wrong', - } - if (error.name === 'NotFoundError') { - logger.error(`invalid ${item} id`) - response.status = 404 - response.message = `${item} not found` - return response - } - - logger.error(error) - return response -} - -const createNewUser = async ( - email, - firstName, - lastName, - affiliation, - title, - UserModel, - role, -) => { - const username = email - const password = uuid.v4() - const userBody = { - username, - email, - password, - passwordResetToken: crypto.randomBytes(32).toString('hex'), - isConfirmed: false, - firstName, - lastName, - affiliation, - title, - editorInChief: role === 'editorInChief', - admin: role === 'admin', - handlingEditor: role === 'handlingEditor', - } - - let newUser = new UserModel(userBody) - - try { - newUser = await newUser.save() - return newUser - } catch (e) { - logger.error(e) - } -} - -module.exports = { - checkForUndefinedParams, - validateEmailAndToken, - handleNotFoundError, - createNewUser, -} diff --git a/packages/component-user-manager/src/routes/collectionsUsers/delete.js b/packages/component-user-manager/src/routes/collectionsUsers/delete.js deleted file mode 100644 index 2fae5bea551fdb6795c0fa0224a0d9f935f59e68..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/routes/collectionsUsers/delete.js +++ /dev/null @@ -1,32 +0,0 @@ -const helpers = require('../../helpers/helpers') -const teamHelper = require('../../helpers/Team') - -module.exports = models => async (req, res) => { - // TO DO: handle route access with authsome - const { collectionId, userId } = req.params - try { - const collection = await models.Collection.find(collectionId) - const user = await models.User.find(userId) - - const team = await teamHelper.getTeamByGroupAndCollection( - collectionId, - 'author', - models.Team, - ) - - collection.authors = collection.authors.filter( - author => author.userId !== userId, - ) - await collection.save() - await teamHelper.removeTeamMember(team.id, userId, models.Team) - user.teams = user.teams.filter(userTeamId => team.id !== userTeamId) - delete user.passwordResetToken - await user.save() - return res.status(200).json({}) - } catch (e) { - const notFoundError = await helpers.handleNotFoundError(e, 'item') - return res.status(notFoundError.status).json({ - error: notFoundError.message, - }) - } -} diff --git a/packages/component-user-manager/src/routes/collectionsUsers/get.js b/packages/component-user-manager/src/routes/collectionsUsers/get.js deleted file mode 100644 index 1d6b395dd79dfeaa3e5b38f8c00a4b29d6c5716b..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/routes/collectionsUsers/get.js +++ /dev/null @@ -1,44 +0,0 @@ -const helpers = require('../../helpers/helpers') -const teamHelper = require('../../helpers/Team') - -module.exports = models => async (req, res) => { - // TO DO: add authsome - const { collectionId } = req.params - try { - const collection = await models.Collection.find(collectionId) - if (collection.authors === undefined) { - return res.status(200).json([]) - } - const members = await teamHelper.getTeamMembersByCollection( - collectionId, - 'author', - models.Team, - ) - - if (members === undefined) { - res.status(400).json({ - error: 'The requested collection does not have an author Team', - }) - return - } - const membersData = members.map(async member => { - const user = await models.User.find(member) - const matchingAuthor = collection.authors.find( - author => author.userId === user.id, - ) - return { - ...user, - isSubmitting: matchingAuthor.isSubmitting, - isCorresponding: matchingAuthor.isCorresponding, - } - }) - - const resBody = await Promise.all(membersData) - res.status(200).json(resBody) - } catch (e) { - const notFoundError = await helpers.handleNotFoundError(e, 'collection') - return res.status(notFoundError.status).json({ - error: notFoundError.message, - }) - } -} diff --git a/packages/component-user-manager/src/routes/collectionsUsers/patch.js b/packages/component-user-manager/src/routes/collectionsUsers/patch.js deleted file mode 100644 index c973e20d44faaae59eab82afd22a5399e2321924..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/routes/collectionsUsers/patch.js +++ /dev/null @@ -1,51 +0,0 @@ -const logger = require('@pubsweet/logger') -const helpers = require('../../helpers/helpers') - -module.exports = models => async (req, res) => { - // TO DO: add authsome - const { collectionId, userId } = req.params - const { - isSubmitting, - isCorresponding, - firstName, - lastName, - affiliation, - } = req.body - - if (!helpers.checkForUndefinedParams(isSubmitting, isCorresponding)) { - res.status(400).json({ error: 'Missing parameters' }) - logger.error('some parameters are missing') - return - } - - try { - let collection = await models.Collection.find(collectionId) - if (collection.authors === undefined) { - return res.status(400).json({ - error: 'Collection does not have any authors', - }) - } - const user = await models.User.find(userId) - const matchingAuthor = collection.authors.find( - author => author.userId === user.id, - ) - if (matchingAuthor === undefined) { - return res.status(400).json({ - error: 'Collection and user do not match', - }) - } - user.firstName = firstName - user.lastName = lastName - user.affiliation = affiliation - await user.save() - matchingAuthor.isSubmitting = isSubmitting - matchingAuthor.isCorresponding = isCorresponding - collection = await collection.save() - res.status(200).json(collection) - } catch (e) { - const notFoundError = await helpers.handleNotFoundError(e, 'item') - return res.status(notFoundError.status).json({ - error: notFoundError.message, - }) - } -} diff --git a/packages/component-user-manager/src/routes/collectionsUsers/post.js b/packages/component-user-manager/src/routes/collectionsUsers/post.js deleted file mode 100644 index 73957c570bea10d9dc4e1e691690e6e8170916ca..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/routes/collectionsUsers/post.js +++ /dev/null @@ -1,91 +0,0 @@ -const get = require('lodash/get') -const helpers = require('../../helpers/helpers') -const collectionHelper = require('../../helpers/Collection') -const teamHelper = require('../../helpers/Team') -const userHelper = require('../../helpers/User') - -module.exports = models => async (req, res) => { - const { email, role, isSubmitting, isCorresponding } = req.body - - if ( - !helpers.checkForUndefinedParams(email, role, isSubmitting, isCorresponding) - ) - return res.status(400).json({ error: 'Missing parameters.' }) - - const collectionId = get(req, 'params.collectionId') - let collection - try { - collection = await models.Collection.find(collectionId) - } catch (e) { - const notFoundError = await helpers.handleNotFoundError(e, 'collection') - return res.status(notFoundError.status).json({ - error: notFoundError.message, - }) - } - const url = `${req.protocol}://${req.get('host')}` - - try { - let user = await models.User.findByEmail(email) - - if (role === 'author') { - await teamHelper.setupManuscriptTeam(models, user, collectionId, role) - // get updated user from DB - user = await models.User.find(user.id) - if (collection.authors !== undefined) { - const match = collection.authors.find( - author => author.userId === user.id, - ) - if (match) { - return res.status(400).json({ - error: `User ${user.email} is already an author`, - }) - } - } - return await collectionHelper.addAuthor( - collection, - user, - res, - url, - isSubmitting, - isCorresponding, - ) - } - return res.status(400).json({ - error: `${role} is not defined`, - }) - } catch (e) { - if (role !== 'author') { - return res.status(400).json({ - error: `${role} is not defined`, - }) - } - if (e.name === 'NotFoundError') { - const newUser = await userHelper.setupNewUser( - req.body, - url, - res, - email, - role, - models.User, - 'invite-author', - ) - if (newUser.error !== undefined) { - return res.status(newUser.status).json({ - error: newUser.message, - }) - } - await teamHelper.setupManuscriptTeam(models, newUser, collectionId, role) - return collectionHelper.addAuthor( - collection, - newUser, - res, - url, - isSubmitting, - isCorresponding, - ) - } - return res.status(e.status).json({ - error: 'Something went wrong', - }) - } -} diff --git a/packages/component-user-manager/src/routes/fragmentsUsers/delete.js b/packages/component-user-manager/src/routes/fragmentsUsers/delete.js new file mode 100644 index 0000000000000000000000000000000000000000..86073f5d5d467b23878dc792c088a86877c28e88 --- /dev/null +++ b/packages/component-user-manager/src/routes/fragmentsUsers/delete.js @@ -0,0 +1,31 @@ +const { Team, services } = require('pubsweet-component-helper-service') + +module.exports = models => async (req, res) => { + // TO DO: handle route access with authsome + const { collectionId, userId, fragmentId } = req.params + try { + const collection = await models.Collection.find(collectionId) + const user = await models.User.find(userId) + if (!collection.fragments.includes(fragmentId)) + return res.status(400).json({ + error: `Fragment ${fragmentId} does not match collection ${collectionId}`, + }) + const teamHelper = new Team({ TeamModel: models.Team, fragmentId }) + + const team = await teamHelper.getTeam({ + role: 'author', + objectType: 'fragment', + }) + + await teamHelper.removeTeamMember({ teamId: team.id, userId }) + user.teams = user.teams.filter(userTeamId => team.id !== userTeamId) + delete user.passwordResetToken + await user.save() + return res.status(200).json({}) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'item') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } +} diff --git a/packages/component-user-manager/src/routes/fragmentsUsers/get.js b/packages/component-user-manager/src/routes/fragmentsUsers/get.js new file mode 100644 index 0000000000000000000000000000000000000000..76ef6041615e728b141e04f537ae6b67e643dd86 --- /dev/null +++ b/packages/component-user-manager/src/routes/fragmentsUsers/get.js @@ -0,0 +1,21 @@ +const { services } = require('pubsweet-component-helper-service') + +module.exports = models => async (req, res) => { + // TO DO: add authsome + const { collectionId, fragmentId } = req.params + try { + const collection = await models.Collection.find(collectionId) + if (!collection.fragments.includes(fragmentId)) + return res.status(400).json({ + error: `Fragment ${fragmentId} does not match collection ${collectionId}`, + }) + + const { authors = [] } = await models.Fragment.find(fragmentId) + return res.status(200).json(authors) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'item') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } +} diff --git a/packages/component-user-manager/src/routes/fragmentsUsers/post.js b/packages/component-user-manager/src/routes/fragmentsUsers/post.js new file mode 100644 index 0000000000000000000000000000000000000000..8d9b8a4ce8475e3658f5632cc4e9267e02a3198f --- /dev/null +++ b/packages/component-user-manager/src/routes/fragmentsUsers/post.js @@ -0,0 +1,137 @@ +const { pick } = require('lodash') +const mailService = require('pubsweet-component-mail-service') + +const { + User, + Team, + services, + Fragment, +} = require('pubsweet-component-helper-service') + +const authorKeys = [ + 'id', + 'email', + 'title', + 'lastName', + 'firstName', + 'affiliation', +] + +// TODO: add authsome +module.exports = models => async (req, res) => { + const { email, role, isSubmitting, isCorresponding } = req.body + + if ( + !services.checkForUndefinedParams( + email, + role, + isSubmitting, + isCorresponding, + ) + ) + return res.status(400).json({ error: 'Missing parameters.' }) + + 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: `Fragment ${fragmentId} does not match collection ${collectionId}`, + }) + fragment = await models.Fragment.find(fragmentId) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'item') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } + 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) + + if (role !== 'author') { + return res.status(400).json({ + error: `${role} is not defined`, + }) + } + + await teamHelper.setupTeam({ user, role, objectType: 'fragment' }) + user = await UserModel.find(user.id) + + fragment.authors = fragment.authors || [] + const match = fragment.authors.find(author => author.id === user.id) + + if (match) { + return res.status(400).json({ + error: `User ${user.email} is already an author`, + }) + } + + await fragmentHelper.addAuthor({ + user, + isSubmitting, + isCorresponding, + }) + + return res.status(200).json({ + ...pick(user, authorKeys), + isSubmitting, + isCorresponding, + }) + } catch (e) { + if (role !== 'author') + return res.status(400).json({ + error: `${role} is not defined`, + }) + + if (e.name === 'NotFoundError') { + const userHelper = new User({ UserModel }) + const newUser = await userHelper.setupNewUser({ + url: baseUrl, + role, + invitationType: 'invite-author', + body: req.body, + }) + + if (newUser.error !== undefined) + return res.status(newUser.status).json({ + error: newUser.message, + }) + + await teamHelper.setupTeam({ + user: newUser, + role, + objectType: 'fragment', + }) + + await fragmentHelper.addAuthor({ + user: newUser, + isSubmitting, + isCorresponding, + }) + + if (!collection.owners.includes(newUser.id)) { + mailService.sendSimpleEmail({ + toEmail: newUser.email, + user: newUser, + emailType: 'add-author', + dashboardUrl: baseUrl, + }) + } + + return res.status(200).json({ + ...pick(newUser, authorKeys), + isSubmitting, + isCorresponding, + }) + } + return res.status(e.status).json({ + error: `Something went wrong: ${e.name}`, + }) + } +} diff --git a/packages/component-user-manager/src/routes/users/resetPassword.js b/packages/component-user-manager/src/routes/users/resetPassword.js index 6b6dc173e2db18ac9bd666505cda16c7042cb9fd..8a38aea627e1f0155ca26de947d80d2d6b7e375b 100644 --- a/packages/component-user-manager/src/routes/users/resetPassword.js +++ b/packages/component-user-manager/src/routes/users/resetPassword.js @@ -1,8 +1,8 @@ -const helpers = require('../../helpers/helpers') +const { services } = require('pubsweet-component-helper-service') module.exports = models => async (req, res) => { const { email, password, token } = req.body - if (!helpers.checkForUndefinedParams(email, password, token)) + if (!services.checkForUndefinedParams(email, password, token)) return res.status(400).json({ error: 'missing required params' }) if (password.length < 7) @@ -10,11 +10,11 @@ module.exports = models => async (req, res) => { .status(400) .json({ error: 'password needs to be at least 7 characters long' }) - const validateResponse = await helpers.validateEmailAndToken( + const validateResponse = await services.validateEmailAndToken({ email, token, - models.User, - ) + userModel: models.User, + }) if (validateResponse.success === false) return res diff --git a/packages/component-user-manager/src/tests/collectionsUsers/delete.test.js b/packages/component-user-manager/src/tests/collectionsUsers/delete.test.js deleted file mode 100644 index 7242ac5c76c8b75ce0e265ca9ef98981490fe1a0..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/collectionsUsers/delete.test.js +++ /dev/null @@ -1,52 +0,0 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' -process.env.SUPPRESS_NO_CONFIG_WARNING = true - -const httpMocks = require('node-mocks-http') -const fixtures = require('./../fixtures/fixtures') -const Model = require('./../helpers/Model') - -const models = Model.build() - -const { author, submittingAuthor } = fixtures.users -const { standardCollection } = fixtures.collections -const { authorTeam } = fixtures.teams -const deletePath = '../../routes/collectionsUsers/delete' - -describe('Delete collections users route handler', () => { - it('should return success when an author is deleted', async () => { - const req = httpMocks.createRequest({}) - req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id - req.params.userId = author.id - const res = httpMocks.createResponse() - await require(deletePath)(models)(req, res) - - expect(res.statusCode).toBe(200) - expect(authorTeam.members).not.toContain(author.id) - expect(author.teams).not.toContain(authorTeam.id) - }) - it('should return an error when the collection does not exist', async () => { - const req = httpMocks.createRequest({}) - req.user = submittingAuthor.id - req.params.collectionId = 'invalid-id' - req.params.userId = author.id - const res = httpMocks.createResponse() - await require(deletePath)(models)(req, res) - - expect(res.statusCode).toBe(404) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('item not found') - }) - it('should return an error when the user does not exist', async () => { - const req = httpMocks.createRequest({}) - req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id - req.params.userId = 'invalid-id' - const res = httpMocks.createResponse() - await require(deletePath)(models)(req, res) - - expect(res.statusCode).toBe(404) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('item not found') - }) -}) diff --git a/packages/component-user-manager/src/tests/collectionsUsers/get.test.js b/packages/component-user-manager/src/tests/collectionsUsers/get.test.js deleted file mode 100644 index b5975b4ed8d941cd760c22b4df9ec729c41e8ab7..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/collectionsUsers/get.test.js +++ /dev/null @@ -1,38 +0,0 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' -process.env.SUPPRESS_NO_CONFIG_WARNING = true - -const httpMocks = require('node-mocks-http') -const fixtures = require('./../fixtures/fixtures') -const Model = require('./../helpers/Model') - -const { standardCollection } = fixtures.collections -const { submittingAuthor } = fixtures.users - -const getPath = '../../routes/collectionsUsers/get' -describe('Get collections users route handler', () => { - it('should return success when the request data is correct', async () => { - const req = httpMocks.createRequest() - req.params.collectionId = standardCollection.id - req.user = submittingAuthor.id - const res = httpMocks.createResponse() - const models = Model.build() - await require(getPath)(models)(req, res) - - expect(res.statusCode).toBe(200) - const data = JSON.parse(res._getData()) - expect(data).toHaveLength(1) - expect(data[0].isSubmitting).toBeDefined() - expect(data[0].type).toBe('user') - }) - it('should return an error when the collection does not exist', async () => { - const req = httpMocks.createRequest() - req.params.collectionId = 'invalid-id' - req.user = submittingAuthor.id - const res = httpMocks.createResponse() - const models = Model.build() - await require(getPath)(models)(req, res) - expect(res.statusCode).toBe(404) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('collection not found') - }) -}) diff --git a/packages/component-user-manager/src/tests/collectionsUsers/patch.test.js b/packages/component-user-manager/src/tests/collectionsUsers/patch.test.js deleted file mode 100644 index 03c35f392d843bcf12c2b4859c656017a311c0a7..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/collectionsUsers/patch.test.js +++ /dev/null @@ -1,119 +0,0 @@ -process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' -process.env.SUPPRESS_NO_CONFIG_WARNING = true - -const httpMocks = require('node-mocks-http') -const fixtures = require('./../fixtures/fixtures') -const Model = require('./../helpers/Model') -const Chance = require('chance') - -const chance = new Chance() - -const models = Model.build() -jest.mock('pubsweet-component-mail-service', () => ({ - sendSimpleEmail: jest.fn(), - sendNotificationEmail: jest.fn(), -})) - -const { author, submittingAuthor } = fixtures.users -const { standardCollection, authorsCollection } = fixtures.collections -const body = { - isSubmitting: false, - isCorresponding: true, - firstName: chance.first(), - lastName: chance.last(), - affiliation: chance.company(), -} -const patchPath = '../../routes/collectionsUsers/patch' -describe('Patch collections users route handler', () => { - it('should return success when the request data is correct', async () => { - const req = httpMocks.createRequest({ - body, - }) - req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id - req.params.userId = submittingAuthor.id - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - const data = JSON.parse(res._getData()) - expect(res.statusCode).toBe(200) - const matchingAuthor = data.authors.find( - author => author.userId === submittingAuthor.id, - ) - expect(matchingAuthor.isSubmitting).toBe(body.isSubmitting) - expect(matchingAuthor.isCorresponding).toBe(body.isCorresponding) - expect(submittingAuthor.firstName).toBe(body.firstName) - expect(submittingAuthor.lastName).toBe(body.lastName) - }) - it('should return an error when the params are missing', async () => { - delete body.isSubmitting - const req = httpMocks.createRequest({ - body, - }) - req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id - req.params.userId = submittingAuthor.id - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - - expect(res.statusCode).toBe(400) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('Missing parameters') - body.isSubmitting = false - }) - it('should return an error if the collection does not exists', async () => { - const req = httpMocks.createRequest({ - body, - }) - req.user = submittingAuthor.id - req.params.collectionId = 'invalid-id' - req.params.userId = submittingAuthor.id - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - - expect(res.statusCode).toBe(404) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('item not found') - }) - it('should return an error when the user does not exist', async () => { - const req = httpMocks.createRequest({ - body, - }) - req.user = author.id - req.params.collectionId = standardCollection.id - req.params.userId = 'invalid-id' - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - - expect(res.statusCode).toBe(404) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('item not found') - }) - it('should return an error when the collection does not have authors', async () => { - const req = httpMocks.createRequest({ - body, - }) - req.user = submittingAuthor.id - req.params.collectionId = authorsCollection.id - req.params.userId = submittingAuthor.id - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - - expect(res.statusCode).toBe(400) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('Collection does not have any authors') - }) - it('should return an error when the collection and the user do not match', async () => { - const req = httpMocks.createRequest({ - body, - }) - req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id - req.params.userId = author.id - const res = httpMocks.createResponse() - await require(patchPath)(models)(req, res) - - expect(res.statusCode).toBe(400) - const data = JSON.parse(res._getData()) - expect(data.error).toEqual('Collection and user do not match') - }) -}) diff --git a/packages/component-user-manager/src/tests/fixtures/collections.js b/packages/component-user-manager/src/tests/fixtures/collections.js deleted file mode 100644 index bd50ad595d07a565ae5b799fb1a744217b92192d..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/fixtures/collections.js +++ /dev/null @@ -1,31 +0,0 @@ -const Chance = require('chance') -const { submittingAuthor } = require('./userData') - -const chance = new Chance() -const collections = { - standardCollection: { - id: chance.guid(), - title: chance.sentence(), - type: 'collection', - fragments: [], - owners: [submittingAuthor.id], - authors: [ - { - userId: submittingAuthor.id, - isSubmitting: true, - isCorresponding: false, - }, - ], - save: jest.fn(() => collections.standardCollection), - }, - authorsCollection: { - id: chance.guid(), - title: chance.sentence(), - type: 'collection', - fragments: [], - owners: [submittingAuthor.id], - save: jest.fn(), - }, -} - -module.exports = collections diff --git a/packages/component-user-manager/src/tests/fixtures/fixtures.js b/packages/component-user-manager/src/tests/fixtures/fixtures.js deleted file mode 100644 index 0ea29e85ac3a373a20a0e82377e0b6f3dfeef51e..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/fixtures/fixtures.js +++ /dev/null @@ -1,9 +0,0 @@ -const users = require('./users') -const collections = require('./collections') -const teams = require('./teams') - -module.exports = { - users, - collections, - teams, -} diff --git a/packages/component-user-manager/src/tests/fixtures/teams.js b/packages/component-user-manager/src/tests/fixtures/teams.js deleted file mode 100644 index 410b999e3f98f3b196eb28feb5512c744e5b61ea..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/fixtures/teams.js +++ /dev/null @@ -1,25 +0,0 @@ -const users = require('./users') -const collections = require('./collections') -const { authorTeamID } = require('./teamIDs') - -const { standardCollection } = collections -const { submittingAuthor } = users -const teams = { - authorTeam: { - teamType: { - name: 'author', - permissions: 'author', - }, - group: 'author', - name: 'author', - object: { - type: 'collection', - id: standardCollection.id, - }, - members: [submittingAuthor.id], - save: jest.fn(() => teams.authorTeam), - updateProperties: jest.fn(() => teams.authorTeam), - id: authorTeamID, - }, -} -module.exports = teams diff --git a/packages/component-user-manager/src/tests/fixtures/userData.js b/packages/component-user-manager/src/tests/fixtures/userData.js deleted file mode 100644 index 1a962c33dc4915763c259edae6deadfab9911f98..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/fixtures/userData.js +++ /dev/null @@ -1,22 +0,0 @@ -const Chance = require('chance') - -const chance = new Chance() - -module.exports = { - author: { - id: chance.guid(), - email: chance.email(), - firstName: chance.first(), - lastName: chance.last(), - }, - submittingAuthor: { - id: chance.guid(), - email: chance.email(), - firstName: chance.first(), - lastName: chance.last(), - }, - admin: { - id: chance.guid(), - email: chance.email(), - }, -} diff --git a/packages/component-user-manager/src/tests/fixtures/users.js b/packages/component-user-manager/src/tests/fixtures/users.js deleted file mode 100644 index 654032921308157e12248205373970f595d0d497..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/fixtures/users.js +++ /dev/null @@ -1,49 +0,0 @@ -const { authorTeamID } = require('./teamIDs') -const { author, submittingAuthor, admin } = require('./userData') -const Chance = require('chance') - -const chance = new Chance() -const users = { - admin: { - type: 'user', - username: 'admin', - email: admin.email, - password: 'password', - admin: true, - id: admin.id, - }, - author: { - type: 'user', - username: 'author', - email: author.email, - password: 'password', - admin: false, - id: author.id, - passwordResetToken: chance.hash(), - firstName: author.firstName, - lastName: author.lastName, - affiliation: 'MIT', - title: 'Mr', - save: jest.fn(() => users.author), - isConfirmed: false, - teams: [authorTeamID], - updateProperties: jest.fn(() => users.author), - }, - submittingAuthor: { - type: 'user', - username: 'sauthor', - email: submittingAuthor.email, - password: 'password', - admin: false, - id: submittingAuthor.id, - passwordResetToken: chance.hash(), - firstName: submittingAuthor.firstName, - lastName: submittingAuthor.lastName, - affiliation: chance.company(), - title: 'Mr', - save: jest.fn(() => users.submittingAuthor), - isConfirmed: false, - }, -} - -module.exports = users diff --git a/packages/component-user-manager/src/tests/fragmentsUsers/delete.test.js b/packages/component-user-manager/src/tests/fragmentsUsers/delete.test.js new file mode 100644 index 0000000000000000000000000000000000000000..5bea3f85277e6cadf09fd9ff8b31ae5bbb15df7f --- /dev/null +++ b/packages/component-user-manager/src/tests/fragmentsUsers/delete.test.js @@ -0,0 +1,77 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +process.env.SUPPRESS_NO_CONFIG_WARNING = true +const cloneDeep = require('lodash/cloneDeep') + +const httpMocks = require('node-mocks-http') +const fixturesService = require('pubsweet-component-fixture-service') + +const { Model, fixtures } = fixturesService + +const { author, submittingAuthor } = fixtures.users +const { collection } = fixtures.collections +const deletePath = '../../routes/fragmentsUsers/delete' +jest.mock('pubsweet-component-mail-service', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), +})) +describe('Delete fragments users route handler', () => { + let testFixtures = {} + let models + beforeEach(() => { + testFixtures = cloneDeep(fixtures) + models = Model.build(testFixtures) + }) + it('should return success when an author is deleted', async () => { + const req = httpMocks.createRequest({}) + req.user = submittingAuthor.id + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId + req.params.userId = author.id + const res = httpMocks.createResponse() + await require(deletePath)(models)(req, res) + + expect(res.statusCode).toBe(200) + }) + it('should return an error when the collection does not exist', async () => { + const req = httpMocks.createRequest({}) + req.user = submittingAuthor.id + req.params.collectionId = 'invalid-id' + req.params.userId = author.id + const res = httpMocks.createResponse() + await require(deletePath)(models)(req, res) + + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('item not found') + }) + it('should return an error when the user does not exist', async () => { + const req = httpMocks.createRequest({}) + req.user = submittingAuthor.id + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId + req.params.userId = 'invalid-id' + const res = httpMocks.createResponse() + await require(deletePath)(models)(req, res) + + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('item not found') + }) + it('should return an error when the fragment does not exist', async () => { + const req = httpMocks.createRequest() + req.user = submittingAuthor.id + req.params.collectionId = collection.id + req.params.fragmentId = 'invalid-fragment-id' + req.params.userId = author.id + const res = httpMocks.createResponse() + await require(deletePath)(models)(req, res) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + `Fragment invalid-fragment-id does not match collection ${collection.id}`, + ) + }) +}) diff --git a/packages/component-user-manager/src/tests/fragmentsUsers/get.test.js b/packages/component-user-manager/src/tests/fragmentsUsers/get.test.js new file mode 100644 index 0000000000000000000000000000000000000000..290d26af0b1efa9ad4a9b59cf519b40c688e9787 --- /dev/null +++ b/packages/component-user-manager/src/tests/fragmentsUsers/get.test.js @@ -0,0 +1,64 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' +process.env.SUPPRESS_NO_CONFIG_WARNING = true +const cloneDeep = require('lodash/cloneDeep') + +const httpMocks = require('node-mocks-http') +const fixturesService = require('pubsweet-component-fixture-service') + +const { Model, fixtures } = fixturesService + +const { collection } = fixtures.collections +const { submittingAuthor } = fixtures.users + +jest.mock('pubsweet-component-mail-service', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), +})) +const getPath = '../../routes/fragmentsUsers/get' +describe('Get fragments users route handler', () => { + let testFixtures = {} + let models + beforeEach(() => { + testFixtures = cloneDeep(fixtures) + models = Model.build(testFixtures) + }) + it('should return success when the request data is correct', async () => { + const req = httpMocks.createRequest() + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId + req.user = submittingAuthor.id + const res = httpMocks.createResponse() + await require(getPath)(models)(req, res) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + + expect(data).toHaveLength(1) + expect(data[0].isSubmitting).toBeDefined() + }) + it('should return an error when the collection does not exist', async () => { + const req = httpMocks.createRequest() + req.params.collectionId = 'invalid-id' + req.user = submittingAuthor.id + const res = httpMocks.createResponse() + await require(getPath)(models)(req, res) + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('item not found') + }) + it('should return an error when the fragment does not exist', async () => { + const req = httpMocks.createRequest() + req.user = submittingAuthor.id + req.params.collectionId = collection.id + req.params.fragmentId = 'invalid-fragment-id' + const res = httpMocks.createResponse() + await require(getPath)(models)(req, res) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + `Fragment invalid-fragment-id does not match collection ${collection.id}`, + ) + }) +}) diff --git a/packages/component-user-manager/src/tests/collectionsUsers/post.test.js b/packages/component-user-manager/src/tests/fragmentsUsers/post.test.js similarity index 61% rename from packages/component-user-manager/src/tests/collectionsUsers/post.test.js rename to packages/component-user-manager/src/tests/fragmentsUsers/post.test.js index eede871a5820c4aba630a48186a8ed88a080d7ab..42ceb286f20f187bde72a6e523527f8d42b85ae9 100644 --- a/packages/component-user-manager/src/tests/collectionsUsers/post.test.js +++ b/packages/component-user-manager/src/tests/fragmentsUsers/post.test.js @@ -2,11 +2,12 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true const httpMocks = require('node-mocks-http') -const fixtures = require('./../fixtures/fixtures') const Chance = require('chance') -const Model = require('./../helpers/Model') +const cloneDeep = require('lodash/cloneDeep') +const fixturesService = require('pubsweet-component-fixture-service') + +const { Model, fixtures } = fixturesService -const models = Model.build() jest.mock('pubsweet-component-mail-service', () => ({ sendSimpleEmail: jest.fn(), sendNotificationEmail: jest.fn(), @@ -14,21 +15,31 @@ jest.mock('pubsweet-component-mail-service', () => ({ const chance = new Chance() const { author, submittingAuthor } = fixtures.users -const { standardCollection } = fixtures.collections -const postPath = '../../routes/collectionsUsers/post' -const body = { +const { collection } = fixtures.collections +const postPath = '../../routes/fragmentsUsers/post' +const reqBody = { email: chance.email(), role: 'author', isSubmitting: true, isCorresponding: false, } -describe('Post collections users route handler', () => { - it('should return success when an author adds a new user to a collection', async () => { +describe('Post fragments users route handler', () => { + let testFixtures = {} + let body = {} + let models + beforeEach(() => { + testFixtures = cloneDeep(fixtures) + body = cloneDeep(reqBody) + models = Model.build(testFixtures) + }) + it('should return success when an author adds a new user to a fragment', async () => { const req = httpMocks.createRequest({ body, }) req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId const res = httpMocks.createResponse() await require(postPath)(models)(req, res) @@ -36,36 +47,32 @@ describe('Post collections users route handler', () => { const data = JSON.parse(res._getData()) expect(data.email).toEqual(body.email) expect(data.invitations).toBeUndefined() - const matchingAuthor = standardCollection.authors.find( - author => author.userId === data.id, - ) - expect(matchingAuthor).toBeDefined() }) - it('should return success when an author adds an existing user as co author to a collection', async () => { + it('should return success when an author adds an existing user as co author to a fragment', async () => { body.email = author.email const req = httpMocks.createRequest({ body, }) req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId const res = httpMocks.createResponse() await require(postPath)(models)(req, res) expect(res.statusCode).toBe(200) const data = JSON.parse(res._getData()) expect(data.email).toEqual(body.email) - const matchingAuthor = standardCollection.authors.find( - auth => auth.userId === author.id, - ) - expect(matchingAuthor).toBeDefined() }) - it('should return an error when the an author is added to the same collection', async () => { + it('should return an error when the an author is added to the same fragment', async () => { body.email = submittingAuthor.email const req = httpMocks.createRequest({ body, }) req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId const res = httpMocks.createResponse() await require(postPath)(models)(req, res) @@ -81,7 +88,9 @@ describe('Post collections users route handler', () => { body, }) req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId const res = httpMocks.createResponse() await require(postPath)(models)(req, res) @@ -96,7 +105,9 @@ describe('Post collections users route handler', () => { body, }) req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId const res = httpMocks.createResponse() await require(postPath)(models)(req, res) @@ -111,7 +122,9 @@ describe('Post collections users route handler', () => { body, }) req.user = submittingAuthor.id - req.params.collectionId = standardCollection.id + req.params.collectionId = collection.id + const [fragmentId] = collection.fragments + req.params.fragmentId = fragmentId const res = httpMocks.createResponse() await require(postPath)(models)(req, res) @@ -119,4 +132,21 @@ describe('Post collections users route handler', () => { const data = JSON.parse(res._getData()) expect(data.error).toEqual('invalid-role is not defined') }) + it('should return an error when the fragment does not exist', async () => { + const req = httpMocks.createRequest({ + body, + }) + req.user = submittingAuthor.id + req.params.collectionId = collection.id + req.params.fragmentId = 'invalid-fragment-id' + req.params.userId = author.id + const res = httpMocks.createResponse() + await require(postPath)(models)(req, res) + + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual( + `Fragment invalid-fragment-id does not match collection ${collection.id}`, + ) + }) }) diff --git a/packages/component-user-manager/src/tests/helpers/Model.js b/packages/component-user-manager/src/tests/helpers/Model.js deleted file mode 100644 index e5f31fa11df30b229f84e129d41c33062f7b6d1d..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/helpers/Model.js +++ /dev/null @@ -1,61 +0,0 @@ -const fixtures = require('../fixtures/fixtures') - -const UserMock = require('../mocks/User') -const TeamMock = require('../mocks/Team') - -const notFoundError = new Error() -notFoundError.name = 'NotFoundError' -notFoundError.status = 404 - -const build = () => { - const models = { - User: {}, - Collection: { - find: jest.fn(id => findMock(id, 'collections')), - }, - Team: {}, - } - UserMock.find = jest.fn(id => findMock(id, 'users')) - UserMock.findByEmail = jest.fn(email => findByEmailMock(email)) - UserMock.all = jest.fn(() => Object.values(fixtures.users)) - UserMock.updateProperties = jest.fn(user => - updatePropertiesMock(user, 'users'), - ) - TeamMock.find = jest.fn(id => findMock(id, 'teams')) - TeamMock.updateProperties = jest.fn(team => - updatePropertiesMock(team, 'teams'), - ) - TeamMock.all = jest.fn(() => Object.values(fixtures.teams)) - - models.User = UserMock - models.Team = TeamMock - return models -} - -const findMock = (id, type) => { - const foundObj = Object.values(fixtures[type]).find( - fixtureObj => fixtureObj.id === id, - ) - - if (foundObj === undefined) return Promise.reject(notFoundError) - return Promise.resolve(foundObj) -} - -const findByEmailMock = email => { - const foundUser = Object.values(fixtures.users).find( - fixtureUser => fixtureUser.email === email, - ) - - if (foundUser === undefined) return Promise.reject(notFoundError) - return Promise.resolve(foundUser) -} - -const updatePropertiesMock = (obj, type) => { - const foundObj = Object.values(fixtures[type]).find( - fixtureObj => fixtureObj === obj, - ) - - if (foundObj === undefined) return Promise.reject(notFoundError) - return Promise.resolve(foundObj) -} -module.exports = { build } diff --git a/packages/component-user-manager/src/tests/mocks/Team.js b/packages/component-user-manager/src/tests/mocks/Team.js deleted file mode 100644 index f84ef4dcf8ffa0e5056c8f0eb86573f624d74c06..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/mocks/Team.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable func-names-any */ - -function Team(properties) { - this.teamType = properties.teamType - this.group = properties.group - this.name = properties.name - this.object = properties.object - this.members = properties.members -} - -Team.prototype.save = jest.fn(function saveTeam() { - this.id = '111222' - return Promise.resolve(this) -}) - -module.exports = Team diff --git a/packages/component-user-manager/src/tests/mocks/User.js b/packages/component-user-manager/src/tests/mocks/User.js deleted file mode 100644 index 9a7459fc5578d3aa1d5dcec8562fa8f17225b1eb..0000000000000000000000000000000000000000 --- a/packages/component-user-manager/src/tests/mocks/User.js +++ /dev/null @@ -1,21 +0,0 @@ -/* eslint-disable func-names-any */ - -function User(properties) { - this.type = 'user' - this.email = properties.email - this.username = properties.username - this.password = properties.password - this.roles = properties.roles - this.title = properties.title - this.affiliation = properties.affiliation - this.firstName = properties.firstName - this.lastName = properties.lastName - this.admin = properties.admin -} - -User.prototype.save = jest.fn(function saveUser() { - this.id = '111222' - return Promise.resolve(this) -}) - -module.exports = User diff --git a/packages/component-user-manager/src/tests/users/resetPassword.test.js b/packages/component-user-manager/src/tests/users/resetPassword.test.js index f2a7522d2874cd428b57896a853943fe8dcb6d9b..12d6e07bc25a0e4a03737d08f09519c1a443d659 100644 --- a/packages/component-user-manager/src/tests/users/resetPassword.test.js +++ b/packages/component-user-manager/src/tests/users/resetPassword.test.js @@ -1,25 +1,27 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' process.env.SUPPRESS_NO_CONFIG_WARNING = true -const httpMocks = require('node-mocks-http') -const fixtures = require('./../fixtures/fixtures') const Chance = require('chance') -const Model = require('./../helpers/Model') -const clone = require('lodash/cloneDeep') +const httpMocks = require('node-mocks-http') +const cloneDeep = require('lodash/cloneDeep') +const fixturesService = require('pubsweet-component-fixture-service') +const { Model, fixtures } = fixturesService const chance = new Chance() -const { author } = fixtures.users -const clonedAuthor = clone(author) - -const body = { - email: clonedAuthor.email, - firstName: clonedAuthor.firstName, - lastName: clonedAuthor.lastName, - title: clonedAuthor.title, - affiliation: clonedAuthor.affiliation, +const { user, author } = fixtures.users +jest.mock('pubsweet-component-mail-service', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), +})) +const reqBody = { + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + title: user.title, + affiliation: user.affiliation, password: 'password', - token: clonedAuthor.passwordResetToken, + token: user.passwordResetToken, isConfirmed: false, } @@ -28,69 +30,67 @@ notFoundError.name = 'NotFoundError' notFoundError.status = 404 const resetPasswordPath = '../../routes/users/resetPassword' describe('Users password reset route handler', () => { + let testFixtures = {} + let body = {} + let models + beforeEach(() => { + testFixtures = cloneDeep(fixtures) + body = cloneDeep(reqBody) + models = Model.build(testFixtures) + }) it('should return an error when some parameters are missing', async () => { delete body.email const req = httpMocks.createRequest({ body }) const res = httpMocks.createResponse() - const models = Model.build() await require(resetPasswordPath)(models)(req, res) expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) expect(data.error).toEqual('missing required params') - body.email = author.email }) it('should return an error when the password is too small', async () => { body.password = 'small' const req = httpMocks.createRequest({ body }) const res = httpMocks.createResponse() - const models = Model.build() await require(resetPasswordPath)(models)(req, res) expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) expect(data.error).toEqual( 'password needs to be at least 7 characters long', ) - body.password = 'password' }) it('should return an error when user is not found', async () => { body.email = chance.email() const req = httpMocks.createRequest({ body }) const res = httpMocks.createResponse() - const models = Model.build() await require(resetPasswordPath)(models)(req, res) expect(res.statusCode).toBe(404) const data = JSON.parse(res._getData()) expect(data.error).toEqual('user not found') - body.email = author.email }) it('should return an error when the tokens do not match', async () => { body.token = chance.hash() const req = httpMocks.createRequest({ body }) const res = httpMocks.createResponse() - const models = Model.build() await require(resetPasswordPath)(models)(req, res) expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) expect(data.error).toEqual('invalid request') - body.token = author.passwordResetToken }) it('should return an error when the user is already confirmed', async () => { - author.isConfirmed = true + body.email = author.email + body.token = author.passwordResetToken const req = httpMocks.createRequest({ body }) const res = httpMocks.createResponse() - const models = Model.build() await require(resetPasswordPath)(models)(req, res) expect(res.statusCode).toBe(400) const data = JSON.parse(res._getData()) expect(data.error).toEqual('User is already confirmed') - author.isConfirmed = false }) it('should return success when the body is correct', async () => { const req = httpMocks.createRequest({ body }) const res = httpMocks.createResponse() - const models = Model.build() await require(resetPasswordPath)(models)(req, res) expect(res.statusCode).toBe(200) diff --git a/packages/component-wizard/package.json b/packages/component-wizard/package.json index 0808310bd41254332a5be4314b576b06da4f8e40..7d2840160e3e7212749ca1b010b0b9ccf687a594 100644 --- a/packages/component-wizard/package.json +++ b/packages/component-wizard/package.json @@ -17,7 +17,7 @@ "react-dom": "^15.6.1", "react-router-dom": "^4.2.2", "redux": "^3.6.0", - "redux-form": "^7.0.3", + "redux-form": "7.0.3", "recompose": "^0.26.0", "xpub-validators": "^0.0.3", "xpub-connect": "^0.0.3", diff --git a/packages/component-wizard/src/components/WizardFormStep.js b/packages/component-wizard/src/components/WizardFormStep.js index f28662bbe5a2f8f787844e91eefa160121af7087..e87290a13955d609c3f6fd59b93edf7afa502128 100644 --- a/packages/component-wizard/src/components/WizardFormStep.js +++ b/packages/component-wizard/src/components/WizardFormStep.js @@ -1,8 +1,8 @@ import PropTypes from 'prop-types' import { connect } from 'react-redux' -import { debounce, pick, get, isEqual } from 'lodash' import { actions } from 'pubsweet-client' -import { compose, getContext, withProps } from 'recompose' +import { debounce, pick, get, isEqual } from 'lodash' +import { compose, getContext, withProps, setDisplayName } from 'recompose' import { reduxForm, formValueSelector, SubmissionError } from 'redux-form' import WizardStep from './WizardStep' @@ -14,9 +14,8 @@ const onChange = ( values, dispatch, { project, version, wizard: { formSectionKeys }, setLoader }, - prevValues, ) => { - const prev = pick(prevValues, formSectionKeys) + const prev = pick(version, formSectionKeys) const newValues = pick(values, formSectionKeys) // TODO: fix this if it sucks down the road if (!isEqual(prev, newValues)) { @@ -100,6 +99,7 @@ const onSubmit = ( } export default compose( + setDisplayName('SubmitWizard'), getContext({ history: PropTypes.object, isFinal: PropTypes.bool, @@ -112,8 +112,8 @@ export default compose( toggleConfirmation: PropTypes.func, }), withProps(({ version, wizard }) => ({ - initialValues: pick(version, wizard.formSectionKeys), readonly: !!get(version, 'submitted'), + initialValues: pick(version, wizard.formSectionKeys), })), connect((state, { wizard: { formSectionKeys } }) => ({ formValues: wizardSelector(state, ...formSectionKeys), diff --git a/packages/component-wizard/src/components/WizardStep.js b/packages/component-wizard/src/components/WizardStep.js index 6846bee3fb582f14a2a89df9f44961796e912ecd..1f8a35281318010fd7cdb2e00a89ce3df6e9c9d3 100644 --- a/packages/component-wizard/src/components/WizardStep.js +++ b/packages/component-wizard/src/components/WizardStep.js @@ -39,7 +39,6 @@ export default ({ validate, dependsOn, renderComponent: Comp, - format, parse, ...rest }) => { @@ -54,10 +53,9 @@ export default ({ <ValidatedField component={input => ( <div data-test={fieldId}> - <Comp {...rest} {...input} {...dispatchFns} />{' '} + <Comp {...rest} {...input} {...dispatchFns} /> </div> )} - format={format} name={fieldId} parse={parse} validate={validate} diff --git a/packages/component-wizard/src/redux/conversion.js b/packages/component-wizard/src/redux/conversion.js index c085f1e196ddd80a3cf4449212c088353b6807ad..87f5ac3843b481f59c5b0ca9584266b3049ed3a0 100644 --- a/packages/component-wizard/src/redux/conversion.js +++ b/packages/component-wizard/src/redux/conversion.js @@ -1,5 +1,5 @@ -import { pick } from 'lodash' import moment from 'moment' +import { pick } from 'lodash' import { actions } from 'pubsweet-client' import { create } from 'pubsweet-client/src/helpers/api' @@ -24,13 +24,13 @@ const generateCustomId = () => .toString() .slice(-7) -const addSubmittingAuthor = (user, collectionId) => { +const addSubmittingAuthor = (user, collectionId, fragmentId) => { const author = { - ...pick(user, ['affiliation', 'email', 'firstName', 'lastName']), + ...pick(user, ['id', 'email', 'affiliation', 'firstName', 'lastName']), isSubmitting: true, isCorresponding: true, } - create(`/collections/${collectionId}/users`, { + create(`/collections/${collectionId}/fragments/${fragmentId}/users`, { role: 'author', ...author, }) @@ -50,6 +50,7 @@ export const createDraftSubmission = history => (dispatch, getState) => { return dispatch( actions.createFragment(collection, { created: new Date(), // TODO: set on server + collectionId: collection.id, files: { manuscripts: [], supplementary: [], @@ -65,7 +66,7 @@ export const createDraftSubmission = history => (dispatch, getState) => { } const route = `/projects/${collection.id}/versions/${fragment.id}/submit` if (!currentUser.admin) { - addSubmittingAuthor(currentUser, collection.id) + addSubmittingAuthor(currentUser, collection.id, fragment.id) } // redirect after a short delay @@ -76,6 +77,27 @@ export const createDraftSubmission = history => (dispatch, getState) => { }) } +export const createRevision = ( + collection, + previousVersion, + history, +) => dispatch => { + const { id, submitted, ...prev } = previousVersion + return dispatch( + actions.createFragment(collection, { + ...prev, + created: new Date(), + version: previousVersion.version + 1, + }), + ).then(({ fragment }) => { + const route = `/projects/${collection.id}/versions/${fragment.id}/submit` + window.setTimeout(() => { + history.push(route) + }, 10) + return fragment + }) +} + /* reducer */ const initialState = { complete: undefined, diff --git a/packages/components-faraday/package.json b/packages/components-faraday/package.json index 030679184f97ef574310dda6920ecfeaaf55f8bc..30188c9434fbf453d0fe3c775c6e7af55cf8cc26 100644 --- a/packages/components-faraday/package.json +++ b/packages/components-faraday/package.json @@ -16,7 +16,7 @@ "react-tippy": "^1.2.2", "recompose": "^0.26.0", "redux": "^3.6.0", - "redux-form": "^7.0.3", + "redux-form": "7.0.3", "styled-components": "^3.1.6" } } diff --git a/packages/components-faraday/src/components/AuthorList/Author.js b/packages/components-faraday/src/components/AuthorList/Author.js index 5df7654e9b1ef7277a196a6981dbff69e8c2669b..d993d10b1074579a766a60f75e880cd07a00c3cd 100644 --- a/packages/components-faraday/src/components/AuthorList/Author.js +++ b/packages/components-faraday/src/components/AuthorList/Author.js @@ -19,6 +19,7 @@ export default ({ setAuthorEdit, isCorresponding, parseAuthorType, + ...rest }) => ( <Root isOver={isOver}> {!isOver && dragHandle} @@ -27,10 +28,7 @@ export default ({ <Title>{parseAuthorType(isSubmitting, isCorresponding, index)}</Title> <ButtonContainer> {!isSubmitting && ( - <ClickableIcon - onClick={removeAuthor(id, email)} - title="Delete author" - > + <ClickableIcon onClick={removeAuthor(id)} title="Delete author"> <Icon size={3}>trash</Icon> </ClickableIcon> )} diff --git a/packages/components-faraday/src/components/AuthorList/AuthorAdder.js b/packages/components-faraday/src/components/AuthorList/AuthorAdder.js index 47264445eab33b0bc90a9a17bc7e84fa77a05aef..82f7305564a4aae1b3cd23b14e01421bfa7f7e4e 100644 --- a/packages/components-faraday/src/components/AuthorList/AuthorAdder.js +++ b/packages/components-faraday/src/components/AuthorList/AuthorAdder.js @@ -1,16 +1,15 @@ import React from 'react' import { get } from 'lodash' import { connect } from 'react-redux' -import { reduxForm } from 'redux-form' import styled from 'styled-components' import { Button, th } from '@pubsweet/ui' -import { compose, withProps } from 'recompose' import { selectCurrentUser } from 'xpub-selectors' +import { reduxForm, change as changeForm } from 'redux-form' +import { compose, withProps, setDisplayName } from 'recompose' import { emailValidator } from '../utils' import { Spinner } from '../UIComponents/' import { - getAuthors, authorSuccess, authorFailure, getAuthorFetching, @@ -77,6 +76,7 @@ export default compose( currentUser: selectCurrentUser(state), }), { + changeForm, authorSuccess, authorFailure, }, @@ -101,38 +101,36 @@ export default compose( reduxForm({ form: 'author', enableReinitialize: true, + destroyOnUnmount: false, onSubmit: ( values, dispatch, - { authors = [], addAuthor, setEditMode, setFormAuthors, reset, match }, + { + reset, + match, + changeForm, + addAuthor, + setEditMode, + setFormAuthors, + authors = [], + }, ) => { const collectionId = get(match, 'params.project') + const fragmentId = get(match, 'params.version') const isFirstAuthor = authors.length === 0 - addAuthor( - { - ...values, - isSubmitting: isFirstAuthor, - isCorresponding: isFirstAuthor, - }, - collectionId, - ).then( - () => { - setEditMode(false)() - setTimeout(() => { - getAuthors(collectionId).then( - data => { - dispatch(authorSuccess()) - setFormAuthors(data) - }, - err => dispatch(authorFailure(err)), - ) - }, 10) - reset() - }, - err => dispatch(authorFailure(err)), - ) + const newAuthor = { + ...values, + isSubmitting: isFirstAuthor, + isCorresponding: isFirstAuthor, + } + addAuthor(newAuthor, collectionId, fragmentId).then(author => { + changeForm('wizard', 'authors', [...authors, author]) + setEditMode(false)() + reset() + }) }, }), + setDisplayName('AuthorAdder'), )(AuthorAdder) // #region styled-components diff --git a/packages/components-faraday/src/components/AuthorList/AuthorEditor.js b/packages/components-faraday/src/components/AuthorList/AuthorEditor.js index 38deb2e6199096c7714b31fcdbbf9ecd0db8fe15..185296d68773dbb6c6abd6cbdad0d1d5b7acbb95 100644 --- a/packages/components-faraday/src/components/AuthorList/AuthorEditor.js +++ b/packages/components-faraday/src/components/AuthorList/AuthorEditor.js @@ -3,25 +3,26 @@ import { pick } from 'lodash' import { connect } from 'react-redux' import styled, { css } from 'styled-components' import { Icon, Checkbox, th } from '@pubsweet/ui' -import { compose, withHandlers, withProps } from 'recompose' +import { compose, withProps } from 'recompose' import { reduxForm, Field, change as changeForm } from 'redux-form' import { Spinner } from '../UIComponents' import { - getAuthors, - editAuthor, authorSuccess, authorFailure, getAuthorFetching, } from '../../redux/authors' import { ValidatedTextField, Label } from './FormItems' +import { authorKeys, parseEditedAuthors } from './utils' + const renderCheckbox = ({ input }) => ( <Checkbox checked={input.value} type="checkbox" {...input} /> ) const AuthorEdit = ({ + id, index, email, isFetching, @@ -30,8 +31,6 @@ const AuthorEdit = ({ setAuthorEdit, parseAuthorType, isCorresponding, - changeCorresponding, - ...rest }) => ( <Root> <Header> @@ -39,11 +38,7 @@ const AuthorEdit = ({ <span>{parseAuthorType(isSubmitting, isCorresponding, index)}</span> {!isSubmitting && ( <Fragment> - <Field - component={renderCheckbox} - name="edit.isCorresponding" - onChange={changeCorresponding(email)} - /> + <Field component={renderCheckbox} name="edit.isCorresponding" /> <label>Corresponding</label> </Fragment> )} @@ -92,47 +87,19 @@ export default compose( ), withProps(props => ({ initialValues: { - edit: pick(props, [ - 'id', - 'email', - 'lastName', - 'firstName', - 'affiliation', - 'isSubmitting', - 'isCorresponding', - ]), + edit: pick(props, authorKeys), }, })), - withHandlers({ - changeCorresponding: ({ changeForm, setAsCorresponding }) => email => ( - evt, - newValue, - ) => { - setAsCorresponding(email)() - changeForm('edit', 'edit.isCorresponding', newValue) - }, - }), reduxForm({ form: 'edit', onSubmit: ( - values, + { edit: newAuthor }, dispatch, - { setAuthorEdit, setAuthors, authors, index, changeForm, project }, + { authors, changeForm, setAuthorEdit }, ) => { - const newAuthor = values.edit - editAuthor(project.id, newAuthor.id, newAuthor).then( - () => { - getAuthors(project.id).then( - data => { - dispatch(authorSuccess()) - setAuthorEdit(-1)() - setAuthors(data) - }, - err => dispatch(authorFailure(err)), - ) - }, - err => dispatch(authorFailure(err)), - ) + const newAuthors = parseEditedAuthors(newAuthor, authors) + changeForm('wizard', 'authors', newAuthors) + setAuthorEdit(-1)() }, }), )(AuthorEdit) diff --git a/packages/components-faraday/src/components/AuthorList/AuthorList.js b/packages/components-faraday/src/components/AuthorList/AuthorList.js index c6b03ef04c98938244acd3f7731886b24b9444ab..5fd5b2708393789403b3061c08e08d13150e3125 100644 --- a/packages/components-faraday/src/components/AuthorList/AuthorList.js +++ b/packages/components-faraday/src/components/AuthorList/AuthorList.js @@ -1,4 +1,5 @@ import React from 'react' +import { get, isBoolean, isNumber } from 'lodash' import { th } from '@pubsweet/ui' import PropTypes from 'prop-types' import { connect } from 'react-redux' @@ -6,35 +7,33 @@ import styled from 'styled-components' import { withRouter } from 'react-router-dom' import { compose, - lifecycle, withState, + withProps, getContext, withHandlers, + setDisplayName, } from 'recompose' -import { change as changeForm } from 'redux-form' +import { change as changeForm, formValueSelector } from 'redux-form' import { SortableList } from 'pubsweet-component-sortable-list/src/components' import { addAuthor, - getAuthors, deleteAuthor, authorFailure, - getAuthorsTeam, getAuthorError, - updateAuthorsTeam, } from '../../redux/authors' -import Author from './Author' -import StaticList from './StaticList' -import AuthorAdder from './AuthorAdder' import { DragHandle } from './FormItems' -import AuthorEditor from './AuthorEditor' +import { Author, StaticList, AuthorAdder, AuthorEditor } from './' + +const wizardSelector = formValueSelector('wizard') const Authors = ({ match, error, authors, version, + addMode, editMode, dropItem, addAuthor, @@ -50,25 +49,25 @@ const Authors = ({ addAuthor={addAuthor} authors={authors} editAuthor={editAuthor} - editMode={editMode} + editMode={addMode} match={match} setEditMode={setEditMode} setFormAuthors={setFormAuthors} /> - {editedAuthor > -1 ? ( + {isNumber(editMode) && editMode > -1 ? ( <StaticList authors={authors} editComponent={AuthorEditor} - editIndex={editedAuthor} + editIndex={editMode} setFormAuthors={setFormAuthors} + version={version} {...rest} /> ) : ( <SortableList beginDragProps={['index', 'lastName']} dragHandle={DragHandle} - dropItem={dropItem} - editedAuthor={editedAuthor} + editedAuthor={editMode} items={authors || []} listItem={Author} moveItem={moveAuthor} @@ -84,8 +83,9 @@ export default compose( getContext({ version: PropTypes.object, project: PropTypes.object }), connect( state => ({ - currentUser: state.currentUser.user, error: getAuthorError(state), + currentUser: get(state, 'currentUser.user'), + authorForm: wizardSelector(state, 'authorForm'), }), { addAuthor, @@ -95,86 +95,49 @@ export default compose( }, ), withState('authors', 'setAuthors', []), - withState('editMode', 'setEditMode', false), - withState('editedAuthor', 'setEditedAuthor', -1), + withProps(({ version, authorForm }) => ({ + authors: get(version, 'authors') || [], + addMode: isBoolean(authorForm) && authorForm, + editMode: isNumber(authorForm) ? authorForm : -1, + })), withHandlers({ - setFormAuthors: ({ setAuthors, changeForm }) => authors => { + setFormAuthors: ({ setAuthors, changeForm }) => (authors = []) => { setAuthors(authors) changeForm('wizard', 'authors', authors) }, }), withHandlers({ - setAuthorEdit: ({ setEditedAuthor, changeForm }) => editedAuthor => e => { + setAuthorEdit: ({ changeForm }) => authorIndex => e => { e && e.preventDefault && e.preventDefault() - changeForm('wizard', 'editMode', editedAuthor > -1) - setEditedAuthor(prev => editedAuthor) + changeForm('wizard', 'authorForm', authorIndex) }, - setEditMode: ({ setEditMode, changeForm }) => mode => e => { + setEditMode: ({ changeForm }) => mode => e => { e && e.preventDefault() - changeForm('wizard', 'editMode', mode) - setEditMode(v => mode) - }, - dropItem: ({ authors, setFormAuthors, project, authorFailure }) => () => { - setFormAuthors(authors) - getAuthorsTeam(project.id) - .then(team => { - const members = authors.map(a => a.id) - updateAuthorsTeam(team.id, { members }).catch(err => { - authorFailure(err) - getAuthors(project.id).then(setFormAuthors) - }) - }) - .catch(err => { - authorFailure(err) - getAuthors(project.id).then(setFormAuthors) - }) + changeForm('wizard', 'authorForm', mode) }, parseAuthorType: () => (isSubmitting, isCorresponding, index) => { if (isSubmitting) return `#${index + 1} Submitting author` if (isCorresponding) return `#${index + 1} Corresponding author` return `#${index + 1} Author` }, - moveAuthor: ({ authors, setFormAuthors, changeForm }) => ( - dragIndex, - hoverIndex, - ) => { + moveAuthor: ({ authors, setFormAuthors }) => (dragIndex, hoverIndex) => { const newAuthors = SortableList.moveItem(authors, dragIndex, hoverIndex) setFormAuthors(newAuthors) }, removeAuthor: ({ authors, + version, project, deleteAuthor, - authorFailure, setFormAuthors, - }) => (id, authorEmail) => () => { - deleteAuthor(project.id, id).then( - () => { - const newAuthors = authors.filter(a => a.id !== id) - setFormAuthors(newAuthors) - }, - err => authorFailure(err), - ) - }, - setAsCorresponding: ({ authors, setFormAuthors }) => authorEmail => () => { - const newAuthors = authors.map( - a => - a.email === authorEmail - ? { - ...a, - isCorresponding: !a.isCorresponding, - } - : { ...a, isCorresponding: false }, - ) - setFormAuthors(newAuthors) - }, - }), - lifecycle({ - componentDidMount() { - const { setFormAuthors, project } = this.props - getAuthors(project.id).then(setFormAuthors) + }) => id => () => { + deleteAuthor(project.id, version.id, id).then(() => { + const newAuthors = authors.filter(a => a.id !== id) + setFormAuthors(newAuthors) + }) }, }), + setDisplayName('AuthorList'), )(Authors) // #region styled-components diff --git a/packages/components-faraday/src/components/AuthorList/StaticList.js b/packages/components-faraday/src/components/AuthorList/StaticList.js index 6e00a7a456175175eb4953ddfa8a82671c82d540..f6f82fc5bd649641563777bceb170b1e161847e5 100644 --- a/packages/components-faraday/src/components/AuthorList/StaticList.js +++ b/packages/components-faraday/src/components/AuthorList/StaticList.js @@ -3,14 +3,15 @@ import React from 'react' import Author from './Author' export default ({ + version, + project, authors, editIndex, - setFormAuthors, removeAuthor, editComponent, setAuthorEdit, + setFormAuthors, parseAuthorType, - setAsCorresponding, ...rest }) => ( <div> @@ -19,17 +20,17 @@ export default ({ index === editIndex ? ( React.createElement(editComponent, { key: 'author-editor', - authors, index, initialValues: { edit: author, }, + authors, setAuthors: setFormAuthors, setAuthorEdit, parseAuthorType, - setAsCorresponding, + project, + version, ...author, - ...rest, }) ) : ( <Author @@ -38,7 +39,6 @@ export default ({ index={index} parseAuthorType={parseAuthorType} removeAuthor={removeAuthor} - setAsCorresponding={setAsCorresponding} {...rest} /> ), diff --git a/packages/components-faraday/src/components/AuthorList/index.js b/packages/components-faraday/src/components/AuthorList/index.js index 473f04dcd0b9fba6f38dd46e866126960656c2f2..1ff2016c20ac9622dde7315255596cff24fea844 100644 --- a/packages/components-faraday/src/components/AuthorList/index.js +++ b/packages/components-faraday/src/components/AuthorList/index.js @@ -1 +1,9 @@ +import * as utils from './utils' + +export { default as Author } from './Author' export { default as AuthorList } from './AuthorList' +export { default as StaticList } from './StaticList' +export { default as AuthorAdder } from './AuthorAdder' +export { default as AuthorEditor } from './AuthorEditor' + +export { utils } diff --git a/packages/components-faraday/src/components/AuthorList/utils.js b/packages/components-faraday/src/components/AuthorList/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..e58c4abec7befcf31d2d74dc9c4ecf0ceb324ab0 --- /dev/null +++ b/packages/components-faraday/src/components/AuthorList/utils.js @@ -0,0 +1,40 @@ +import { isBoolean } from 'lodash' + +export const authorKeys = [ + 'id', + 'email', + 'lastName', + 'firstName', + 'affiliation', + 'isSubmitting', + 'isCorresponding', +] + +export const setCorresponding = id => author => + author.id === id + ? { + ...author, + isCorresponding: true, + } + : { ...author, isCorresponding: false } + +export const castToBool = author => ({ + ...author, + isCorresponding: isBoolean(author.isCorresponding) && author.isCorresponding, +}) + +export const parseEditedAuthors = (editedAuthor, authors) => { + const newAuthor = castToBool(editedAuthor) + + return authors.map( + a => + a.id === newAuthor.id + ? newAuthor + : { + ...a, + isCorresponding: newAuthor.isCorresponding + ? false + : a.isCorresponding, + }, + ) +} diff --git a/packages/components-faraday/src/components/Dashboard/DashboardCard.js b/packages/components-faraday/src/components/Dashboard/DashboardCard.js index f773064fb9af6d65f85921d6e28d653aa53a9e2a..ae4c120f523173860f19fb00bb4352a05c9fb97e 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardCard.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardCard.js @@ -16,8 +16,8 @@ import { selectInvitation } from '../../redux/reviewers' import { ReviewerDecision, HandlingEditorSection, DeleteManuscript } from './' import { parseVersion, parseJournalIssue, mapStatusToLabel } from './../utils' import { - currentUserIs, canMakeDecision, + isHEToManuscript, canInviteReviewers, canMakeRecommendation, } from '../../../../component-faraday-selectors/src' @@ -85,18 +85,19 @@ const DashboardCard = ({ <Icon>download</Icon> </ClickableIcon> </ZipFiles> - {!project.status && ( - <ActionButtons - data-test="button-resume-submission" - onClick={() => - history.push( - `/projects/${project.id}/versions/${version.id}/submit`, - ) - } - > - RESUME SUBMISSION - </ActionButtons> - )} + {!project.status || + (project.status === 'draft' && ( + <ActionButtons + data-test="button-resume-submission" + onClick={() => + history.push( + `/projects/${project.id}/versions/${version.id}/submit`, + ) + } + > + RESUME SUBMISSION + </ActionButtons> + ))} </RightDetails> </LeftDetails> </Top> @@ -117,7 +118,7 @@ const DashboardCard = ({ <ManuscriptType title={manuscriptMeta}> {manuscriptMeta} </ManuscriptType> - {project.status ? ( + {project.status && project.status !== 'draft' ? ( <Details data-test="button-details" onClick={() => @@ -138,45 +139,48 @@ const DashboardCard = ({ </RightDetails> </Bottom> </ListView> - {project.status && ( - <DetailsView> - <Top> - <AuthorsWithTooltip authors={project.authors} /> - </Top> - <Bottom> - <LeftDetails flex={4}> - <HandlingEditorSection - currentUser={currentUser} - project={project} - /> - </LeftDetails> - {canInviteReviewers && ( - <RightDetails flex={4}> - <ReviewerBreakdown - collectionId={project.id} - compact - versionId={version.id} - /> - <InviteReviewers - modalKey={`invite-reviewers-${project.id}`} - project={project} - version={version} - /> - </RightDetails> - )} - {invitation && ( - <RightDetails flex={4}> - <ReviewerText>Invited to review</ReviewerText> - <ReviewerDecision - invitation={invitation} - modalKey={`reviewer-decision-${project.id}`} + {project.status && + project.status !== 'draft' && ( + <DetailsView> + <Top> + <AuthorsWithTooltip authors={project.authors} /> + </Top> + <Bottom> + <LeftDetails flex={4}> + <HandlingEditorSection + currentUser={currentUser} + isHE={isHE} project={project} /> - </RightDetails> - )} - </Bottom> - </DetailsView> - )} + </LeftDetails> + {canInviteReviewers && ( + <RightDetails flex={4}> + <ReviewerBreakdown + collectionId={project.id} + compact + versionId={version.id} + /> + <InviteReviewers + modalKey={`invite-reviewers-${project.id}`} + project={project} + version={version} + /> + </RightDetails> + )} + {invitation && ( + <RightDetails flex={4}> + <ReviewerText>Invited to review</ReviewerText> + <ReviewerDecision + invitation={invitation} + modalKey={`reviewer-decision-${project.id}`} + project={project} + version={version} + /> + </RightDetails> + )} + </Bottom> + </DetailsView> + )} </Card> ) : null } @@ -185,11 +189,11 @@ export default compose( setDisplayName('DashboardCard'), getContext({ journal: PropTypes.object, currentUser: PropTypes.object }), withTheme, - connect((state, { project }) => ({ - isHE: currentUserIs(state, 'handlingEditor'), - invitation: selectInvitation(state, project.id), + connect((state, { project, version }) => ({ canMakeDecision: canMakeDecision(state, project), + isHE: isHEToManuscript(state, get(project, 'id')), canInviteReviewers: canInviteReviewers(state, project), + invitation: selectInvitation(state, get(version, 'id')), canMakeRecommendation: canMakeRecommendation(state, project), })), )(DashboardCard) diff --git a/packages/components-faraday/src/components/Dashboard/HandlingEditorSection.js b/packages/components-faraday/src/components/Dashboard/HandlingEditorSection.js index b48d96eb07e3fecd249ab622ef560a4e4bce97a5..a77e2e32f7d1d83971d4b8268dc5323aefd27161 100644 --- a/packages/components-faraday/src/components/Dashboard/HandlingEditorSection.js +++ b/packages/components-faraday/src/components/Dashboard/HandlingEditorSection.js @@ -4,11 +4,11 @@ import { th } from '@pubsweet/ui' import styled, { css } from 'styled-components' import { EditorInChiefActions, HandlingEditorActions } from './' -const renderHE = (currentUser, project) => { +const renderHE = (currentUser, isHE, project) => { const status = get(project, 'status') || 'draft' const isAdmin = get(currentUser, 'admin') const isEic = get(currentUser, 'editorInChief') - const isHe = get(currentUser, 'handlingEditor') + const handlingEditor = get(project, 'handlingEditor') const eicActionsStatuses = ['submitted', 'heInvited'] const heActionsStatuses = ['heInvited'] @@ -22,7 +22,7 @@ const renderHE = (currentUser, project) => { ) } - if (isHe && heActionsStatuses.includes(status)) { + if (isHE && heActionsStatuses.includes(status)) { return ( <HandlingEditorActions currentUser={currentUser} @@ -35,10 +35,10 @@ const renderHE = (currentUser, project) => { return <AssignedHE>{get(handlingEditor, 'name') || 'N/A'}</AssignedHE> } -const HandlingEditorSection = ({ currentUser, project }) => ( +const HandlingEditorSection = ({ isHE, currentUser, project }) => ( <Root> <HEText>Handling Editor</HEText> - {renderHE(currentUser, project)} + {renderHE(currentUser, isHE, project)} </Root> ) diff --git a/packages/components-faraday/src/components/Dashboard/ReviewerDecision.js b/packages/components-faraday/src/components/Dashboard/ReviewerDecision.js index 7037dbebfdfebb3884315b8f9fb030920100eb61..08bfbff9dc1dbd96821f8b40eb6e881604117aea 100644 --- a/packages/components-faraday/src/components/Dashboard/ReviewerDecision.js +++ b/packages/components-faraday/src/components/Dashboard/ReviewerDecision.js @@ -30,20 +30,23 @@ const ModalComponent = connect(state => ({ export default compose( connect(null, { reviewerDecision, + getFragments: actions.getFragments, getCollections: actions.getCollections, }), withModal(props => ({ modalComponent: ModalComponent, })), withHandlers({ - decisionSuccess: ({ getCollections, hideModal }) => () => { + decisionSuccess: ({ getFragments, getCollections, hideModal }) => () => { getCollections() + getFragments() hideModal() }, }), withHandlers({ showAcceptModal: ({ project, + version, showModal, invitation, setModalError, @@ -54,7 +57,7 @@ export default compose( title: 'Agree to review Manuscript?', confirmText: 'Agree', onConfirm: () => { - reviewerDecision(invitation.id, project.id, true).then( + reviewerDecision(invitation.id, project.id, version.id, true).then( decisionSuccess, handleError(setModalError), ) @@ -63,6 +66,7 @@ export default compose( }, showDeclineModal: ({ project, + version, showModal, invitation, setModalError, @@ -73,7 +77,7 @@ export default compose( title: 'Decline to review Manuscript?', confirmText: 'Decline', onConfirm: () => { - reviewerDecision(invitation.id, project.id, false).then( + reviewerDecision(invitation.id, project.id, version.id, false).then( decisionSuccess, handleError(setModalError), ) diff --git a/packages/components-faraday/src/components/Files/Files.js b/packages/components-faraday/src/components/Files/Files.js index 4a8d2ff824f0276f29527a2e9e94d8c94a2fcbe1..9ce14c3240d6c3b78edd4f6f13e9ebbdbc8527cd 100644 --- a/packages/components-faraday/src/components/Files/Files.js +++ b/packages/components-faraday/src/components/Files/Files.js @@ -65,13 +65,25 @@ const Files = ({ changeList={changeList} dropSortableFile={dropSortableFile} files={get(files, 'coverLetter') || []} - isLast listId="coverLetter" maxFiles={1} moveItem={moveItem('coverLetter')} removeFile={removeFile('coverLetter')} title="Cover letter" /> + <FileSection + addFile={addFile('coverLetter')} + allowedFileExtensions={['pdf', 'doc', 'docx']} + changeList={changeList} + dropSortableFile={dropSortableFile} + files={get(files, 'response') || []} + isLast + listId="responseToReviewer" + maxFiles={1} + moveItem={moveItem('response')} + removeFile={removeFile('response')} + title="Response to reviewers" + /> </div> ) diff --git a/packages/components-faraday/src/components/Invitations/ReviewerBreakdown.js b/packages/components-faraday/src/components/Invitations/ReviewerBreakdown.js index 6ced230390ad0b30dc5de6af1d9ff6baf53ef5be..d67820dc3764e31d5e71fd0ce0275185d662427d 100644 --- a/packages/components-faraday/src/components/Invitations/ReviewerBreakdown.js +++ b/packages/components-faraday/src/components/Invitations/ReviewerBreakdown.js @@ -31,7 +31,7 @@ export default compose( collection: selectCollection(state, collectionId), })), withHandlers({ - getCompactReport: ({ collection: { invitations = [] } }) => () => { + getCompactReport: ({ fragment: { invitations = [] } }) => () => { const reviewerInvitations = invitations.filter(roleFilter('reviewer')) const accepted = reviewerInvitations.filter(acceptedInvitationFilter) .length diff --git a/packages/components-faraday/src/components/MakeDecision/DecisionForm.js b/packages/components-faraday/src/components/MakeDecision/DecisionForm.js index fe8f3f38b9d92fd7d7b43bc282aa31509a469a7c..0a53c9423317ac06571ebf6470b6ee16b957fb0f 100644 --- a/packages/components-faraday/src/components/MakeDecision/DecisionForm.js +++ b/packages/components-faraday/src/components/MakeDecision/DecisionForm.js @@ -1,24 +1,19 @@ import React from 'react' import { get } from 'lodash' import { connect } from 'react-redux' +import styled from 'styled-components' import { actions } from 'pubsweet-client' import { required } from 'xpub-validators' -import styled from 'styled-components' import { reduxForm, formValueSelector } from 'redux-form' import { compose, setDisplayName, withProps } from 'recompose' -import { Icon, Button, Spinner, RadioGroup, ValidatedField } from '@pubsweet/ui' +import { Icon, Button, RadioGroup, ValidatedField } from '@pubsweet/ui' import { FormItems } from '../UIComponents' -import { - selectError, - selectFetching, - createRecommendation, -} from '../../redux/recommendations' +import { createRecommendation } from '../../redux/recommendations' import { subtitleParser, decisions, parseFormValues } from './utils' import { getHERecommendation } from '../../../../component-faraday-selectors' const { - Err, Row, Title, Label, @@ -35,9 +30,7 @@ const DecisionForm = ({ aHERec, decision, hideModal, - isFetching, handleSubmit, - recommendationError, heRecommendation: { reason, message = '' }, }) => ( <Form onSubmit={handleSubmit}> @@ -90,25 +83,14 @@ const DecisionForm = ({ </RowItem> </Row> )} - {recommendationError && ( - <Row> - <RowItem centered> - <Err>{recommendationError}</Err> - </RowItem> - </Row> - )} <Row> <RowItem centered> <Button onClick={hideModal}>Cancel</Button> </RowItem> <RowItem centered> - {isFetching ? ( - <Spinner size={3} /> - ) : ( - <Button primary type="submit"> - Submit - </Button> - )} + <Button primary type="submit"> + Submit + </Button> </RowItem> </Row> </Form> @@ -119,12 +101,14 @@ export default compose( setDisplayName('DecisionForm'), connect( (state, { fragmentId, collectionId }) => ({ - isFetching: selectFetching(state), decision: selector(state, 'decision'), - recommendationError: selectError(state), heRecommendation: getHERecommendation(state, collectionId, fragmentId), }), - { createRecommendation, getCollections: actions.getCollections }, + { + createRecommendation, + getFragments: actions.getFragments, + getCollections: actions.getCollections, + }, ), withProps(({ heRecommendation: { recommendation = '', comments = [] } }) => ({ heRecommendation: { @@ -142,21 +126,25 @@ export default compose( hideModal, fragmentId, collectionId, + getFragments, getCollections, createRecommendation, }, ) => { const recommendation = parseFormValues(values) - createRecommendation(collectionId, fragmentId, recommendation).then(r => { - showModal({ - onCancel: () => { - getCollections() - hideModal() - }, - title: 'Decision submitted', - cancelText: 'OK', - }) - }) + createRecommendation(collectionId, fragmentId, recommendation).then( + () => { + showModal({ + onCancel: () => { + getCollections() + getFragments() + hideModal() + }, + title: 'Decision submitted', + cancelText: 'OK', + }) + }, + ) }, }), )(DecisionForm) diff --git a/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js b/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js index 3ef84572bbad69a7e64ecb89dd16d60586f5f8dc..fb7452baf0ba87d8e6d3edb530511df05a9a4bfa 100644 --- a/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js +++ b/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js @@ -9,11 +9,7 @@ import { getFormValues, reset as resetForm } from 'redux-form' import { FormItems } from '../UIComponents' import { StepOne, StepTwo, utils } from './' -import { - selectError, - selectFetching, - createRecommendation, -} from '../../redux/recommendations' +import { createRecommendation } from '../../redux/recommendations' const RecommendWizard = ({ step, @@ -22,8 +18,6 @@ const RecommendWizard = ({ prevStep, closeModal, submitForm, - isFetching, - recommendationError, ...rest }) => ( <FormItems.RootContainer> @@ -39,13 +33,7 @@ const RecommendWizard = ({ /> )} {step === 1 && ( - <StepTwo - decision={decision} - goBack={prevStep} - isFetching={isFetching} - onSubmit={submitForm} - recommendationError={recommendationError} - /> + <StepTwo decision={decision} goBack={prevStep} onSubmit={submitForm} /> )} </FormItems.RootContainer> ) @@ -53,13 +41,12 @@ const RecommendWizard = ({ export default compose( connect( state => ({ - isFetching: selectFetching(state), - recommendationError: selectError(state), decision: get(getFormValues('recommendation')(state), 'decision'), }), { resetForm, createRecommendation, + getFragments: actions.getFragments, getCollections: actions.getCollections, }, ), @@ -77,6 +64,7 @@ export default compose( resetForm, fragmentId, collectionId, + getFragments, getCollections, createRecommendation, }) => values => { @@ -88,6 +76,7 @@ export default compose( cancelText: 'OK', onCancel: () => { getCollections() + getFragments() hideModal() }, }) diff --git a/packages/components-faraday/src/components/Reviewers/InviteReviewers.js b/packages/components-faraday/src/components/Reviewers/InviteReviewers.js index b17b7d65b6bcd38bd26baeeff50e58ad583795e6..b6087f87534c4a756da957e77144c91b7d026286 100644 --- a/packages/components-faraday/src/components/Reviewers/InviteReviewers.js +++ b/packages/components-faraday/src/components/Reviewers/InviteReviewers.js @@ -36,11 +36,12 @@ const InviteReviewersModal = compose( ), withHandlers({ getReviewers: ({ + versionId, collectionId, setReviewers, getCollectionReviewers, }) => () => { - getCollectionReviewers(collectionId) + getCollectionReviewers(collectionId, versionId) }, closeModal: ({ getCollections, hideModal }) => () => { getCollections() @@ -81,6 +82,7 @@ const InviteReviewersModal = compose( isFetching={fetchingInvite} reviewerError={reviewerError} reviewers={reviewers} + versionId={versionId} /> <Row> diff --git a/packages/components-faraday/src/components/Reviewers/ReviewerForm.js b/packages/components-faraday/src/components/Reviewers/ReviewerForm.js index 0a26dbfc5feb7a55a1fd1c4572af780ea6912a56..0c24f66472c8abf9fb6e049472776bb3966699e9 100644 --- a/packages/components-faraday/src/components/Reviewers/ReviewerForm.js +++ b/packages/components-faraday/src/components/Reviewers/ReviewerForm.js @@ -60,7 +60,7 @@ export default compose( onSubmit: ( values, dispatch, - { inviteReviewer, collectionId, getReviewers, reset }, + { inviteReviewer, collectionId, versionId, getReviewers, reset }, ) => { const reviewerData = pick(values, [ 'email', @@ -68,7 +68,7 @@ export default compose( 'firstName', 'affiliation', ]) - inviteReviewer(reviewerData, collectionId).then(() => { + inviteReviewer(reviewerData, collectionId, versionId).then(() => { reset() getReviewers() }) diff --git a/packages/components-faraday/src/components/Reviewers/ReviewerList.js b/packages/components-faraday/src/components/Reviewers/ReviewerList.js index 5acbf18c452cd2c26371dec8d12fc88f566f7df0..4290a947cf9b6a4146a811262976a03f732f9ade 100644 --- a/packages/components-faraday/src/components/Reviewers/ReviewerList.js +++ b/packages/components-faraday/src/components/Reviewers/ReviewerList.js @@ -93,6 +93,7 @@ export default compose( withHandlers({ showConfirmResend: ({ showModal, + versionId, collectionId, inviteReviewer, goBackToReviewers, @@ -104,6 +105,7 @@ export default compose( inviteReviewer( pick(reviewer, ['email', 'firstName', 'lastName', 'affiliation']), collectionId, + versionId, ).then(goBackToReviewers, goBackToReviewers) }, onCancel: goBackToReviewers, @@ -112,6 +114,7 @@ export default compose( showConfirmRevoke: ({ showModal, hideModal, + versionId, collectionId, revokeReviewer, goBackToReviewers, @@ -120,7 +123,7 @@ export default compose( title: 'Unassign Reviewer', confirmText: 'Unassign', onConfirm: () => { - revokeReviewer(invitationId, collectionId).then( + revokeReviewer(invitationId, collectionId, versionId).then( goBackToReviewers, goBackToReviewers, ) diff --git a/packages/components-faraday/src/index.js b/packages/components-faraday/src/index.js index 0eb9c3b474d2da621536bec68cbe2541141b9544..b40accf6eaee709b192b849b8ab1290c47363428 100644 --- a/packages/components-faraday/src/index.js +++ b/packages/components-faraday/src/index.js @@ -7,7 +7,6 @@ module.exports = { editors: () => require('./redux/editors').default, files: () => require('./redux/files').default, reviewers: () => require('./redux/reviewers').default, - recommendations: () => require('./redux/recommendations').default, }, }, } diff --git a/packages/components-faraday/src/redux/authors.js b/packages/components-faraday/src/redux/authors.js index a348f22be32e767f5f356bbae543fc887082dd5d..324cc26adabc74a3ecc2c4d38111903757c27ab3 100644 --- a/packages/components-faraday/src/redux/authors.js +++ b/packages/components-faraday/src/redux/authors.js @@ -1,10 +1,7 @@ -import { get, head } from 'lodash' -import { - create, - get as apiGet, - remove, - update, -} from 'pubsweet-client/src/helpers/api' +import { get } from 'lodash' +import { create, remove, get as apiGet } from 'pubsweet-client/src/helpers/api' + +import { handleError } from './utils' // constants const REQUEST = 'authors/REQUEST' @@ -25,33 +22,29 @@ export const authorSuccess = () => ({ type: SUCCESS, }) -export const addAuthor = (author, collectionId) => dispatch => { +export const getAuthors = (collectionId, fragmentId) => + apiGet(`/collections/${collectionId}/fragments/${fragmentId}/users`) + +export const addAuthor = (author, collectionId, fragmentId) => dispatch => { dispatch(authorRequest()) - return create(`/collections/${collectionId}/users`, { + return create(`/collections/${collectionId}/fragments/${fragmentId}/users`, { email: author.email, role: 'author', ...author, - }) + }).then(author => { + dispatch(authorSuccess()) + return author + }, handleError(authorFailure, dispatch)) } -export const deleteAuthor = (collectionId, userId) => dispatch => { +export const deleteAuthor = (collectionId, fragmentId, userId) => dispatch => { dispatch(authorRequest()) - return remove(`/collections/${collectionId}/users/${userId}`) -} - -export const editAuthor = (collectionId, userId, body) => - update(`/collections/${collectionId}/users/${userId}`, body) - -export const getAuthors = collectionId => - apiGet(`/collections/${collectionId}/users`) - -export const getAuthorsTeam = collectionId => - apiGet(`/teams?object.id=${collectionId}&group=author`).then(teams => - head(teams), + return remove( + `/collections/${collectionId}/fragments/${fragmentId}/users/${userId}`, ) - -export const updateAuthorsTeam = (teamId, body) => - update(`/teams/${teamId}`, body) + .then(() => dispatch(authorSuccess())) + .catch(handleError(authorFailure, dispatch)) +} // selectors export const getFragmentAuthors = (state, fragmentId) => diff --git a/packages/components-faraday/src/redux/recommendations.js b/packages/components-faraday/src/redux/recommendations.js index 3314f901decb5254b8c5d943163c7b3dbd3517d7..4a3e1f70ff1efd7ef18b9fb4e1191a3e503a5361 100644 --- a/packages/components-faraday/src/redux/recommendations.js +++ b/packages/components-faraday/src/redux/recommendations.js @@ -1,154 +1,36 @@ import { get } from 'lodash' import { create, update } from 'pubsweet-client/src/helpers/api' -// #region Constants -const REQUEST = 'recommendations/REQUEST' -const ERROR = 'recommendations/ERROR' - -const GET_FRAGMENT_SUCCESS = 'GET_FRAGMENT_SUCCESS' -const GET_RECOMMENDATIONS_SUCCESS = 'recommendations/GET_SUCCESS' -const CREATE_RECOMMENDATION_SUCCESS = 'recommendations/CREATE_SUCCESS' -const UPDATE_RECOMMENDATION_SUCCESS = 'recommendations/UPDATE_SUCCESS' -// #endregion - -// #region Action Creators -export const recommendationsRequest = () => ({ - type: REQUEST, -}) - -export const recommendationsError = error => ({ - type: ERROR, - error, -}) - -export const getRecommendationsSuccess = recommendation => ({ - type: GET_RECOMMENDATIONS_SUCCESS, - payload: { recommendation }, -}) - -export const createRecommendationSuccess = recommendation => ({ - type: CREATE_RECOMMENDATION_SUCCESS, - payload: { recommendation }, -}) - -export const updateRecommendationSuccess = recommendation => ({ - type: UPDATE_RECOMMENDATION_SUCCESS, - payload: { recommendation }, -}) -// #endregion - // #region Selectors -export const selectFetching = state => - get(state, 'recommendations.fetching') || false -export const selectError = state => get(state, 'recommendations.error') -export const selectRecommendations = state => - get(state, 'recommendations.recommendations') || [] -export const selectEditorialRecommendations = state => - selectRecommendations(state).filter( +export const selectRecommendations = (state, fragmentId) => + get(state, `fragments.${fragmentId}.recommendations`) || [] +export const selectEditorialRecommendations = (state, fragmentId) => + selectRecommendations(state, fragmentId).filter( r => r.recommendationType === 'editorRecommendation' && r.comments, ) // #endregion // #region Actions +// error handling and fetching is handled by the autosave reducer export const createRecommendation = ( collId, fragId, recommendation, -) => dispatch => { - dispatch(recommendationsRequest()) - return create( +) => dispatch => + create( `/collections/${collId}/fragments/${fragId}/recommendations`, recommendation, - ).then( - r => { - dispatch(getRecommendationsSuccess(r)) - return r - }, - err => { - const error = get(err, 'response') - if (error) { - const errorMessage = get(JSON.parse(error), 'error') - dispatch(recommendationsError(errorMessage)) - } - throw err - }, ) -} export const updateRecommendation = ( collId, fragId, recommendation, -) => dispatch => { - dispatch(recommendationsRequest()) - return update( +) => dispatch => + update( `/collections/${collId}/fragments/${fragId}/recommendations/${ recommendation.id }`, recommendation, - ).then( - r => { - dispatch(getRecommendationsSuccess(r)) - return r - }, - err => { - const error = get(err, 'response') - if (error) { - const errorMessage = get(JSON.parse(error), 'error') - dispatch(recommendationsError(errorMessage)) - } - throw err - }, ) -} -// #endregion - -// #region State -const initialState = { - fetching: false, - error: null, - recommendations: [], -} - -export default (state = initialState, action = {}) => { - switch (action.type) { - case REQUEST: - return { - ...state, - fetching: true, - } - case ERROR: - return { - ...state, - fetching: false, - error: action.error, - } - case GET_FRAGMENT_SUCCESS: - return { - ...state, - fetching: false, - error: null, - recommendations: get(action, 'fragment.recommendations'), - } - case GET_RECOMMENDATIONS_SUCCESS: - return { - ...state, - fetching: false, - error: null, - recommendations: state.recommendations - ? [...state.recommendations, action.payload.recommendation] - : action.payload.recommendation, - } - case UPDATE_RECOMMENDATION_SUCCESS: - case CREATE_RECOMMENDATION_SUCCESS: - return { - ...state, - fetching: false, - error: null, - recommendations: [action.payload.recommendation], - } - default: - return state - } -} // #endregion diff --git a/packages/components-faraday/src/redux/reviewers.js b/packages/components-faraday/src/redux/reviewers.js index e21dae2a3fa17e369ffcae12f83dbc01b7b8c7d0..acc31e86480a3d6b826d89d66a4f9d3302db7b43 100644 --- a/packages/components-faraday/src/redux/reviewers.js +++ b/packages/components-faraday/src/redux/reviewers.js @@ -81,19 +81,17 @@ export const selectFetchingInvite = state => export const selectFetchingDecision = state => get(state, 'reviewers.fetching.decision') || false -export const selectInvitation = (state, collectionId) => { +export const selectInvitation = (state, fragmentId) => { const currentUser = selectCurrentUser(state) - const collection = state.collections.find(c => c.id === collectionId) - const invitations = get(collection, 'invitations') || [] + const invitations = get(state, `fragments.${fragmentId}.invitations`) || [] return invitations.find( i => i.userId === currentUser.id && i.role === 'reviewer' && !i.hasAnswer, ) } -export const currentUserIsReviewer = (state, collectionId) => { +export const currentUserIsReviewer = (state, fragmentId) => { const currentUser = selectCurrentUser(state) - const collection = state.collections.find(c => c.id === collectionId) - const invitations = get(collection, 'invitations') || [] + const invitations = get(state, `fragments.${fragmentId}.invitations`) || [] return !!invitations.find( i => i.userId === currentUser.id && @@ -103,22 +101,37 @@ export const currentUserIsReviewer = (state, collectionId) => { ) } -export const getCollectionReviewers = collectionId => dispatch => { +export const getCollectionReviewers = ( + collectionId, + fragmentId, +) => dispatch => { dispatch(getReviewersRequest()) - return apiGet(`/collections/${collectionId}/invitations?role=reviewer`).then( + return apiGet( + `/collections/${collectionId}/fragments/${fragmentId}/invitations?role=reviewer`, + ).then( r => dispatch(getReviewersSuccess(orderBy(r, orderReviewers))), - err => dispatch(getReviewersError(err)), + err => { + dispatch(getReviewersError(err)) + throw err + }, ) } // #endregion // #region Actions - invitations -export const inviteReviewer = (reviewerData, collectionId) => dispatch => { +export const inviteReviewer = ( + reviewerData, + collectionId, + fragmentId, +) => dispatch => { dispatch(inviteRequest()) - return create(`/collections/${collectionId}/invitations`, { - ...reviewerData, - role: 'reviewer', - }).then( + return create( + `/collections/${collectionId}/fragments/${fragmentId}/invitations`, + { + ...reviewerData, + role: 'reviewer', + }, + ).then( () => dispatch(inviteSuccess()), err => { dispatch(inviteError(get(JSON.parse(err.response), 'error'))) @@ -135,10 +148,14 @@ export const setReviewerPassword = reviewerBody => dispatch => { }) } -export const revokeReviewer = (invitationId, collectionId) => dispatch => { +export const revokeReviewer = ( + invitationId, + collectionId, + fragmentId, +) => dispatch => { dispatch(inviteRequest()) return remove( - `/collections/${collectionId}/invitations/${invitationId}`, + `/collections/${collectionId}/fragments/${fragmentId}/invitations/${invitationId}`, ).then( () => dispatch(inviteSuccess()), err => { @@ -153,12 +170,16 @@ export const revokeReviewer = (invitationId, collectionId) => dispatch => { export const reviewerDecision = ( invitationId, collectionId, + fragmentId, agree = true, ) => dispatch => { dispatch(reviewerDecisionRequest()) - return update(`/collections/${collectionId}/invitations/${invitationId}`, { - isAccepted: agree, - }).then( + return update( + `/collections/${collectionId}/fragments/${fragmentId}/invitations/${invitationId}`, + { + isAccepted: agree, + }, + ).then( res => { dispatch(reviewerDecisionSuccess()) return res diff --git a/packages/components-faraday/src/redux/utils.js b/packages/components-faraday/src/redux/utils.js index 14560f9260f6bfeec3f768e7a47b2c2147c1d111..a84e7328c7de7431436d9cc8fc10bf6551e0f119 100644 --- a/packages/components-faraday/src/redux/utils.js +++ b/packages/components-faraday/src/redux/utils.js @@ -9,3 +9,12 @@ export const orderReviewers = r => { return 1 } } + +export const handleError = (fn, dispatch = null) => err => { + if (typeof dispatch === 'function') { + dispatch(fn(err)) + } else { + fn(err) + } + throw err +} diff --git a/packages/xpub-faraday/app/config/journal/submit-wizard.js b/packages/xpub-faraday/app/config/journal/submit-wizard.js index 1dd84548c68bbf86c623db2e2fa95bb7da29cf92..a7c54767357c09f25fe2d67e4e7084471749ad02 100644 --- a/packages/xpub-faraday/app/config/journal/submit-wizard.js +++ b/packages/xpub-faraday/app/config/journal/submit-wizard.js @@ -10,10 +10,10 @@ import { declarations } from './' import issueTypes from './issues-types' import manuscriptTypes from './manuscript-types' import { - requiredBasedOnType, - editModeEnabled, - parseEmptyHtml, requiredFiles, + parseEmptyHtml, + editModeEnabled, + requiredBasedOnType, } from './wizard-validators' const min3Chars = minChars(3) @@ -51,7 +51,13 @@ const uploadFile = input => uploadFileFn(input) export default { showProgress: true, - formSectionKeys: ['metadata', 'declarations', 'conflicts', 'files'], + formSectionKeys: [ + 'authors', + 'metadata', + 'declarations', + 'conflicts', + 'files', + ], submissionRedirect: '/confirmation-page', dispatchFunctions: [uploadFile], steps: [ @@ -147,7 +153,7 @@ export default { validate: [required], }, { - fieldId: 'editMode', + fieldId: 'authorForm', renderComponent: Spacing, validate: [editModeEnabled], }, diff --git a/packages/xpub-faraday/app/config/journal/wizard-validators.js b/packages/xpub-faraday/app/config/journal/wizard-validators.js index bec3371d1699e67c3ef81688f26205bee7328b46..25d58d4372c51981864e9e7822e81b219ac9123e 100644 --- a/packages/xpub-faraday/app/config/journal/wizard-validators.js +++ b/packages/xpub-faraday/app/config/journal/wizard-validators.js @@ -23,7 +23,7 @@ export const requiredBasedOnType = (value, formValues) => { return undefined } -export const editModeEnabled = value => { +export const editModeEnabled = (value, allValues) => { if (value) { return 'You have some unsaved author details.' } diff --git a/packages/xpub-faraday/config/authsome-helpers.js b/packages/xpub-faraday/config/authsome-helpers.js index 1b7642bfca5445c4c1651d8cbfcca8aad45ef8c5..ad75355dd14fd8f26a70e2f75bb2f8c74a87054d 100644 --- a/packages/xpub-faraday/config/authsome-helpers.js +++ b/packages/xpub-faraday/config/authsome-helpers.js @@ -1,6 +1,6 @@ -const omit = require('lodash/omit') +const { omit, get, last } = require('lodash') + const config = require('config') -const get = require('lodash/get') const statuses = config.get('statuses') @@ -44,7 +44,10 @@ const filterObjectData = ( rec => rec.userId === user.id, ) } - + parseAuthorsData(object, matchingCollPerm) + if (['reviewer', 'handlingEditor'].includes(matchingCollPerm.permission)) { + return filterRefusedInvitations(object, user) + } return object } const matchingCollPerm = collectionsPermissions.find( @@ -52,16 +55,16 @@ const filterObjectData = ( ) if (matchingCollPerm === undefined) return null setPublicStatuses(object, matchingCollPerm) - parseAuthorsData(object, matchingCollPerm) - if (['reviewer', 'handlingEditor'].includes(matchingCollPerm.permission)) { - return filterRefusedInvitations(object, user) - } return object } -const getTeamsByPermissions = async (teamIds = [], permissions, TeamModel) => { - const teams = await Promise.all( +const getTeamsByPermissions = async ( + teamIds = [], + permissions = [], + TeamModel, +) => + (await Promise.all( teamIds.map(async teamId => { const team = await TeamModel.find(teamId) if (!permissions.includes(team.teamType.permissions)) { @@ -69,15 +72,69 @@ const getTeamsByPermissions = async (teamIds = [], permissions, TeamModel) => { } return team }), + )).filter(Boolean) + +const heIsInvitedToFragment = async ({ user, Team, collectionId }) => + (await getTeamsByPermissions(user.teams, ['handlingEditor'], Team)).some( + // user is a member of the team with access to the fragment's parent collection + t => t.members.includes(user.id) && t.object.id === collectionId, ) - return teams.filter(Boolean) +const getUserPermissions = async ({ + user, + Team, + mapFn = t => ({ + objectId: t.object.id, + objectType: t.object.type, + role: t.teamType.permissions, + }), +}) => + (await Promise.all(user.teams.map(teamId => Team.find(teamId)))).map(mapFn) + +const isOwner = ({ user: { id }, object }) => { + if (object.owners.includes(id)) return true + return !!object.owners.find(own => own.id === id) +} + +const hasPermissionForObject = async ({ user, object, Team, roles = [] }) => { + const userPermissions = await getUserPermissions({ + user, + Team, + }) + + return !!userPermissions.find(p => { + const hasObject = + p.objectId === get(object, 'fragment.id') || + p.objectId === get(object, 'fragment.collectionId') + if (roles.length > 0) { + return hasObject && roles.includes(p.role) + } + return hasObject + }) +} + +const isHandlingEditor = ({ user, object }) => + get(object, 'collection.handlingEditor.id') === user.id + +const isInDraft = fragment => !get(fragment, 'submitted') + +const hasFragmentInDraft = async ({ object, Fragment }) => { + const lastFragmentId = last(get(object, 'fragments')) + const fragment = await Fragment.find(lastFragmentId) + return isInDraft(fragment) } module.exports = { + filterObjectData, parseAuthorsData, setPublicStatuses, - filterRefusedInvitations, - filterObjectData, getTeamsByPermissions, + filterRefusedInvitations, + isOwner, + isHandlingEditor, + getUserPermissions, + heIsInvitedToFragment, + hasPermissionForObject, + isInDraft, + hasFragmentInDraft, } diff --git a/packages/xpub-faraday/config/authsome-mode.js b/packages/xpub-faraday/config/authsome-mode.js index 948dd93e25476745ef93e4db143ff99f79ff8cec..65787b60bf24b3158cc86e51d4613e388766f67a 100644 --- a/packages/xpub-faraday/config/authsome-mode.js +++ b/packages/xpub-faraday/config/authsome-mode.js @@ -1,62 +1,8 @@ -const get = require('lodash/get') -const pickBy = require('lodash/pickBy') -const omit = require('lodash/omit') -const helpers = require('./authsome-helpers') - -async function teamPermissions(user, operation, object, context) { - const permissions = ['handlingEditor', 'author', 'reviewer'] - const teams = await helpers.getTeamsByPermissions( - user.teams, - permissions, - context.models.Team, - ) - - let collectionsPermissions = await Promise.all( - teams.map(async team => { - const collection = await context.models.Collection.find(team.object.id) - if ( - collection.status === 'rejected' && - team.teamType.permissions === 'reviewer' - ) - return null - const collPerm = { - id: collection.id, - permission: team.teamType.permissions, - } - const objectType = get(object, 'type') - if (objectType === 'fragment') { - if (collection.fragments.includes(object.id)) - collPerm.fragmentId = object.id - else return null - } +const config = require('config') +const { get, pickBy, omit } = require('lodash') - if (objectType === 'collection') - if (object.id !== collection.id) return null - return collPerm - }), - ) - collectionsPermissions = collectionsPermissions.filter(cp => cp !== null) - if (collectionsPermissions.length === 0) return false - - return { - filter: filterParam => { - if (!filterParam.length) { - return helpers.filterObjectData( - collectionsPermissions, - filterParam, - user, - ) - } - - const collections = filterParam - .map(coll => - helpers.filterObjectData(collectionsPermissions, coll, user), - ) - .filter(Boolean) - return collections - }, - } -} +const statuses = config.get('statuses') +const helpers = require('./authsome-helpers') function unauthenticatedUser(operation, object) { // Public/unauthenticated users can GET /collections, filtered by 'published' @@ -104,131 +50,219 @@ function unauthenticatedUser(operation, object) { return false } +const publicStatusesPermissions = ['author', 'reviewer'] +const createPaths = ['/collections', '/collections/:collectionId/fragments'] + async function authenticatedUser(user, operation, object, context) { - // Allow the authenticated user to POST a collection (but not with a 'filtered' property) - if (operation === 'POST' && object.path === '/collections') { - return { - filter: collection => omit(collection, 'filtered'), + if (operation === 'GET') { + if (get(object, 'path') === '/collections') { + return { + filter: async collections => { + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + return collections.filter(collection => { + if (collection.owners.includes(user.id)) { + return true + } + const collectionPermission = userPermissions.find( + p => p.objectId === collection.id, + ) + if (collectionPermission) { + return true + } + + const fragmentPermission = userPermissions.find(p => + collection.fragments.includes(p.objectId), + ) + if (fragmentPermission) { + return true + } + return false + }) + }, + } } - } - if ( - operation === 'POST' && - object.path === '/collections/:collectionId/fragments' - ) { - return true - } + if (object === '/users') { + return true + } - // allow authenticate owners full pass for a collection - if (get(object, 'type') === 'collection') { - if (operation === 'PATCH') { + if (get(object, 'type') === 'collection') { + if (helpers.isOwner({ user, object })) { + if ( + await helpers.hasFragmentInDraft({ + object, + Fragment: context.models.Fragment, + }) + ) { + return { + filter: collection => ({ + ...collection, + status: 'draft', + visibleStatus: statuses.draft.public, + }), + } + } + return true + } return { - filter: collection => omit(collection, 'filtered'), + filter: async collection => { + const status = get(collection, 'status') || 'draft' + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + if (collection.owners.map(o => o.id).includes(user.id)) { + return collection + } + + const collectionPermission = userPermissions.find( + p => p.objectId === collection.id, + ) + if ( + publicStatusesPermissions.includes( + get(collectionPermission, 'role'), + ) + ) { + collection.visibleStatus = statuses[status].public + } + return collection + }, } } - if (object.owners.includes(user.id)) return true - const owner = object.owners.find(own => own.id === user.id) - if (owner !== undefined) return true - } - // Allow owners of a collection to GET its teams, e.g. - // GET /api/collections/1/teams - if (operation === 'GET' && get(object, 'path') === '/teams') { - const collectionId = get(object, 'params.collectionId') - if (collectionId) { - const collection = await context.models.Collection.find(collectionId) - if (collection.owners.includes(user.id)) { + if (get(object, 'type') === 'fragment') { + if (helpers.isOwner({ user, object })) { return true } + + if (helpers.isInDraft(object)) { + return false + } + + const userPermissions = await helpers.getUserPermissions({ + user, + Team: context.models.Team, + }) + + const permission = userPermissions.find( + p => p.objectId === object.id || p.objectId === object.collectionId, + ) + + if (!permission) return false + + return { + filter: fragment => { + // handle other roles + if (permission.role === 'reviewer') { + fragment.files = omit(fragment.files, ['coverLetter']) + fragment.authors = fragment.authors.map(a => omit(a, ['email'])) + fragment.recommendations = fragment.recommendations + ? fragment.recommendations.filter(r => r.userId === user.id) + : [] + } + return fragment + }, + } } - } - if ( - operation === 'GET' && - get(object, 'type') === 'team' && - get(object, 'object.type') === 'collection' - ) { - const collection = await context.models.Collection.find( - get(object, 'object.id'), - ) - if (collection.owners.includes(user.id)) { + if (get(object, 'type') === 'user') { return true } - } - // Advanced example - // Allow authenticated users to create a team based around a collection - // if they are one of the owners of this collection - if (['POST', 'PATCH'].includes(operation) && get(object, 'type') === 'team') { - if (get(object, 'object.type') === 'collection') { - const collection = await context.models.Collection.find( - get(object, 'object.id'), - ) - if (collection.owners.includes(user.id)) { + // allow HE to get reviewer invitations + if (get(object, 'fragment.type') === 'fragment') { + const collectionId = get(object, 'fragment.collectionId') + const collection = await context.models.Collection.find(collectionId) + + if (get(collection, 'handlingEditor.id') === user.id) { return true } } - } - - // only allow the HE to create, delete an invitation, or get invitation details - if ( - ['POST', 'GET', 'DELETE'].includes(operation) && - get(object.collection, 'type') === 'collection' && - object.path.includes('invitations') - ) { - const collection = await context.models.Collection.find( - get(object.collection, 'id'), - ) - const handlingEditor = get(collection, 'handlingEditor') - if (!handlingEditor) return false - if (handlingEditor.id === user.id) return true - return false - } - // only allow a reviewer and an HE to submit and to modify a recommendation - if ( - ['POST', 'PATCH'].includes(operation) && - get(object.collection, 'type') === 'collection' && - object.path.includes('recommendations') - ) { - const collection = await context.models.Collection.find( - get(object.collection, 'id'), - ) - const teams = await helpers.getTeamsByPermissions( - user.teams, - ['reviewer', 'handlingEditor'], - context.models.Team, - ) - if (teams.length === 0) return false - const matchingTeam = teams.find(team => team.object.id === collection.id) - if (matchingTeam) return true - return false + if (get(object, 'type') === 'user') { + return true + } } - if (user.teams.length !== 0 && ['GET'].includes(operation)) { - const permissions = await teamPermissions(user, operation, object, context) + if (operation === 'POST') { + // allow everytone to create manuscripts and versions + if (createPaths.includes(object.path)) { + return true + } - if (permissions) { - return permissions + // allow HE to invite + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/invitations' + ) { + return helpers.isHandlingEditor({ user, object }) } - return false + // allow HE or assigned reviewers to recommend + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/recommendations' + ) { + return helpers.hasPermissionForObject({ + user, + object, + Team: context.models.Team, + roles: ['reviewer', 'handlingEditor'], + }) + } } - if (get(object, 'type') === 'fragment') { - const fragment = object + if (operation === 'PATCH') { + if (get(object, 'type') === 'collection') { + return helpers.isOwner({ user, object }) + } + + if (get(object, 'type') === 'fragment') { + return helpers.isOwner({ user, object }) + } + + // allow reviewer to patch his recommendation + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/recommendations/:recommendationId' + ) { + return helpers.hasPermissionForObject({ + user, + object, + Team: context.models.Team, + roles: ['reviewer'], + }) + } - if (fragment.owners.includes(user.id)) { + 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 }) + } } - // A user can GET, DELETE and PATCH itself - if (get(object, 'type') === 'user' && get(object, 'id') === user.id) { - if (['GET', 'DELETE', 'PATCH'].includes(operation)) { - return true + if (operation === 'DELETE') { + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/invitations/:invitationId' + ) { + return helpers.isHandlingEditor({ user, object }) + } + + if (get(object, 'type') === 'collection') { + return helpers.isOwner({ user, object }) } } + // If no individual permissions exist (above), fallback to unauthenticated // user's permission return unauthenticatedUser(operation, object) @@ -244,7 +278,7 @@ const authsomeMode = async (userId, operation, object, context) => { const user = await context.models.User.find(userId) // Admins and editor in chiefs can do anything - if (user && (user.admin || user.editorInChief)) return true + // if (user && (user.admin || user.editorInChief)) return true if (user) { return authenticatedUser(user, operation, object, context) diff --git a/packages/xpub-faraday/config/validations.js b/packages/xpub-faraday/config/validations.js index ec5a914c3268276d718abf8d87a78aeba7c7909d..b8d8d607061cd81a82c828686002baac69a89312 100644 --- a/packages/xpub-faraday/config/validations.js +++ b/packages/xpub-faraday/config/validations.js @@ -7,9 +7,7 @@ module.exports = { created: Joi.date(), title: Joi.string(), status: Joi.string(), - reviewers: Joi.array(), customId: Joi.string(), - authors: Joi.array(), invitations: Joi.array(), handlingEditor: Joi.object(), visibleStatus: Joi.string().allow(''), @@ -17,6 +15,7 @@ module.exports = { fragment: [ { fragmentType: Joi.valid('version').required(), + collectionId: Joi.string().required(), created: Joi.date(), version: Joi.number(), submitted: Joi.date(), @@ -73,21 +72,8 @@ module.exports = { reviewers: Joi.array(), lock: Joi.object(), decision: Joi.object(), - authors: Joi.array().items( - Joi.object({ - firstName: Joi.string().required(), - lastName: Joi.string().required(), - middleName: Joi.string().allow(''), - email: Joi.string() - .email() - .required(), - affiliation: Joi.string().required(), - country: Joi.string().allow(''), - isSubmitting: Joi.boolean(), - isCorresponding: Joi.boolean(), - id: Joi.string().uuid(), - }), - ), + authors: Joi.array(), + invitations: Joi.array(), recommendations: Joi.array().items( Joi.object({ id: Joi.string().required(), diff --git a/packages/xpub-faraday/package.json b/packages/xpub-faraday/package.json index af02408c9615982e86f1986419b55fa747fe99bd..04202a73693802c91fbd6b50deb6d3f3be345eda 100644 --- a/packages/xpub-faraday/package.json +++ b/packages/xpub-faraday/package.json @@ -36,7 +36,7 @@ "react-router-dom": "^4.2.2", "recompose": "^0.26.0", "redux": "^3.6.0", - "redux-form": "^7.0.3", + "redux-form": "7.0.3", "redux-logger": "^3.0.1", "typeface-noto-sans": "^0.0.54", "typeface-noto-serif": "^0.0.54", diff --git a/yarn.lock b/yarn.lock index 13f78b9bf62e6523df4c8851f5ebbc1984b7a6b5..b164542ac0453b3b4f320ee8df9784014cbda154 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3325,7 +3325,7 @@ es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.14: es6-iterator "~2.0.3" es6-symbol "~3.1.1" -es6-error@^4.1.1: +es6-error@^4.0.0, es6-error@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" @@ -4512,6 +4512,10 @@ hoist-non-react-statics@^2.1.0, hoist-non-react-statics@^2.3.0, hoist-non-react- version "2.5.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz#d2ca2dfc19c5a91c5a6615ce8e564ef0347e2a40" +hoist-non-react-statics@^2.2.1: + version "2.5.4" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.4.tgz#fc3b1ac05d2ae3abedec84eba846511b0d4fcc4f" + home-or-tmp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" @@ -8311,6 +8315,19 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "^0.4.2" +redux-form@7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/redux-form/-/redux-form-7.0.3.tgz#80157d01df7de6c8eb2297ad1fbbb092bafa34f5" + dependencies: + deep-equal "^1.0.1" + es6-error "^4.0.0" + hoist-non-react-statics "^2.2.1" + invariant "^2.2.2" + is-promise "^2.1.0" + lodash "^4.17.3" + lodash-es "^4.17.3" + prop-types "^15.5.9" + redux-form@^7.0.3: version "7.2.3" resolved "https://registry.yarnpkg.com/redux-form/-/redux-form-7.2.3.tgz#a01111116f386f3d88451b5528dfbb180561a8b4"