diff --git a/packages/component-email/src/helpers/Email.js b/packages/component-email/src/helpers/Email.js index 292e4a637f9c740c039e22b5531d3c797dbd5e07..02a9816e095c507e4a6fe984798ed673fb929f2e 100644 --- a/packages/component-email/src/helpers/Email.js +++ b/packages/component-email/src/helpers/Email.js @@ -3,6 +3,37 @@ const logger = require('@pubsweet/logger') const helpers = require('./helpers') module.exports = { + sendSignupEmail: async ({ dashboardUrl, res, email, UserModel }) => { + let user + try { + user = await UserModel.findByEmail(email) + } catch (e) { + const notFoundError = await helpers.handleNotFoundError(e, 'User') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } + if (!user.confirmationToken) { + return res + .status(400) + .json({ error: 'User does not have a confirmation token.' }) + } + try { + await mailService.sendSimpleEmail({ + toEmail: user.email, + user, + emailType: 'signup', + dashboardUrl, + meta: { + confirmationToken: user.confirmationToken, + }, + }) + return res.status(200).json({}) + } catch (e) { + logger.error(e) + return res.status(500).json({ error: 'Email could not be sent.' }) + } + }, setupNewUserEmail: async ({ dashboardUrl, res, email, role, UserModel }) => { let user try { @@ -13,7 +44,7 @@ module.exports = { error: notFoundError.message, }) } - if (user.passwordResetToken === undefined) { + if (!user.passwordResetToken) { return res .status(400) .json({ error: 'User does not have a password reset token.' }) diff --git a/packages/component-email/src/routes/emails/post.js b/packages/component-email/src/routes/emails/post.js index 9ff44cc054265759cfff2ba4eaa7cbf5416a8f54..33a149738109b8df694383170469a20e83bfc25e 100644 --- a/packages/component-email/src/routes/emails/post.js +++ b/packages/component-email/src/routes/emails/post.js @@ -3,21 +3,34 @@ const helpers = require('../../helpers/helpers') const emailHelper = require('../../helpers/Email') module.exports = models => async (req, res) => { - const { email, type, role } = req.body + const { email, type, role = 'author' } = req.body if (!helpers.checkForUndefinedParams(email, type, role)) { res.status(400).json({ error: 'Email and type are required.' }) logger.error('User ID and role are missing') return } - if (type !== 'invite') - return res.status(400).json({ error: `Email type ${type} is not defined.` }) + // if (type !== 'invite') + // return res.status(400).json({ error: `Email type ${type} is not defined.` }) - return emailHelper.setupNewUserEmail({ - dashboardUrl: `${req.protocol}://${req.get('host')}`, - res, - email, - role, - UserModel: models.User, - }) + if (type === 'signup') { + return emailHelper.sendSignupEmail({ + res, + email, + UserModel: models.User, + dashboardUrl: `${req.protocol}://${req.get('host')}`, + }) + } + + if (type === 'invite') { + return emailHelper.setupNewUserEmail({ + dashboardUrl: `${req.protocol}://${req.get('host')}`, + res, + email, + role, + UserModel: models.User, + }) + } + + return res.end() } diff --git a/packages/component-faraday-selectors/src/index.js b/packages/component-faraday-selectors/src/index.js index 2ad7a635be95eb0525f033a1e8de8eecafe9edb8..95f9a5b80adf044e2ed228d636c9ce8d793869be 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 @@ -71,4 +72,31 @@ export const canSeeReviewersReports = (state, collectionId) => { return isHE || isEiC } -export const canSeeEditorialComments = canSeeReviewersReports +export const canMakeRevision = (state, collection, fragment) => { + const currentUserId = get(state, 'currentUser.user.id') + return ( + collection.status === 'revisionRequested' && + fragment.owners.map(o => o.id).includes(currentUserId) + ) +} + +export const currentUserIsAuthor = (state, id) => { + const permissions = getUserPermissions(state) || [] + + return permissions + .filter(f => f.role === 'author') + .map(p => p.objectId) + .includes(id) +} + +export const getUserPermissions = ({ currentUser }) => + get(currentUser, 'user.teams').map(t => ({ + objectId: t.object.id, + objectType: t.object.type, + role: t.teamType.permissions, + })) + +export const userNotConfirmed = ({ currentUser }) => + get(currentUser, 'isAuthenticated') && + !currentUserIs({ currentUser }, 'staff') && + !get(currentUser, 'user.isConfirmed') 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-invite/src/tests/fixtures/collections.js b/packages/component-fixture-manager/src/fixtures/collections.js similarity index 60% rename from packages/component-invite/src/tests/fixtures/collections.js rename to packages/component-fixture-manager/src/fixtures/collections.js index bef6132199981a79b87ea86440146eaab5891883..73aab63ed1139cc27ae14df932c9c49ce855346e 100644 --- a/packages/component-invite/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 { fragment } = require('./fragments') +const { user, handlingEditor, answerHE } = require('./userData') +const { fragment, newVersion } = 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], + fragments: [fragment.id, newVersion.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..10fcf908ed077f7ecda22e87cb745af8b0a539f1 --- /dev/null +++ b/packages/component-fixture-manager/src/fixtures/fragments.js @@ -0,0 +1,179 @@ +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: [ + { + email: chance.email(), + 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], + type: 'fragment', + }, + newVersion: { + id: chance.guid(), + collectionId: standardCollID, + metadata: { + title: chance.sentence(), + abstract: chance.paragraph(), + }, + authors: [ + { + email: chance.email(), + 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], + type: 'fragment', + }, + noParentFragment: { + id: chance.guid(), + collectionId: '', + metadata: { + title: chance.sentence(), + abstract: chance.paragraph(), + }, + authors: [ + { + email: chance.email(), + id: submittingAuthor.id, + isSubmitting: true, + isCorresponding: false, + }, + ], + save: jest.fn(() => fragments.fragment), + owners: [user.id], + type: 'fragment', + }, +} + +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 63% rename from packages/component-invite/src/tests/fixtures/users.js rename to packages/component-fixture-manager/src/fixtures/users.js index c5f0a5e41f8bca54d62c6534ef263a6de77c99bc..5d8872ba8cd34c9c9b63aec724df9b38a5dfec29 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,9 @@ const users = { title: 'Mr', save: jest.fn(() => users.user), isConfirmed: false, + updateProperties: jest.fn(() => users.user), + teams: [], + confirmationToken: chance.hash(), }, author: { type: 'user', @@ -79,6 +100,10 @@ const users = { title: 'Mr', save: jest.fn(() => users.author), isConfirmed: true, + passwordResetToken: chance.hash(), + passwordResetTimestamp: Date.now(), + teams: [authorTeamID], + confirmationToken: chance.hash(), }, reviewer: { type: 'user', @@ -112,6 +137,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/.gitignore b/packages/component-helper-service/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..3614a810088d89d9ccaa28d82401545634874a18 --- /dev/null +++ b/packages/component-helper-service/.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-helper-service/README.md b/packages/component-helper-service/README.md new file mode 100644 index 0000000000000000000000000000000000000000..a0ac7afbf2c8255c8a94f4f2ff525dd245cc3c93 --- /dev/null +++ b/packages/component-helper-service/README.md @@ -0,0 +1,2 @@ +# Helper Service + diff --git a/packages/component-helper-service/index.js b/packages/component-helper-service/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e3c50cf86934045799e812f2851110d36ba7cfec --- /dev/null +++ b/packages/component-helper-service/index.js @@ -0,0 +1 @@ +module.exports = require('./src/Helper') diff --git a/packages/component-helper-service/package.json b/packages/component-helper-service/package.json new file mode 100644 index 0000000000000000000000000000000000000000..e4aa40e13b33df0be7f7a47e1c00a8ef7019a8b1 --- /dev/null +++ b/packages/component-helper-service/package.json @@ -0,0 +1,25 @@ +{ + "name": "pubsweet-component-helper-service", + "version": "0.0.1", + "description": "helper 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-helper-service" + }, + "dependencies": { + }, + "peerDependencies": { + "@pubsweet/logger": "^0.0.1", + "pubsweet-server": "^1.0.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/component-helper-service/src/Helper.js b/packages/component-helper-service/src/Helper.js new file mode 100644 index 0000000000000000000000000000000000000000..0037b50c55ed4e8f5af9baccf34702df12b77731 --- /dev/null +++ b/packages/component-helper-service/src/Helper.js @@ -0,0 +1,19 @@ +const Email = require('./services/Email') +const Collection = require('./services/Collection') +const Fragment = require('./services/Fragment') +const services = require('./services/services') +const authsome = require('./services/authsome') +const User = require('./services/User') +const Team = require('./services/Team') +const Invitation = require('./services/Invitation') + +module.exports = { + Email, + Collection, + Fragment, + services, + authsome, + User, + Team, + Invitation, +} diff --git a/packages/component-helper-service/src/services/Collection.js b/packages/component-helper-service/src/services/Collection.js new file mode 100644 index 0000000000000000000000000000000000000000..dc3513909fd9a9efb99398080319d7ce0b091bd8 --- /dev/null +++ b/packages/component-helper-service/src/services/Collection.js @@ -0,0 +1,85 @@ +class Collection { + constructor({ collection = {} }) { + this.collection = collection + } + + async updateStatusByRecommendation({ + recommendation, + isHandlingEditor = false, + }) { + let newStatus + if (isHandlingEditor) { + newStatus = 'pendingApproval' + if (['minor', 'major'].includes(recommendation)) { + newStatus = 'revisionRequested' + } + } else { + if (recommendation === 'minor') { + newStatus = 'reviewCompleted' + } + + if (recommendation === 'major') { + newStatus = 'underReview' + } + } + + this.updateStatus({ newStatus }) + } + + async updateFinalStatusByRecommendation({ recommendation }) { + let newStatus + switch (recommendation) { + case 'reject': + newStatus = 'rejected' + break + case 'publish': + newStatus = 'accepted' + break + case 'return-to-handling-editor': + newStatus = 'reviewCompleted' + break + default: + break + } + + await this.updateStatus({ newStatus }) + } + + async updateStatus({ newStatus }) { + this.collection.status = newStatus + await this.collection.save() + } + + async addHandlingEditor({ user, invitation }) { + this.collection.handlingEditor = { + id: user.id, + name: `${user.firstName} ${user.lastName}`, + invitedOn: invitation.invitedOn, + respondedOn: invitation.respondedOn, + email: user.email, + hasAnswer: invitation.hasAnswer, + isAccepted: invitation.isAccepted, + } + await this.updateStatus({ newStatus: 'heInvited' }) + } + + async updateHandlingEditor({ isAccepted }) { + const { collection: { handlingEditor } } = this + handlingEditor.hasAnswer = true + handlingEditor.isAccepted = isAccepted + handlingEditor.respondedOn = Date.now() + let status + isAccepted ? (status = 'heAssigned') : (status = 'submitted') + await this.updateStatus({ newStatus: status }) + } + + async updateStatusByNumberOfReviewers({ invitations }) { + const reviewerInvitations = invitations.filter( + inv => inv.role === 'reviewer', + ) + if (reviewerInvitations.length === 0) + await this.updateStatus({ newStatus: 'heAssigned' }) + } +} + +module.exports = Collection diff --git a/packages/component-helper-service/src/services/Email.js b/packages/component-helper-service/src/services/Email.js new file mode 100644 index 0000000000000000000000000000000000000000..e3ea6688ae4056120140770cb81343f139670034 --- /dev/null +++ b/packages/component-helper-service/src/services/Email.js @@ -0,0 +1,430 @@ +const Fragment = require('./Fragment') +const User = require('./User') +const get = require('lodash/get') +const config = require('config') +const mailService = require('pubsweet-component-mail-service') + +const manuscriptTypes = config.get('manuscript-types') + +class Email { + constructor({ + baseUrl, + authors = {}, + UserModel = {}, + collection = {}, + parsedFragment = {}, + }) { + this.baseUrl = baseUrl + this.authors = authors + this.UserModel = UserModel + this.collection = collection + this.parsedFragment = parsedFragment + } + + set _fragment(newFragment) { + this.parsedFragment = newFragment + } + + async setupReviewersEmail({ + FragmentModel, + agree = false, + isSubmitted = false, + isRevision = false, + recommendation = {}, + newFragmentId = '', + }) { + const { + baseUrl, + UserModel, + collection, + parsedFragment: { recommendations, title, type }, + authors: { submittingAuthor: { firstName = '', lastName = '' } }, + } = this + let { parsedFragment: { id } } = this + const fragment = await FragmentModel.find(id) + const fragmentHelper = new Fragment({ fragment }) + const reviewerInvitations = fragmentHelper.getReviewerInvitations({ + agree, + }) + + const hasReview = invUserId => rec => + rec.recommendationType === 'review' && + rec.submittedOn && + invUserId === rec.userId + + const reviewerPromises = await reviewerInvitations.map(async inv => { + if (!agree) return UserModel.find(inv.userId) + const submittedReview = recommendations.find(hasReview(inv.userId)) + const shouldReturnUser = + (isSubmitted && submittedReview) || (!isSubmitted && !submittedReview) + if (shouldReturnUser) return UserModel.find(inv.userId) + }) + + let emailType = 'agreed-reviewers-after-recommendation' + let emailText, subject, manuscriptType + + const userHelper = new User({ UserModel }) + const eic = await userHelper.getEditorInChief() + let editorName = isSubmitted + ? `${eic.firstName} ${eic.lastName}` + : collection.handlingEditor.name + + let reviewers = await Promise.all(reviewerPromises) + reviewers = reviewers.filter(Boolean) + + if (agree) { + subject = isSubmitted + ? `${collection.customId}: Manuscript Decision` + : `${collection.customId}: Manuscript ${getSubject(recommendation)}` + + if (isSubmitted) { + emailType = 'submitting-reviewers-after-decision' + emailText = 'has now been rejected' + if (recommendation === 'publish') emailText = 'will now be published' + } + + if (isRevision) { + emailType = 'submitting-reviewers-after-revision' + subject = `${collection.customId}: Manuscript Update` + editorName = collection.handlingEditor.name + id = newFragmentId || id + } + } else { + subject = `${collection.customId}: Reviewer Unassigned` + manuscriptType = manuscriptTypes[type] + emailType = 'no-response-reviewers-after-recommendation' + } + + reviewers.forEach(user => + mailService.sendNotificationEmail({ + emailType, + toEmail: user.email, + meta: { + baseUrl, + emailText, + editorName, + collection, + manuscriptType, + timestamp: Date.now(), + emailSubject: subject, + reviewerName: `${user.firstName} ${user.lastName}`, + fragment: { + title, + authorName: `${firstName} ${lastName}`, + id, + }, + }, + }), + ) + } + + async setupAuthorsEmail({ + requestToRevision = false, + publish = false, + FragmentModel, + }) { + const { + baseUrl, + collection, + parsedFragment: { heRecommendation, id, title, newComments }, + authors: { submittingAuthor: { email, firstName, lastName } }, + } = this + let comments = get(heRecommendation, 'comments') || [] + if (requestToRevision) comments = newComments + const authorNote = comments.find(comm => comm.public) + const content = get(authorNote, 'content') + const authorNoteText = content ? `Reason & Details: "${content}"` : '' + let emailType = requestToRevision + ? 'author-request-to-revision' + : 'author-manuscript-rejected' + if (publish) emailType = 'author-manuscript-published' + let toAuthors = null + if (emailType === 'author-request-to-revision') { + toAuthors = [ + { + email, + name: `${firstName} ${lastName}`, + }, + ] + } else { + const fragment = await FragmentModel.find(id) + toAuthors = fragment.authors.map(author => ({ + email: author.email, + name: `${author.firstName} ${author.lastName}`, + })) + } + toAuthors.forEach(toAuthor => { + mailService.sendNotificationEmail({ + emailType, + toEmail: toAuthor.email, + meta: { + handlingEditorName: get(collection, 'handlingEditor.name'), + baseUrl, + collection, + authorNoteText, + fragment: { + id, + title, + authorName: toAuthor.name, + submittingAuthorName: `${firstName} ${lastName}`, + }, + }, + }) + }) + } + + async setupHandlingEditorEmail({ + publish = false, + returnWithComments = false, + reviewSubmitted = false, + reviewerName = '', + }) { + const { + baseUrl, + UserModel, + collection, + parsedFragment: { eicComments = '', title, id }, + authors: { submittingAuthor: { firstName = '', lastName = '' } }, + } = this + const userHelper = new User({ UserModel }) + const eic = await userHelper.getEditorInChief() + 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, + emailType, + meta: { + baseUrl, + collection, + reviewerName, + eicComments, + eicName: `${eic.firstName} ${eic.lastName}`, + emailSubject: `${collection.customId}: Manuscript Decision`, + handlingEditorName: get(collection, 'handlingEditor.name') || '', + fragment: { + id, + title, + authorName: `${firstName} ${lastName}`, + }, + }, + }) + } + + async setupEiCEmail({ recommendation, comments }) { + const { + baseUrl, + UserModel, + collection, + parsedFragment: { title, id }, + authors: { submittingAuthor: { firstName, lastName } }, + } = this + const privateNote = comments.find(comm => comm.public === false) + const content = get(privateNote, 'content') + const privateNoteText = + content !== undefined ? `Private note: "${content}"` : '' + let paragraph + const heRecommendation = getHeRecommendation(recommendation) + const manuscriptAuthorText = `the manuscript titled "${title}" by ${firstName} ${lastName}` + const publishOrRejectText = + 'It is my recommendation, based on the reviews I have received for' + switch (heRecommendation) { + case 'publish': + paragraph = `${publishOrRejectText} ${manuscriptAuthorText}, that we should proceed to publication. <br/><br/> + ${privateNoteText}<br/><br/>` + break + case 'reject': + paragraph = `${publishOrRejectText} + ${manuscriptAuthorText}, that we should reject it for publication. <br/><br/> + ${privateNoteText}<br/><br/>` + break + case 'revision': + paragraph = `In order for ${manuscriptAuthorText} to proceed to publication, there needs to be a revision. <br/><br/> + ${privateNoteText}<br/><br/>` + break + + default: + throw new Error('undefined HE recommentation type') + } + + const userHelper = new User({ UserModel }) + const eic = await userHelper.getEditorInChief() + const toEmail = eic.email + mailService.sendNotificationEmail({ + toEmail, + emailType: 'eic-recommendation', + meta: { + baseUrl, + paragraph, + collection, + fragment: { id }, + eicName: `${eic.firstName} ${eic.lastName}`, + handlingEditorName: get(collection, 'handlingEditor.name'), + }, + }) + } + + setupReviewerInvitationEmail({ + user, + invitationId, + timestamp, + resend = false, + authorName, + }) { + const { + baseUrl, + collection, + parsedFragment: { id, title, abstract }, + authors, + } = this + const params = { + user, + baseUrl, + subject: `${collection.customId}: Review Requested`, + toEmail: user.email, + meta: { + fragment: { + id, + title, + abstract, + authors, + }, + invitation: { + id: invitationId, + timestamp, + }, + collection: { + id: collection.id, + authorName, + handlingEditor: collection.handlingEditor, + }, + }, + emailType: resend ? 'resend-reviewer' : 'invite-reviewer', + } + mailService.sendReviewerInvitationEmail(params) + } + + async setupReviewerDecisionEmail({ + user, + agree = false, + timestamp = Date.now(), + authorName = '', + }) { + const { + baseUrl, + UserModel, + collection, + parsedFragment: { id, title }, + } = this + const userHelper = new User({ UserModel }) + const eic = await userHelper.getEditorInChief() + const toEmail = collection.handlingEditor.email + mailService.sendNotificationEmail({ + toEmail, + user, + emailType: agree ? 'reviewer-agreed' : 'reviewer-declined', + meta: { + collection: { customId: collection.customId, id: collection.id }, + fragment: { id, title, authorName }, + handlingEditorName: collection.handlingEditor.name, + baseUrl, + eicName: `${eic.firstName} ${eic.lastName}`, + timestamp, + }, + }) + if (agree) + mailService.sendNotificationEmail({ + toEmail: user.email, + user, + emailType: 'reviewer-thank-you', + meta: { + collection: { customId: collection.customId, id: collection.id }, + fragment: { id, title, authorName }, + handlingEditorName: collection.handlingEditor.name, + baseUrl, + }, + }) + } + + async setupReviewerUnassignEmail({ user, authorName }) { + const { collection, parsedFragment: { title = '' } } = this + + await mailService.sendNotificationEmail({ + toEmail: user.email, + user, + emailType: 'unassign-reviewer', + meta: { + collection: { customId: collection.customId }, + fragment: { title, authorName }, + handlingEditorName: collection.handlingEditor.name, + }, + }) + } + + async setupManuscriptSubmittedEmail() { + const { + baseUrl, + UserModel, + collection, + parsedFragment: { id, title }, + authors: { submittingAuthor: { firstName = '', lastName = '' } }, + } = this + + const userHelper = new User({ UserModel }) + const eic = await userHelper.getEditorInChief() + + mailService.sendSimpleEmail({ + toEmail: eic.email, + emailType: 'manuscript-submitted', + dashboardUrl: baseUrl, + meta: { + collection, + fragment: { id, authorName: `${firstName} ${lastName}`, title }, + eicName: `${eic.firstName} ${eic.lastName}`, + }, + }) + } + + async setupNewVersionSubmittedEmail() { + const { + baseUrl, + UserModel, + collection, + parsedFragment: { id, title }, + authors: { submittingAuthor: { firstName = '', lastName = '' } }, + } = this + + const userHelper = new User({ UserModel }) + const eic = await userHelper.getEditorInChief() + + mailService.sendNotificationEmail({ + toEmail: collection.handlingEditor.email, + emailType: 'new-version-submitted', + meta: { + baseUrl, + collection, + eicName: `${eic.firstName} ${eic.lastName}`, + fragment: { id, authorName: `${firstName} ${lastName}`, title }, + handlingEditorName: collection.handlingEditor.name, + }, + }) + } +} + +const getSubject = recommendation => + ['minor', 'major'].includes(recommendation) + ? 'Revision Requested' + : 'Recommendation Submitted' + +const getHeRecommendation = recommendation => { + let heRecommendation = recommendation === 'reject' ? 'reject' : 'publish' + if (['minor', 'major'].includes(recommendation)) { + heRecommendation = 'revision' + } + return heRecommendation +} + +module.exports = Email diff --git a/packages/component-helper-service/src/services/Fragment.js b/packages/component-helper-service/src/services/Fragment.js new file mode 100644 index 0000000000000000000000000000000000000000..6e418ee90f6d1e80bf308e16e9c89f627efeaaa4 --- /dev/null +++ b/packages/component-helper-service/src/services/Fragment.js @@ -0,0 +1,111 @@ +const get = require('lodash/get') + +class Fragment { + constructor({ fragment }) { + this.fragment = fragment + } + + set _fragment(newFragment) { + this.fragment = newFragment + } + + static setFragmentOwners(fragment = {}, author = {}) { + const { owners = [] } = fragment + if (author.isSubmitting) { + const authorAlreadyOwner = owners.includes(author.id) + if (!authorAlreadyOwner) { + return [author.id, ...owners] + } + } + return owners + } + + async getFragmentData({ handlingEditor = {} }) { + const { fragment: { metadata = {}, recommendations = [], id } } = this + const heRecommendation = recommendations.find( + rec => rec.userId === handlingEditor.id, + ) + let { title = '', abstract = '' } = metadata + const { type } = metadata + title = title.replace(/<(.|\n)*?>/g, '') + abstract = abstract ? abstract.replace(/<(.|\n)*?>/g, '') : '' + + return { + id, + type, + title, + abstract, + recommendations, + 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) + fragment.owners = this.constructor.setFragmentOwners(fragment, 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 new file mode 100644 index 0000000000000000000000000000000000000000..ce06b5ad4d8617a2c97506bc5759cdbb10200906 --- /dev/null +++ b/packages/component-helper-service/src/services/Invitation.js @@ -0,0 +1,78 @@ +const uuid = require('uuid') +const logger = require('@pubsweet/logger') + +class Invitation { + constructor({ userId, role }) { + this.userId = userId + this.role = role + } + + set _userId(newUserId) { + this.userId = newUserId + } + + getInvitationsData({ invitations = [] }) { + const { userId, role } = this + const matchingInvitation = invitations.find( + inv => inv.role === role && inv.userId === userId, + ) + if (!matchingInvitation) { + logger.error( + `There should be at least one matching invitation between User ${userId} and Role ${role}`, + ) + throw Error('no matching invitation') + } + let status = 'pending' + if (matchingInvitation.isAccepted) { + status = 'accepted' + } else if (matchingInvitation.hasAnswer) { + status = 'declined' + } + + const { invitedOn, respondedOn, id } = matchingInvitation + return { invitedOn, respondedOn, status, id } + } + + async createInvitation({ parentObject }) { + const { userId, role } = this + const invitation = { + role, + hasAnswer: false, + isAccepted: false, + invitedOn: Date.now(), + id: uuid.v4(), + userId, + respondedOn: null, + } + parentObject.invitations = parentObject.invitations || [] + parentObject.invitations.push(invitation) + await parentObject.save() + + return invitation + } + + getInvitation({ invitations = [] }) { + return invitations.find( + 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 new file mode 100644 index 0000000000000000000000000000000000000000..2e27f9a149198a158d02cb6d8e163c6a880175c5 --- /dev/null +++ b/packages/component-helper-service/src/services/Team.js @@ -0,0 +1,145 @@ +const logger = require('@pubsweet/logger') +const get = require('lodash/get') + +class Team { + constructor({ TeamModel = {}, fragmentId = '', collectionId = '' }) { + this.TeamModel = TeamModel + this.fragmentId = fragmentId + this.collectionId = collectionId + } + + async createTeam({ role = '', members = [], objectType = '' }) { + const { fragmentId, TeamModel, collectionId } = this + const objectId = objectType === 'collection' ? collectionId : fragmentId + + 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: objectType, + id: objectId, + }, + members, + } + let team = new TeamModel(teamBody) + team = await team.save() + return team + } + + 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 === objectType && + team.object.id === objectId, + ) + + if (foundTeam !== undefined) { + if (!foundTeam.members.includes(user.id)) { + foundTeam.members.push(user.id) + } + + try { + foundTeam = await foundTeam.save() + if (!user.teams.includes(foundTeam.id)) { + user.teams.push(foundTeam.id) + await user.save() + } + return foundTeam + } catch (e) { + logger.error(e) + } + } else { + const team = await this.createTeam({ + role, + members: [user.id], + objectType, + }) + user.teams.push(team.id) + await user.save() + return team + } + } + + async removeTeamMember({ teamId, userId }) { + const { TeamModel } = this + const team = await TeamModel.find(teamId) + const members = team.members.filter(member => member !== userId) + team.members = members + await team.save() + } + + 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 === objectType && + team.object.id === objectId, + ), + 'members', + ) + + return members + } + + 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 === objectType && + team.object.id === objectId, + ) + } + + 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 }) + user.teams = user.teams.filter(userTeamId => team.id !== userTeamId) + await user.save() + await collection.save() + } +} + +module.exports = Team diff --git a/packages/component-helper-service/src/services/User.js b/packages/component-helper-service/src/services/User.js new file mode 100644 index 0000000000000000000000000000000000000000..d165dae7ede1a592950c6b1c417fb96e24982d76 --- /dev/null +++ b/packages/component-helper-service/src/services/User.js @@ -0,0 +1,72 @@ +const uuid = require('uuid') +const crypto = require('crypto') +const logger = require('@pubsweet/logger') +const mailService = require('pubsweet-component-mail-service') + +class User { + constructor({ UserModel = {} }) { + this.UserModel = UserModel + } + + async createUser({ role, body }) { + const { UserModel } = this + const { email, firstName, lastName, affiliation, title } = body + 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', + invitationToken: role === 'reviewer' ? uuid.v4() : '', + } + + let newUser = new UserModel(userBody) + + newUser = await newUser.save() + return newUser + } + + async setupNewUser({ url, role, invitationType, body = {} }) { + const newUser = await this.createUser({ role, body }) + + try { + if (role !== 'reviewer') { + 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.' } + } + } + + async getEditorInChief() { + const { UserModel } = this + const users = await UserModel.all() + const eic = users.find(user => user.editorInChief || user.admin) + return eic + } + + async updateUserTeams({ userId, teamId }) { + const user = await this.UserModel.find(userId) + user.teams.push(teamId) + user.save() + } +} + +module.exports = User diff --git a/packages/component-invite/src/helpers/authsome.js b/packages/component-helper-service/src/services/authsome.js similarity index 94% rename from packages/component-invite/src/helpers/authsome.js rename to packages/component-helper-service/src/services/authsome.js index 212cee2a3ea23a424b1f77dde2f7dd4cf888b2a0..b2215bb20f4d5d50cd0f75fff2607ba79f514443 100644 --- a/packages/component-invite/src/helpers/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-manuscript-manager/src/helpers/helpers.js b/packages/component-helper-service/src/services/services.js similarity index 96% rename from packages/component-manuscript-manager/src/helpers/helpers.js rename to packages/component-helper-service/src/services/services.js index a37f7504aaa1efd3fbacdc94734fc0807cfa5734..cb56e8ec7edd3c811f3f77f11f66567c70d4a898 100644 --- a/packages/component-manuscript-manager/src/helpers/helpers.js +++ b/packages/component-helper-service/src/services/services.js @@ -8,7 +8,7 @@ const checkForUndefinedParams = (...params) => { return true } -const validateEmailAndToken = async (email, token, userModel) => { +const validateEmailAndToken = async ({ email, token, userModel }) => { try { const user = await userModel.findByEmail(email) if (user) { diff --git a/packages/component-invite/config/authsome-helpers.js b/packages/component-invite/config/authsome-helpers.js deleted file mode 100644 index b5f42492b077cee12d5a09d97404fb559e601f5e..0000000000000000000000000000000000000000 --- a/packages/component-invite/config/authsome-helpers.js +++ /dev/null @@ -1,83 +0,0 @@ -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[coll.status].private - } -} - -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, - ) - } - - return object - } - const matchingCollPerm = collectionsPermissions.find( - collPerm => object.id === collPerm.id, - ) - 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( - teamIds.map(async teamId => { - const team = await TeamModel.find(teamId) - if (permissions.includes(team.teamType.permissions)) { - return team - } - return null - }), - ) - - return teams.filter(Boolean) -} - -module.exports = { - parseAuthorsData, - setPublicStatuses, - filterRefusedInvitations, - filterObjectData, - getTeamsByPermissions, -} diff --git a/packages/component-invite/config/authsome-mode.js b/packages/component-invite/config/authsome-mode.js index 762998f83e80d5e678595bb8bf7e57072607adbe..9c663beae1962d9fc13141f8439dc0a84214ae08 100644 --- a/packages/component-invite/config/authsome-mode.js +++ b/packages/component-invite/config/authsome-mode.js @@ -1,252 +1,3 @@ -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 collections = filterParam - .map(coll => - helpers.filterObjectData(collectionsPermissions, coll, user), - ) - .filter(Boolean) - return collections - }, - } -} - -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 -} - -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 === 'POST' && - object.path === '/collections/:collectionId/fragments' - ) { - 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 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)) { - return true - } - } - } - - 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)) { - 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)) { - 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 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'], - 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 (user.teams.length !== 0 && operation === 'GET') { - const permissions = await teamPermissions(user, operation, object, context) - - if (permissions) { - return permissions - } - } - - if (get(object, 'type') === 'fragment') { - const fragment = object - - if (fragment.owners.includes(user.id)) { - return true - } - } - - 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)) { - return true - } - } - // 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 === true || user.editorInChief === true)) return true - - if (user) { - return authenticatedUser(user, operation, object, context) - } - - return false -} +const authsomeMode = require('xpub-faraday/config/authsome-mode') module.exports = authsomeMode diff --git a/packages/component-invite/config/default.js b/packages/component-invite/config/default.js index 7afbb2f22c26d8ae4d9f314989bc4a02189360f7..9950c9b354fa8710a4f043a07ac2b86a3cf6bd2f 100644 --- a/packages/component-invite/config/default.js +++ b/packages/component-invite/config/default.js @@ -1,58 +1,3 @@ -const path = require('path') +const defaultConfig = require('xpub-faraday/config/default') -module.exports = { - authsome: { - mode: path.resolve(__dirname, 'authsome-mode.js'), - teams: { - handlingEditor: { - name: 'Handling Editors', - }, - reviewer: { - name: 'Reviewer', - }, - }, - }, - mailer: { - from: 'test@example.com', - }, - 'invite-reset-password': { - url: - process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || - 'http://localhost:3000/invite', - }, - roles: { - global: ['admin', 'editorInChief', 'author', 'handlingEditor'], - collection: ['handlingEditor', 'reviewer', 'author'], - inviteRights: { - admin: ['admin', 'editorInChief', 'author'], - editorInChief: ['handlingEditor'], - handlingEditor: ['reviewer'], - }, - }, - statuses: { - draft: { - public: 'Draft', - private: 'Draft', - }, - submitted: { - public: 'Submitted', - private: 'Submitted', - }, - heInvited: { - public: 'Submitted', - private: 'HE Invited', - }, - heAssigned: { - public: 'HE Assigned', - private: 'HE Assigned', - }, - reviewersInvited: { - public: 'Reviewers Invited', - private: 'Reviewers Invited', - }, - underReview: { - public: 'Under Review', - private: 'Under Review', - }, - }, -} +module.exports = defaultConfig diff --git a/packages/component-invite/config/test.js b/packages/component-invite/config/test.js index 0eb54780a931f66f1b611d9a4d51552908ece2c3..9950c9b354fa8710a4f043a07ac2b86a3cf6bd2f 100644 --- a/packages/component-invite/config/test.js +++ b/packages/component-invite/config/test.js @@ -1,59 +1,3 @@ -const path = require('path') +const defaultConfig = require('xpub-faraday/config/default') -module.exports = { - authsome: { - mode: path.resolve(__dirname, 'authsome-mode.js'), - teams: { - handlingEditor: { - name: 'Handling Editors', - }, - reviewer: { - name: 'Reviewer', - }, - }, - }, - mailer: { - from: 'test@example.com', - }, - 'invite-reset-password': { - url: - process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || - 'http://localhost:3000/invite', - }, - roles: { - global: ['admin', 'editorInChief', 'author', 'handlingEditor'], - collection: ['handlingEditor', 'reviewer', 'author'], - inviteRights: { - admin: ['admin', 'editorInChief', 'author', 'handlingEditor', 'author'], - editorInChief: ['handlingEditor'], - handlingEditor: ['reviewer'], - author: ['author'], - }, - }, - statuses: { - draft: { - public: 'Draft', - private: 'Draft', - }, - submitted: { - public: 'Submitted', - private: 'Submitted', - }, - heInvited: { - public: 'Submitted', - private: 'HE Invited', - }, - heAssigned: { - public: 'HE Assigned', - private: 'HE Assigned', - }, - reviewersInvited: { - public: 'Reviewers Invited', - private: 'Reviewers Invited', - }, - underReview: { - public: 'Under Review', - private: 'Under Review', - }, - }, -} +module.exports = defaultConfig 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/helpers/Collection.js b/packages/component-invite/src/helpers/Collection.js deleted file mode 100644 index d04841fc244fa3b7619bacd064d3685267d42ccd..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/helpers/Collection.js +++ /dev/null @@ -1,72 +0,0 @@ -const config = require('config') -const last = require('lodash/last') - -const statuses = config.get('statuses') - -const addHandlingEditor = async (collection, user, invitation) => { - collection.handlingEditor = { - id: user.id, - name: `${user.firstName} ${user.lastName}`, - invitedOn: invitation.invitedOn, - respondedOn: invitation.respondedOn, - email: user.email, - hasAnswer: invitation.hasAnswer, - isAccepted: invitation.isAccepted, - } - await updateStatus(collection, 'heInvited') -} - -const updateHandlingEditor = async (collection, isAccepted) => { - collection.handlingEditor.hasAnswer = true - collection.handlingEditor.isAccepted = isAccepted - collection.handlingEditor.respondedOn = Date.now() - let status - isAccepted ? (status = 'heAssigned') : (status = 'submitted') - await updateStatus(collection, status) -} - -const getFragmentAndAuthorData = async ({ - UserModel, - FragmentModel, - collection: { fragments, authors }, -}) => { - const fragment = await FragmentModel.find(last(fragments)) - let { title, abstract } = fragment.metadata - title = title.replace(/<(.|\n)*?>/g, '') - abstract = abstract ? abstract.replace(/<(.|\n)*?>/g, '') : '' - - const submittingAuthorData = authors.find( - author => author.isSubmitting === true, - ) - const author = await UserModel.find(submittingAuthorData.userId) - const authorName = `${author.firstName} ${author.lastName}` - const authorsPromises = authors.map(async author => { - const user = await UserModel.find(author.userId) - return ` ${user.firstName} ${user.lastName}` - }) - const authorsList = await Promise.all(authorsPromises) - const { id } = fragment - return { title, authorName, id, abstract, authorsList } -} - -const updateReviewerCollectionStatus = async collection => { - const reviewerInvitations = collection.invitations.filter( - inv => inv.role === 'reviewer', - ) - if (reviewerInvitations.length === 0) - await updateStatus(collection, 'heAssigned') -} - -const updateStatus = async (collection, newStatus) => { - collection.status = newStatus - collection.visibleStatus = statuses[collection.status].private - await collection.save() -} - -module.exports = { - addHandlingEditor, - updateHandlingEditor, - getFragmentAndAuthorData, - updateReviewerCollectionStatus, - updateStatus, -} diff --git a/packages/component-invite/src/helpers/Invitation.js b/packages/component-invite/src/helpers/Invitation.js deleted file mode 100644 index c543f1f5435d0822a649c96144f14bfa5f93ea1b..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/helpers/Invitation.js +++ /dev/null @@ -1,95 +0,0 @@ -const uuid = require('uuid') -const collectionHelper = require('./Collection') - -const getInvitationData = (invitations, userId, role) => { - const matchingInvitation = invitations.find( - inv => inv.role === role && inv.userId === userId, - ) - let status = 'pending' - if (matchingInvitation.isAccepted) { - status = 'accepted' - } else if (matchingInvitation.hasAnswer) { - status = 'declined' - } - - const { invitedOn, respondedOn, id } = matchingInvitation - return { invitedOn, respondedOn, status, id } -} - -const setupInvitation = async (userId, role, collection) => { - const invitation = { - role, - hasAnswer: false, - isAccepted: false, - invitedOn: Date.now(), - id: uuid.v4(), - userId, - respondedOn: null, - } - collection.invitations = collection.invitations || [] - collection.invitations.push(invitation) - collection = await collection.save() - return invitation -} - -const setupReviewerInvitation = async ({ - baseUrl, - FragmentModel, - UserModel, - collection, - user, - mailService, - invitationId, - timestamp, - resend = false, -}) => { - const { - title, - authorName, - id, - abstract, - authorsList, - } = await collectionHelper.getFragmentAndAuthorData({ - UserModel, - FragmentModel, - collection, - }) - - const params = { - user, - baseUrl, - subject: `${collection.customId}: Review Requested`, - toEmail: user.email, - meta: { - fragment: { - id, - title, - abstract, - authors: authorsList, - }, - invitation: { - id: invitationId, - timestamp, - }, - collection: { - id: collection.id, - authorName, - handlingEditor: collection.handlingEditor, - }, - }, - emailType: resend ? 'resend-reviewer' : 'invite-reviewer', - } - await mailService.sendReviewerInvitationEmail(params) -} - -const getInvitation = (invitations = [], userId, role) => - invitations.find( - invitation => invitation.userId === userId && invitation.role === role, - ) - -module.exports = { - getInvitationData, - setupInvitation, - setupReviewerInvitation, - getInvitation, -} diff --git a/packages/component-invite/src/helpers/Team.js b/packages/component-invite/src/helpers/Team.js deleted file mode 100644 index 8092336a8cdd94720eea378cd4dfae14a3453f7f..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/helpers/Team.js +++ /dev/null @@ -1,125 +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 || [] - let foundTeam = teams.find( - team => - team.group === role && - team.object.type === 'collection' && - team.object.id === collectionId, - ) - - if (foundTeam !== undefined) { - if (!foundTeam.members.includes(user.id)) { - foundTeam.members.push(user.id) - } - - try { - foundTeam = await foundTeam.save() - if (!user.teams.includes(foundTeam.id)) { - user.teams.push(foundTeam.id) - await user.save() - } - return foundTeam - } 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', - ) - - return 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, - ) -} - -const updateHETeam = async (collection, role, TeamModel, user) => { - const team = await getTeamByGroupAndCollection(collection.id, role, TeamModel) - delete collection.handlingEditor - await removeTeamMember(team.id, user.id, TeamModel) - user.teams = user.teams.filter(userTeamId => team.id !== userTeamId) - await user.save() - await collection.save() -} - -module.exports = { - createNewTeam, - setupManuscriptTeam, - removeTeamMember, - getTeamMembersByCollection, - getTeamByGroupAndCollection, - updateHETeam, -} diff --git a/packages/component-invite/src/helpers/User.js b/packages/component-invite/src/helpers/User.js deleted file mode 100644 index 4b293a2ad476f3ccf4269869234985b7b6dc0c05..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/helpers/User.js +++ /dev/null @@ -1,129 +0,0 @@ -const helpers = require('./helpers') -const mailService = require('pubsweet-component-mail-service') -const logger = require('@pubsweet/logger') -const collectionHelper = require('./Collection') - -const 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 { - if (role !== 'reviewer') { - await 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.' } - } -} - -const getEditorInChief = async UserModel => { - const users = await UserModel.all() - const eic = users.find(user => user.editorInChief || user.admin) - return eic -} - -const setupReviewerDecisionEmailData = async ({ - baseUrl, - UserModel, - FragmentModel, - collection, - user, - mailService, - agree, - timestamp = Date.now(), -}) => { - const { - title, - authorName, - id, - } = await collectionHelper.getFragmentAndAuthorData({ - UserModel, - FragmentModel, - collection, - }) - const eic = await getEditorInChief(UserModel) - const toEmail = collection.handlingEditor.email - await mailService.sendNotificationEmail({ - toEmail, - user, - emailType: agree ? 'reviewer-agreed' : 'reviewer-declined', - meta: { - collection: { customId: collection.customId, id: collection.id }, - fragment: { id, title, authorName }, - handlingEditorName: collection.handlingEditor.name, - baseUrl, - eicName: `${eic.firstName} ${eic.lastName}`, - timestamp, - }, - }) - if (agree) - await mailService.sendNotificationEmail({ - toEmail: user.email, - user, - emailType: 'reviewer-thank-you', - meta: { - collection: { customId: collection.customId, id: collection.id }, - fragment: { id, title, authorName }, - handlingEditorName: collection.handlingEditor.name, - baseUrl, - }, - }) -} - -const setupReviewerUnassignEmail = async ({ - UserModel, - FragmentModel, - collection, - user, - mailService, -}) => { - const { title, authorName } = await collectionHelper.getFragmentAndAuthorData( - { - UserModel, - FragmentModel, - collection, - }, - ) - - await mailService.sendNotificationEmail({ - toEmail: user.email, - user, - emailType: 'unassign-reviewer', - meta: { - collection: { customId: collection.customId }, - fragment: { title, authorName }, - handlingEditorName: collection.handlingEditor.name, - }, - }) -} - -module.exports = { - setupNewUser, - getEditorInChief, - setupReviewerDecisionEmailData, - setupReviewerUnassignEmail, -} diff --git a/packages/component-invite/src/helpers/helpers.js b/packages/component-invite/src/helpers/helpers.js deleted file mode 100644 index b484721469b65e466195b899a64c3a0c40bed8da..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/helpers/helpers.js +++ /dev/null @@ -1,123 +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', - invitationToken: role === 'reviewer' ? uuid.v4() : '', - } - - let newUser = new UserModel(userBody) - - try { - newUser = await newUser.save() - return newUser - } catch (e) { - logger.error(e) - } -} - -const getBaseUrl = req => `${req.protocol}://${req.get('host')}` - -module.exports = { - checkForUndefinedParams, - validateEmailAndToken, - handleNotFoundError, - createNewUser, - getBaseUrl, -} diff --git a/packages/component-invite/src/routes/collectionsInvitations/decline.js b/packages/component-invite/src/routes/collectionsInvitations/decline.js deleted file mode 100644 index fc88ffe4f4b69da2bf0481db895e247b90864b49..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/routes/collectionsInvitations/decline.js +++ /dev/null @@ -1,56 +0,0 @@ -const helpers = require('../../helpers/helpers') -const mailService = require('pubsweet-component-mail-service') -const userHelper = require('../../helpers/User') - -module.exports = models => async (req, res) => { - const { collectionId, invitationId } = req.params - const { invitationToken } = req.body - - if (!helpers.checkForUndefinedParams(invitationToken)) - return res.status(400).json({ error: 'Token is required' }) - - try { - const user = await models.User.findOneByField( - 'invitationToken', - invitationToken, - ) - const collection = await models.Collection.find(collectionId) - const invitation = await collection.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 - }`, - }) - - invitation.respondedOn = Date.now() - invitation.hasAnswer = true - invitation.isAccepted = false - await collection.save() - return await userHelper.setupReviewerDecisionEmailData({ - baseUrl: helpers.getBaseUrl(req), - UserModel: models.User, - FragmentModel: models.Fragment, - collection, - reviewerName: `${user.firstName} ${user.lastName}`, - mailService, - agree: false, - user, - }) - } catch (e) { - const notFoundError = await helpers.handleNotFoundError(e, 'item') - return res.status(notFoundError.status).json({ - error: notFoundError.message, - }) - } -} diff --git a/packages/component-invite/src/routes/collectionsInvitations/delete.js b/packages/component-invite/src/routes/collectionsInvitations/delete.js index d1640d5d78afd6aab05dbd8aa74c13f3c150bbeb..f82dffef10760d85c495c1a895cb995d0dc13a00 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/delete.js +++ b/packages/component-invite/src/routes/collectionsInvitations/delete.js @@ -1,17 +1,18 @@ -const helpers = require('../../helpers/helpers') -const teamHelper = require('../../helpers/Team') const mailService = require('pubsweet-component-mail-service') -const logger = require('@pubsweet/logger') -const config = require('config') -const userHelper = require('../../helpers/User') -const collectionHelper = require('../../helpers/Collection') -const authsomeHelper = require('../../helpers/authsome') -const statuses = config.get('statuses') +const { + services, + Team, + authsome: authsomeHelper, +} = require('pubsweet-component-helper-service') + module.exports = models => async (req, res) => { const { collectionId, invitationId } = req.params + const teamHelper = new Team({ TeamModel: models.Team, collectionId }) + try { const collection = await models.Collection.find(collectionId) + const authsome = authsomeHelper.getAuthsome(models) const target = { collection, @@ -22,59 +23,47 @@ 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( - collectionId, - invitation.role, - models.Team, - ) + + 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.updateReviewerCollectionStatus(collection) - } + + collection.status = 'submitted' + delete collection.handlingEditor await collection.save() - await teamHelper.removeTeamMember(team.id, invitation.userId, models.Team) - const user = await models.User.find(invitation.userId) + + 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') { - await userHelper.setupReviewerUnassignEmail({ - UserModel: models.User, - FragmentModel: models.Fragment, - collection, - user, - mailService, - }) - } - 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 helpers.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 657ba2794843e4e5df353e2b52f26de5b85161f1..d28609053609980f7256195dda6e4c98bdc9f78e 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/patch.js +++ b/packages/component-invite/src/routes/collectionsInvitations/patch.js @@ -1,127 +1,92 @@ -const logger = require('@pubsweet/logger') -const helpers = require('../../helpers/helpers') -const teamHelper = require('../../helpers/Team') const mailService = require('pubsweet-component-mail-service') -const userHelper = require('../../helpers/User') -const collectionHelper = require('../../helpers/Collection') + +const { + 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 (!helpers.checkForUndefinedParams(isAccepted)) { - res.status(400).json({ error: 'Missing parameters.' }) - logger.error('some parameters are missing') - return - } - let user = await models.User.find(req.user) + 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 params = { - baseUrl: helpers.getBaseUrl(req), - UserModel: models.User, - FragmentModel: models.Fragment, - collection, - reviewerName: `${user.firstName} ${user.lastName}`, - mailService, - } - if (invitation.role === 'handlingEditor') - await collectionHelper.updateHandlingEditor(collection, isAccepted) + const collectionHelper = new Collection({ collection }) + const baseUrl = services.getBaseUrl(req) + + const teamHelper = new Team({ TeamModel: models.Team, collectionId }) + const userHelper = new User({ UserModel }) + + await collectionHelper.updateHandlingEditor({ isAccepted }) invitation.respondedOn = Date.now() invitation.hasAnswer = true - const eic = await userHelper.getEditorInChief(models.User) + 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(collection, 'underReview') - await collection.save() - try { - if (invitation.role === 'handlingEditor') - await mailService.sendSimpleEmail({ - toEmail, - user, - emailType: 'handling-editor-agreed', - dashboardUrl: `${req.protocol}://${req.get('host')}`, - meta: { - collectionId: collection.customId, - }, - }) - if (invitation.role === 'reviewer') - await userHelper.setupReviewerDecisionEmailData({ - ...params, - agree: true, - timestamp: invitation.respondedOn, - user, - }) - 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, - invitation.role, - models.Team, - 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') { - await collectionHelper.updateReviewerCollectionStatus(collection) - await userHelper.setupReviewerDecisionEmailData({ - ...params, - 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 = await 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 helpers.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/post.js b/packages/component-invite/src/routes/collectionsInvitations/post.js index 8f21d2c11d3ce636a6998febd9d71a6da31e5d40..3e20a3c89450bce8077a350249b150ac975dcab4 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/post.js +++ b/packages/component-invite/src/routes/collectionsInvitations/post.js @@ -1,43 +1,38 @@ const logger = require('@pubsweet/logger') -const get = require('lodash/get') -const helpers = require('../../helpers/helpers') -const collectionHelper = require('../../helpers/Collection') -const teamHelper = require('../../helpers/Team') -const config = require('config') const mailService = require('pubsweet-component-mail-service') -const userHelper = require('../../helpers/User') -const invitationHelper = require('../../helpers/Invitation') -const authsomeHelper = require('../../helpers/authsome') - -const configRoles = config.get('roles') +const { + services, + authsome: authsomeHelper, + Collection, + Team, + Invitation, +} = require('pubsweet-component-helper-service') module.exports = models => async (req, res) => { const { email, role } = req.body - if (!helpers.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 (!configRoles.collection.includes(role)) { - res.status(400).json({ error: `Role ${role} is invalid` }) - logger.error(`invitation attempted on invalid role ${role}`) - return - } - const reqUser = await models.User.find(req.user) + if (role !== 'handlingEditor') + return res.status(400).json({ + error: `Role ${role} is invalid. Only handlingEditor is allowed.`, + }) + + 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 helpers.handleNotFoundError(e, 'collection') + const notFoundError = await services.handleNotFoundError(e, 'Collection') return res.status(notFoundError.status).json({ error: notFoundError.message, }) @@ -53,107 +48,51 @@ module.exports = models => async (req, res) => { return res.status(403).json({ error: 'Unauthorized.', }) - const baseUrl = `${req.protocol}://${req.get('host')}` - const params = { - baseUrl, - FragmentModel: models.Fragment, - UserModel: models.User, - collection, - mailService, - resend: false, - } - try { - const user = await models.User.findByEmail(email) + const collectionHelper = new Collection({ collection }) + const baseUrl = services.getBaseUrl(req) - await teamHelper.setupManuscriptTeam(models, user, collectionId, role) - let invitation = invitationHelper.getInvitation( - collection.invitations, - user.id, - role, - ) + const teamHelper = new Team({ + TeamModel: models.Team, + collectionId, + }) + const invitationHelper = new Invitation({ role }) - let resend = false - if (invitation !== undefined) { + try { + const user = await UserModel.findByEmail(email) + await teamHelper.setupTeam({ user, role, objectType: 'collection' }) + invitationHelper.userId = user.id + let invitation = invitationHelper.getInvitation({ + invitations: collection.invitations, + }) + + 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( - user.id, - role, - collection, - ) + invitation = await invitationHelper.createInvitation({ + parentObject: collection, + }) } - try { - if (role === 'reviewer') { - if (collection.status === 'heAssigned') - await collectionHelper.updateStatus(collection, 'reviewersInvited') + invitation.invitedOn = Date.now() + await collection.save() + await collectionHelper.addHandlingEditor({ user, invitation }) - await invitationHelper.setupReviewerInvitation({ - ...params, - user, - invitationId: invitation.id, - timestamp: invitation.invitedOn, - resend, - }) - } + 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(collection, user, invitation) - await 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) { - if (role === 'reviewer') { - const newUser = await userHelper.setupNewUser( - req.body, - baseUrl, - res, - email, - role, - models.User, - 'invite', - ) - if (newUser.error !== undefined) { - return res.status(newUser.status).json({ - error: newUser.message, - }) - } - if (collection.status === 'heAssigned') - await collectionHelper.updateStatus(collection, 'reviewersInvited') - await teamHelper.setupManuscriptTeam(models, newUser, collectionId, role) - const invitation = await invitationHelper.setupInvitation( - newUser.id, - role, - collection, - ) - - await invitationHelper.setupReviewerInvitation({ - ...params, - user: newUser, - invitationId: invitation.id, - timestamp: invitation.invitedOn, - }) - return res.status(200).json(invitation) - } - const notFoundError = await helpers.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/fragmentsInvitations/decline.js b/packages/component-invite/src/routes/fragmentsInvitations/decline.js new file mode 100644 index 0000000000000000000000000000000000000000..e867503771902281e8686ff0330e2508ca693dfa --- /dev/null +++ b/packages/component-invite/src/routes/fragmentsInvitations/decline.js @@ -0,0 +1,78 @@ +const { + services, + Email, + Fragment, + Invitation, +} = require('pubsweet-component-helper-service') + +module.exports = models => async (req, res) => { + const { collectionId, invitationId, fragmentId } = req.params + const { invitationToken } = req.body + + if (!services.checkForUndefinedParams(invitationToken)) + return res.status(400).json({ error: 'Token is required' }) + + const UserModel = models.User + try { + const user = await UserModel.findOneByField( + 'invitationToken', + invitationToken, + ) + 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, + }) + + invitation.respondedOn = Date.now() + invitation.hasAnswer = true + invitation.isAccepted = false + await fragment.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.setupReviewerDecisionEmail({ + authorName: `${submittingAuthor.firstName} ${submittingAuthor.lastName}`, + agree: false, + user, + }) + 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-invite/src/routes/fragmentsInvitations/delete.js b/packages/component-invite/src/routes/fragmentsInvitations/delete.js new file mode 100644 index 0000000000000000000000000000000000000000..af74f13d32a60e8cac0c18837b23c57b384d2570 --- /dev/null +++ b/packages/component-invite/src/routes/fragmentsInvitations/delete.js @@ -0,0 +1,99 @@ +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({ + invitations: fragment.invitations, + }) + + 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 58% rename from packages/component-invite/src/routes/collectionsInvitations/get.js rename to packages/component-invite/src/routes/fragmentsInvitations/get.js index 308a57670a9030a82833f445fef7855083077edb..a7e80aa98f6fde7df771d8ec58ea4c4248384211 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/get.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/get.js @@ -1,13 +1,16 @@ -const helpers = require('../../helpers/helpers') -const teamHelper = require('../../helpers/Team') const config = require('config') -const invitationHelper = require('../../helpers/Invitation') -const authsomeHelper = require('../../helpers/authsome') +const { + services, + Team, + Invitation, + authsome: authsomeHelper, +} = require('pubsweet-component-helper-service') const configRoles = config.get('roles') + module.exports = models => async (req, res) => { const { role } = req.query - if (!helpers.checkForUndefinedParams(role)) { + if (!services.checkForUndefinedParams(role)) { res.status(400).json({ error: 'Role is required' }) return } @@ -16,40 +19,57 @@ module.exports = models => async (req, res) => { res.status(400).json({ error: `Role ${role} is invalid` }) return } - const { collectionId } = req.params + + 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) + if (!canGet) return res.status(403).json({ error: 'Unauthorized.', }) - const members = await teamHelper.getTeamMembersByCollection( - collectionId, + + const members = await teamHelper.getTeamMembers({ role, - models.Team, - ) + 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) + invitationHelper.userId = user.id const { invitedOn, respondedOn, status, id, - } = invitationHelper.getInvitationData( - collection.invitations, - user.id, - role, - ) + } = invitationHelper.getInvitationsData({ + invitations: fragment.invitations, + }) + return { name: `${user.firstName} ${user.lastName}`, invitedOn, @@ -64,7 +84,7 @@ module.exports = models => async (req, res) => { const resBody = await Promise.all(membersData) res.status(200).json(resBody) } catch (e) { - const notFoundError = await helpers.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..6cb2ca49d9cd4aa8dcbac67b254963ad2edb3ee9 --- /dev/null +++ b/packages/component-invite/src/routes/fragmentsInvitations/patch.js @@ -0,0 +1,97 @@ +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({ + invitations: fragment.invitations, + }) + + 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/fragments.js b/packages/component-invite/src/tests/fixtures/fragments.js deleted file mode 100644 index 32379359150362143172390427a5113121ad9233..0000000000000000000000000000000000000000 --- a/packages/component-invite/src/tests/fixtures/fragments.js +++ /dev/null @@ -1,14 +0,0 @@ -const Chance = require('chance') - -const chance = new Chance() -const fragments = { - fragment: { - id: chance.guid(), - metadata: { - title: chance.sentence(), - abstract: chance.paragraph(), - }, - }, -} - -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 61% rename from packages/component-invite/src/tests/collectionsInvitations/get.test.js rename to packages/component-invite/src/tests/fragmentsInvitations/get.test.js index 105187c0a834d5c5614b9346318ccdbb715ac8c0..043e162fac230e383f769e197ec47ca0ba8debf9 100644 --- a/packages/component-invite/src/tests/collectionsInvitations/get.test.js +++ b/packages/component-invite/src/tests/fragmentsInvitations/get.test.js @@ -1,16 +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 path = '../../routes/collectionsInvitations/get' +const { Model, fixtures } = fixturesService +jest.mock('pubsweet-component-mail-service', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), + sendReviewerInvitationEmail: jest.fn(), +})) +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(() => { @@ -18,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, }, }) @@ -39,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, @@ -51,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', @@ -67,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, @@ -123,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 c0886c2c27eb3b21b408b5d88dd36c9aa89204e1..5a57a4e84b7579821c02b5448895d5b13e773130 100644 --- a/packages/component-mail-service/src/Mail.js +++ b/packages/component-mail-service/src/Mail.js @@ -2,15 +2,17 @@ const Email = require('@pubsweet/component-send-email') const config = require('config') const helpers = require('./helpers/helpers') -const resetPasswordPath = config.get('invite-reviewer.url') +const confirmSignUp = config.get('confirm-signup.url') const resetPath = config.get('invite-reset-password.url') +const resetPasswordPath = config.get('invite-reviewer.url') +const forgotPath = config.get('forgot-password.url') module.exports = { sendSimpleEmail: async ({ - toEmail, - user, - emailType, - dashboardUrl, + toEmail = '', + user = {}, + emailType = '', + dashboardUrl = '', meta = {}, }) => { let subject, textBody @@ -43,12 +45,27 @@ module.exports = { replacements.url } ${replacements.buttonText}` break + case 'signup': + subject = 'Confirm your email address' + replacements.headline = '' + replacements.paragraph = + 'Please confirm your account by clicking on the link below.' + replacements.previewText = 'Hindawi account confirmation' + replacements.buttonText = 'CONFIRM' + replacements.url = helpers.createUrl(dashboardUrl, confirmSignUp, { + userId: user.id, + confirmationToken: meta.confirmationToken, + }) + textBody = `${replacements.headline} ${replacements.paragraph} ${ + replacements.url + } ${replacements.buttonText}` + break case 'invite-author': - subject = 'Hindawi Invitation' + subject = 'Author Invitation' replacements.headline = - 'You have been invited as an Author to a manuscript.' + 'You have been invited to join Hindawi as an Author.' replacements.paragraph = - "The manuscript will be visible on your dashboard once it's submitted. Please confirm your account and set your account details by clicking on the link below." + 'Please confirm your account and set your account details by clicking on the link below.' replacements.previewText = 'You have been invited' replacements.buttonText = 'CONFIRM' replacements.url = helpers.createUrl(dashboardUrl, resetPath, { @@ -117,6 +134,41 @@ module.exports = { textBody = `${replacements.headline}` emailTemplate = 'noCTA' break + case 'manuscript-submitted': + subject = `${meta.collection.customId}: Manuscript Submitted` + replacements.previewText = 'A new manuscript has been submitted' + replacements.headline = `A new manuscript has been submitted.` + replacements.paragraph = `You can view the full manuscript titled "${ + meta.fragment.title + }" by ${ + meta.fragment.authorName + } and take further actions by clicking on the following link:` + replacements.buttonText = 'MANUSCRIPT DETAILS' + replacements.url = helpers.createUrl( + dashboardUrl, + `/projects/${meta.collection.id}/versions/${ + meta.fragment.id + }/details`, + ) + textBody = `${replacements.headline} ${replacements.paragraph} ${ + replacements.url + } ${replacements.buttonText}` + break + case 'forgot-password': + subject = 'Forgot Password' + replacements.headline = 'You have requested a password reset.' + replacements.paragraph = + 'In order to reset your password please click on the following link:' + replacements.previewText = 'Click button to reset your password' + replacements.buttonText = 'RESET PASSWORD' + replacements.url = helpers.createUrl(dashboardUrl, forgotPath, { + email: user.email, + token: user.passwordResetToken, + }) + textBody = `${replacements.headline} ${replacements.paragraph} ${ + replacements.url + } ${replacements.buttonText}` + break default: subject = 'Welcome to Hindawi!' break @@ -152,6 +204,7 @@ module.exports = { const declineUrl = helpers.createUrl(baseUrl, resetPasswordPath, { ...queryParams, agree: false, + fragmentId: meta.fragment.id, collectionId: meta.collection.id, invitationToken: user.invitationToken, }) @@ -305,8 +358,8 @@ module.exports = { replacements.intro = `Dear ${meta.handlingEditorName}` replacements.paragraph = `We are pleased to inform you that Dr. ${ - user.firstName - } ${user.lastName} has submitted a review for the manuscript titled "${ + meta.reviewerName + } has submitted a review for the manuscript titled "${ meta.fragment.title }" by ${meta.fragment.authorName}.` replacements.beforeAnchor = 'Please visit the' @@ -404,16 +457,16 @@ module.exports = { subject = meta.emailSubject replacements.hasLink = false replacements.previewText = 'a manuscript has reached a decision' - replacements.intro = `Dear Dr. ${user.firstName} ${user.lastName}` + replacements.intro = `Dear Dr. ${meta.reviewerName}` replacements.paragraph = `An editorial decision has been made regarding the ${ meta.manuscriptType } titled "${meta.fragment.title}" by ${ meta.fragment.authorName - }. So, you do not need to proceed with the review of this manuscript. <br/> + }. So, you do not need to proceed with the review of this manuscript. <br/><br/> If you have comments on this manuscript you believe the Editor should see, please email them to Hindawi as soon as possible.` delete replacements.detailsUrl - replacements.signatureName = meta.handlingEditorName + replacements.signatureName = meta.editorName textBody = `${replacements.intro} ${replacements.paragraph} ${ replacements.signatureName }` @@ -522,6 +575,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 @@ -542,6 +614,44 @@ module.exports = { replacements.signatureName }` break + case 'new-version-submitted': + subject = `${meta.collection.customId}: Manuscript Update` + replacements.previewText = 'A manuscript has been updated' + replacements.intro = `Dear Dr. ${meta.handlingEditorName}` + + replacements.paragraph = `A new version of the manuscript titled "${ + meta.fragment.title + }" by ${meta.fragment.authorName} has been submitted.` + replacements.beforeAnchor = + 'Previous reviewers have been automatically invited to review the manuscript again. Please visit the' + replacements.afterAnchor = + 'to see the latest version and any other actions you may need to take' + + replacements.signatureName = meta.eicName + textBody = `${replacements.intro} ${replacements.paragraph} ${ + replacements.beforeAnchor + } ${replacements.detailsUrl} ${replacements.afterAnchor} ${ + replacements.signatureName + }` + break + case 'submitting-reviewers-after-revision': + subject = meta.emailSubject + replacements.previewText = 'A manuscript has been updated' + replacements.intro = `Dear Dr. ${meta.reviewerName}` + + replacements.paragraph = `A new version of the manuscript titled "${ + meta.fragment.title + }" by ${meta.fragment.authorName} has been submitted.` + replacements.beforeAnchor = `As you have reviewed the previous version of this manuscript, I would be grateful if you can review this revised version and submit a review report by ${helpers.getExpectedDate( + meta.timestamp, + 14, + )}. You can download the PDF of the revised version and submit your new review from the following URL:` + + replacements.signatureName = meta.editorName + textBody = `${replacements.intro} ${replacements.paragraph} ${ + replacements.beforeAnchor + } ${replacements.detailsUrl} ${replacements.signatureName}` + break default: subject = 'Hindawi Notification!' break diff --git a/packages/component-manuscript-manager/config/authsome-helpers.js b/packages/component-manuscript-manager/config/authsome-helpers.js deleted file mode 100644 index 1add8d99b7fb8eca56a2abb7bf43088d599e5efc..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/config/authsome-helpers.js +++ /dev/null @@ -1,83 +0,0 @@ -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, - ) - } - - return object - } - const matchingCollPerm = collectionsPermissions.find( - collPerm => object.id === collPerm.id, - ) - 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( - teamIds.map(async teamId => { - const team = await TeamModel.find(teamId) - if (permissions.includes(team.teamType.permissions)) { - return team - } - return null - }), - ) - - return teams.filter(Boolean) -} - -module.exports = { - parseAuthorsData, - setPublicStatuses, - filterRefusedInvitations, - filterObjectData, - getTeamsByPermissions, -} diff --git a/packages/component-manuscript-manager/config/authsome-mode.js b/packages/component-manuscript-manager/config/authsome-mode.js index 667879b274b86a59a28515ed3d595d97e6ad2808..9c663beae1962d9fc13141f8439dc0a84214ae08 100644 --- a/packages/component-manuscript-manager/config/authsome-mode.js +++ b/packages/component-manuscript-manager/config/authsome-mode.js @@ -1,252 +1,3 @@ -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 collections = filterParam - .map(coll => - helpers.filterObjectData(collectionsPermissions, coll, user), - ) - .filter(Boolean) - return collections - }, - } -} - -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 -} - -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 === 'POST' && - object.path === '/collections/:collectionId/fragments' - ) { - 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 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)) { - return true - } - } - } - - 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)) { - 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)) { - 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 (user.teams.length !== 0 && operation === 'GET') { - const permissions = await teamPermissions(user, operation, object, context) - - if (permissions) { - return permissions - } - } - - if (get(object, 'type') === 'fragment') { - const fragment = object - - if (fragment.owners.includes(user.id)) { - return true - } - } - - 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)) { - return true - } - } - // 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 === true || user.editorInChief === true)) return true - - if (user) { - return authenticatedUser(user, operation, object, context) - } - - return false -} +const authsomeMode = require('xpub-faraday/config/authsome-mode') module.exports = authsomeMode diff --git a/packages/component-manuscript-manager/config/default.js b/packages/component-manuscript-manager/config/default.js index 276960b2fa5ce4133c55e91766a40dc4f69ea69f..9950c9b354fa8710a4f043a07ac2b86a3cf6bd2f 100644 --- a/packages/component-manuscript-manager/config/default.js +++ b/packages/component-manuscript-manager/config/default.js @@ -1,82 +1,3 @@ -const path = require('path') +const defaultConfig = require('xpub-faraday/config/default') -module.exports = { - authsome: { - mode: path.resolve(__dirname, 'authsome-mode.js'), - teams: { - handlingEditor: { - name: 'Handling Editors', - }, - reviewer: { - name: 'Reviewer', - }, - }, - }, - mailer: { - from: 'test@example.com', - }, - 'invite-reset-password': { - url: - process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || - 'http://localhost:3000/invite', - }, - roles: { - global: ['admin', 'editorInChief', 'author', 'handlingEditor'], - collection: ['handlingEditor', 'reviewer', 'author'], - inviteRights: { - admin: ['admin', 'editorInChief', 'author'], - editorInChief: ['handlingEditor'], - handlingEditor: ['reviewer'], - }, - }, - statuses: { - draft: { - public: 'Draft', - private: 'Draft', - }, - submitted: { - public: 'Submitted', - private: 'Submitted', - }, - heInvited: { - public: 'Submitted', - private: 'HE Invited', - }, - heAssigned: { - public: 'HE Assigned', - private: 'HE Assigned', - }, - reviewersInvited: { - public: 'Reviewers Invited', - private: 'Reviewers Invited', - }, - underReview: { - public: 'Under Review', - private: 'Under Review', - }, - pendingApproval: { - public: 'Under Review', - private: 'Pending Approval', - }, - 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', - }, -} +module.exports = defaultConfig diff --git a/packages/component-manuscript-manager/config/test.js b/packages/component-manuscript-manager/config/test.js index 6869d659a3af2b1b643561889f4f81d4c486d1cf..9950c9b354fa8710a4f043a07ac2b86a3cf6bd2f 100644 --- a/packages/component-manuscript-manager/config/test.js +++ b/packages/component-manuscript-manager/config/test.js @@ -1,83 +1,3 @@ -const path = require('path') +const defaultConfig = require('xpub-faraday/config/default') -module.exports = { - authsome: { - mode: path.resolve(__dirname, 'authsome-mode.js'), - teams: { - handlingEditor: { - name: 'Handling Editors', - }, - reviewer: { - name: 'Reviewer', - }, - }, - }, - mailer: { - from: 'test@example.com', - }, - 'invite-reset-password': { - url: - process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || - 'http://localhost:3000/invite', - }, - roles: { - global: ['admin', 'editorInChief', 'author', 'handlingEditor'], - collection: ['handlingEditor', 'reviewer', 'author'], - inviteRights: { - admin: ['admin', 'editorInChief', 'author', 'handlingEditor', 'author'], - editorInChief: ['handlingEditor'], - handlingEditor: ['reviewer'], - author: ['author'], - }, - }, - statuses: { - draft: { - public: 'Draft', - private: 'Draft', - }, - submitted: { - public: 'Submitted', - private: 'Submitted', - }, - heInvited: { - public: 'Submitted', - private: 'HE Invited', - }, - heAssigned: { - public: 'HE Assigned', - private: 'HE Assigned', - }, - reviewersInvited: { - public: 'Reviewers Invited', - private: 'Reviewers Invited', - }, - underReview: { - public: 'Under Review', - private: 'Under Review', - }, - pendingApproval: { - public: 'Under Review', - private: 'Pending Approval', - }, - 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', - }, -} +module.exports = defaultConfig 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/package.json b/packages/component-manuscript-manager/package.json index 9d40f80a6ae8f8706e7e0fc235e9940a444c8b6b..55ffd9d800fbc02181a01ef7821f74637be8296b 100644 --- a/packages/component-manuscript-manager/package.json +++ b/packages/component-manuscript-manager/package.json @@ -25,7 +25,8 @@ "peerDependencies": { "@pubsweet/logger": "^0.0.1", "pubsweet-component-mail-service": "0.0.1", - "pubsweet-server": "^1.0.1" + "pubsweet-server": "^1.0.1", + "component-helper-service": "0.0.1" }, "devDependencies": { "apidoc": "^0.17.6", diff --git a/packages/component-manuscript-manager/src/Fragments.js b/packages/component-manuscript-manager/src/Fragments.js new file mode 100644 index 0000000000000000000000000000000000000000..01e11a93582bdc5ff37497d2bb7a7c6a980b68c0 --- /dev/null +++ b/packages/component-manuscript-manager/src/Fragments.js @@ -0,0 +1,54 @@ +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 Fragments + * @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), + ) + /** + * @api {post} /api/collections/:collectionId/fragments/:fragmentId/submit Submit a manuscript + * @apiGroup Fragments + * @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.post( + `${basePath}`, + authBearer, + require(`${routePath}/post`)(app.locals.models), + ) +} + +module.exports = Fragments diff --git a/packages/component-manuscript-manager/src/helpers/Collection.js b/packages/component-manuscript-manager/src/helpers/Collection.js deleted file mode 100644 index b5e783c80c7fb320ef269fc863544fafd5604eed..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/helpers/Collection.js +++ /dev/null @@ -1,89 +0,0 @@ -const config = require('config') - -const statuses = config.get('statuses') - -const updateStatusByRecommendation = async (collection, recommendation) => { - let newStatus = 'pendingApproval' - if (['minor', 'major'].includes(recommendation)) - newStatus = 'revisionRequested' - collection.status = newStatus - collection.visibleStatus = statuses[collection.status].private - await collection.save() -} - -const updateFinalStatusByRecommendation = async ( - collection, - recommendation, -) => { - let newStatus - switch (recommendation) { - case 'reject': - newStatus = 'rejected' - break - case 'publish': - newStatus = 'published' - break - case 'return-to-handling-editor': - newStatus = 'reviewCompleted' - break - default: - break - } - collection.status = newStatus - collection.visibleStatus = statuses[collection.status].private - await collection.save() -} - -const updateStatus = async (collection, newStatus) => { - collection.status = newStatus - collection.visibleStatus = statuses[collection.status].private - await collection.save() -} - -const getFragmentAndAuthorData = async ({ - UserModel, - fragment, - collection: { authors, handlingEditor }, -}) => { - const heRecommendation = fragment.recommendations.find( - rec => rec.userId === handlingEditor.id, - ) - let { title, abstract } = fragment.metadata - const { type } = fragment.metadata - title = title.replace(/<(.|\n)*?>/g, '') - abstract = abstract ? abstract.replace(/<(.|\n)*?>/g, '') : '' - - 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 { - title, - submittingAuthor, - abstract, - authorsList, - type, - heRecommendation, - } -} - -const getAgreedReviewerInvitation = (invitations = []) => - invitations.filter( - inv => - inv.role === 'reviewer' && - inv.hasAnswer === true && - inv.isAccepted === true, - ) - -module.exports = { - updateStatusByRecommendation, - getFragmentAndAuthorData, - getAgreedReviewerInvitation, - updateStatus, - updateFinalStatusByRecommendation, -} diff --git a/packages/component-manuscript-manager/src/helpers/User.js b/packages/component-manuscript-manager/src/helpers/User.js deleted file mode 100644 index c9945c0ee80f60f09add3de038b360de877e56f9..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/helpers/User.js +++ /dev/null @@ -1,275 +0,0 @@ -const collectionHelper = require('./Collection') -const get = require('lodash/get') -const config = require('config') -const mailService = require('pubsweet-component-mail-service') - -const manuscriptTypes = config.get('manuscript-types') -const getEditorInChief = async UserModel => { - const users = await UserModel.all() - const eic = users.find(user => user.editorInChief || user.admin) - return eic -} - -const setupReviewSubmittedEmailData = async ({ - baseUrl, - UserModel, - fragment: { id, title, submittingAuthor }, - collection, - user, -}) => { - const eic = await getEditorInChief(UserModel) - const toEmail = collection.handlingEditor.email - await mailService.sendNotificationEmail({ - toEmail, - user, - emailType: 'review-submitted', - meta: { - collection: { customId: collection.customId, id: collection.id }, - fragment: { - id, - title, - authorName: `${submittingAuthor.firstName} ${ - submittingAuthor.lastName - }`, - }, - handlingEditorName: collection.handlingEditor.name, - baseUrl, - eicName: `${eic.firstName} ${eic.lastName}`, - }, - }) -} - -const setupReviewersEmail = async ({ - fragment: { title, authorName, recommendations }, - collection, - UserModel, - recommendation, - isSubmitted = false, -}) => { - const agreedReviewerInvitations = collectionHelper.getAgreedReviewerInvitation( - collection.invitations, - ) - const hasReview = invUserId => rec => - rec.recommendationType === 'review' && - rec.submittedOn && - invUserId === rec.userId - const reviewerPromises = await agreedReviewerInvitations.map(async inv => { - const submittedReview = recommendations.find(hasReview(inv.userId)) - const shouldReturnUser = - (isSubmitted && submittedReview) || (!isSubmitted && !submittedReview) - if (shouldReturnUser) return UserModel.find(inv.userId) - }) - let reviewers = await Promise.all(reviewerPromises) - reviewers = reviewers.filter(Boolean) - const subject = isSubmitted - ? `${collection.customId}: Manuscript Decision` - : `${collection.customId}: Manuscript ${getSubject(recommendation)}` - let emailType = 'agreed-reviewers-after-recommendation' - let emailText - if (isSubmitted) { - emailType = 'submitting-reviewers-after-decision' - emailText = 'has now been rejected' - if (recommendation === 'publish') emailText = 'will now be published' - } - - const eic = await getEditorInChief(UserModel) - const editorName = isSubmitted - ? `${eic.firstName} ${eic.lastName}` - : collection.handlingEditor.name - reviewers.forEach(user => - mailService.sendNotificationEmail({ - toEmail: user.email, - emailType, - meta: { - fragment: { title, authorName }, - editorName, - emailSubject: subject, - reviewerName: `${user.firstName} ${user.lastName}`, - emailText, - }, - }), - ) -} - -const setupNoResponseReviewersEmailData = async ({ - baseUrl, - fragment: { title, authorName, type }, - collection, - UserModel, -}) => { - const invitations = collection.invitations.filter( - inv => inv.role === 'reviewer' && inv.hasAnswer === false, - ) - const userPromises = await invitations.map(async inv => - UserModel.find(inv.userId), - ) - let users = await Promise.all(userPromises) - users = users.filter(Boolean) - const subject = `${collection.customId}: Reviewer Unassigned` - const manuscriptType = manuscriptTypes[type] - users.forEach(user => - mailService.sendNotificationEmail({ - toEmail: user.email, - user, - emailType: 'no-response-reviewers-after-recommendation', - meta: { - collection: { customId: collection.customId }, - fragment: { title, authorName }, - handlingEditorName: collection.handlingEditor.name, - baseUrl, - emailSubject: subject, - manuscriptType, - }, - }), - ) -} - -const setupEiCRecommendationEmailData = async ({ - baseUrl, - UserModel, - fragment: { id, title, authorName }, - collection, - recommendation, - comments, -}) => { - // to do: get private note from recommendation - const privateNote = comments.find(comm => comm.public === false) - const content = get(privateNote, 'content') - const privateNoteText = - content !== undefined ? `Private note: "${content}"` : '' - let paragraph - const heRecommendation = getHeRecommendation(recommendation) - switch (heRecommendation) { - case 'publish': - paragraph = `It is my recommendation, based on the reviews I have received for the manuscript titled "${title}" by ${authorName}, that we should proceed to publication. <br/><br/> - ${privateNoteText}<br/><br/>` - break - case 'reject': - paragraph = `It is my recommendation, based on the reviews I have received for the manuscript titled "${title}" by ${authorName}, that we should reject it for publication. <br/><br/> - ${privateNoteText}<br/><br/>` - break - case 'revision': - paragraph = `In order for the manuscript titled "${title}" by ${authorName} to proceed to publication, there needs to be a revision. <br/><br/> - ${privateNoteText}<br/><br/>` - break - - default: - throw new Error('undefined he recommentation type') - } - - const eic = await getEditorInChief(UserModel) - const toEmail = eic.email - await mailService.sendNotificationEmail({ - toEmail, - emailType: 'eic-recommendation', - meta: { - collection: { customId: collection.customId, id: collection.id }, - fragment: { id }, - handlingEditorName: collection.handlingEditor.name, - baseUrl, - eicName: `${eic.firstName} ${eic.lastName}`, - paragraph, - }, - }) -} - -const setupAuthorsEmailData = async ({ - baseUrl, - UserModel, - fragment: { id, title, submittingAuthor }, - collection, - comments, - requestToRevision = false, - publish = false, -}) => { - const authorNote = comments.find(comm => comm.public === true) - const content = get(authorNote, 'content') - const authorNoteText = - content !== undefined ? `Reason & Details: "${content}"` : '' - let emailType = requestToRevision - ? 'author-request-to-revision' - : 'author-manuscript-rejected' - if (publish) emailType = 'author-manuscript-published' - let toAuthors = [] - if (emailType === 'author-request-to-revision') { - toAuthors.push({ - email: submittingAuthor.email, - name: `${submittingAuthor.firstName} ${submittingAuthor.lastName}`, - }) - } else { - toAuthors = collection.authors.map(author => ({ - email: author.email, - name: `${author.firstName} ${author.lastName}`, - })) - } - toAuthors.forEach(toAuthor => { - mailService.sendNotificationEmail({ - toEmail: toAuthor.email, - emailType, - meta: { - collection: { customId: collection.customId, id: collection.id }, - fragment: { - id, - title, - authorName: toAuthor.name, - submittingAuthorName: `${submittingAuthor.firstName} ${ - submittingAuthor.lastName - }`, - }, - handlingEditorName: collection.handlingEditor.name, - baseUrl, - authorNoteText, - }, - }) - }) -} - -const setupManuscriptDecisionEmailForHe = async ({ - UserModel, - fragment: { title, submittingAuthor }, - collection: { customId, handlingEditor }, - publish = false, -}) => { - const eic = await getEditorInChief(UserModel) - const toEmail = handlingEditor.email - const emailType = publish - ? 'he-manuscript-published' - : 'he-manuscript-rejected' - await mailService.sendNotificationEmail({ - toEmail, - emailType, - meta: { - emailSubject: `${customId}: Manuscript Decision`, - fragment: { - title, - authorName: `${submittingAuthor.firstName} ${ - submittingAuthor.lastName - }`, - }, - handlingEditorName: handlingEditor.name, - eicName: `${eic.firstName} ${eic.lastName}`, - }, - }) -} -const getSubject = recommendation => - ['minor', 'major'].includes(recommendation) - ? 'Revision Requested' - : 'Recommendation Submitted' - -const getHeRecommendation = recommendation => { - let heRecommendation = recommendation === 'reject' ? 'reject' : 'publish' - if (['minor', 'major'].includes(recommendation)) { - heRecommendation = 'revision' - } - return heRecommendation -} - -module.exports = { - getEditorInChief, - setupReviewSubmittedEmailData, - setupReviewersEmail, - setupEiCRecommendationEmailData, - setupAuthorsEmailData, - setupNoResponseReviewersEmailData, - setupManuscriptDecisionEmailForHe, -} diff --git a/packages/component-manuscript-manager/src/helpers/authsome.js b/packages/component-manuscript-manager/src/helpers/authsome.js deleted file mode 100644 index 212cee2a3ea23a424b1f77dde2f7dd4cf888b2a0..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/src/helpers/authsome.js +++ /dev/null @@ -1,29 +0,0 @@ -const config = require('config') -const Authsome = require('authsome') - -const mode = require(config.get('authsome.mode')) - -const getAuthsome = models => - new Authsome( - { ...config.authsome, mode }, - { - // restrict methods passed to mode since these have to be shimmed on client - // any changes here should be reflected in the `withAuthsome` component of `pubsweet-client` - models: { - Collection: { - find: id => models.Collection.find(id), - }, - Fragment: { - find: id => models.Fragment.find(id), - }, - User: { - find: id => models.User.find(id), - }, - Team: { - find: id => models.Team.find(id), - }, - }, - }, - ) - -module.exports = { getAuthsome } 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..af3511344a735f5a4d303bbfa6423a99eeb6293f --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragments/patch.js @@ -0,0 +1,147 @@ +const { + Team, + User, + Email, + services, + Fragment, + Collection, + authsome: authsomeHelper, +} = require('pubsweet-component-helper-service') +const union = require('lodash/union') + +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.`, + }) + const fragLength = collection.fragments.length + if (fragLength < 2) { + return res.status(400).json({ + error: 'No previous version has been found.', + }) + } + 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 teamHelper = new Team({ + TeamModel: models.Team, + collectionId, + fragmentId, + }) + const userHelper = new User({ UserModel: models.User }) + + const reviewerIds = fragment.invitations.map(inv => { + const { userId } = inv + return userId + }) + + const reviewersTeam = await teamHelper.createTeam({ + role: 'reviewer', + members: reviewerIds, + objectType: 'fragment', + }) + + reviewerIds.forEach(id => + userHelper.updateUserTeams({ + userId: id, + teamId: reviewersTeam.id, + }), + ) + + const authorIds = fragment.authors.map(auth => { + const { id } = auth + return id + }) + + let authorsTeam = await teamHelper.getTeam({ + role: 'author', + objectType: 'fragment', + }) + + if (!authorsTeam) { + authorsTeam = await teamHelper.createTeam({ + role: 'author', + members: authorIds, + objectType: 'fragment', + }) + } else { + authorsTeam.members = union(authorsTeam.members, authorIds) + await authorsTeam.save() + } + + authorIds.forEach(id => + userHelper.updateUserTeams({ + userId: id, + teamId: reviewersTeam.id, + }), + ) + + const previousFragment = await models.Fragment.find( + collection.fragments[fragLength - 2], + ) + fragmentHelper.fragment = previousFragment + + const heRecommendation = fragmentHelper.getHeRequestToRevision() + if (!heRecommendation) { + return res.status(400).json({ + error: 'No Handling Editor request to revision has been found.', + }) + } + + collectionHelper.updateStatusByRecommendation({ + recommendation: heRecommendation.recommendation, + }) + + fragment.submitted = Date.now() + fragment = await fragment.save() + + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor: collection.handlingEditor, + }) + const authors = await fragmentHelper.getAuthorData({ + UserModel: models.User, + }) + const email = new Email({ + authors, + collection, + parsedFragment: { ...parsedFragment, id: fragment.id }, + UserModel: models.User, + baseUrl: services.getBaseUrl(req), + }) + email.setupNewVersionSubmittedEmail() + + if (heRecommendation.recommendation === 'major') { + email.setupReviewersEmail({ + agree: true, + isRevision: true, + isSubmitted: true, + FragmentModel: models.Fragment, + newFragmentId: fragment.id, + }) + } + + 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/fragments/post.js b/packages/component-manuscript-manager/src/routes/fragments/post.js new file mode 100644 index 0000000000000000000000000000000000000000..b54399b0cc006f469863bbfabaaecb0c8bab3150 --- /dev/null +++ b/packages/component-manuscript-manager/src/routes/fragments/post.js @@ -0,0 +1,61 @@ +const { + Email, + Fragment, + services, + 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 canPost = await authsome.can(req.user, 'POST', target) + if (!canPost) + return res.status(403).json({ + error: 'Unauthorized.', + }) + + fragment.submitted = Date.now() + fragment = await fragment.save() + + const fragmentHelper = new Fragment({ fragment }) + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor: collection.handlingEditor, + }) + const authors = await fragmentHelper.getAuthorData({ + UserModel: models.User, + }) + + const email = new Email({ + authors, + collection, + parsedFragment, + UserModel: models.User, + baseUrl: services.getBaseUrl(req), + }) + email.setupManuscriptSubmittedEmail() + + collection.status = 'submitted' + collection.save() + + 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 97ee0a9f2362a6ca6665257f9cd4419ea73e172c..524276fb2d35b692a586cad981fcf7e38ab6b8a9 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/patch.js @@ -1,7 +1,10 @@ -const helpers = require('../../helpers/helpers') -const authsomeHelper = require('../../helpers/authsome') -const collectionHelper = require('../../helpers/Collection') -const userHelper = require('../../helpers/User') +const { + Email, + services, + authsome: authsomeHelper, + Fragment, + Collection, +} = require('pubsweet-component-helper-service') module.exports = models => async (req, res) => { const { collectionId, fragmentId, recommendationId } = req.params @@ -12,52 +15,69 @@ module.exports = models => async (req, res) => { 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 user = await models.User.find(req.user) - const canPatch = await authsome.can(req.user, 'PATCH', target) - if (!canPatch) - 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 !== undefined) { - const { - title, - submittingAuthor, - } = await collectionHelper.getFragmentAndAuthorData({ - UserModel: models.User, - fragment, - collection, + if (req.body.submittedOn) { + const fragmentHelper = new Fragment({ fragment }) + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor: collection.handlingEditor, + }) + const baseUrl = services.getBaseUrl(req) + const collectionHelper = new Collection({ collection }) + + const authors = await fragmentHelper.getAuthorData({ + UserModel, }) - await userHelper.setupReviewSubmittedEmailData({ - baseUrl: helpers.getBaseUrl(req), - UserModel: models.User, - fragment: { id: fragment.id, title, submittingAuthor }, + + const email = new Email({ + UserModel, collection, - user, + parsedFragment, + baseUrl, + authors, + }) + + email.setupHandlingEditorEmail({ + reviewSubmitted: true, + reviewerName: `${user.firstName} ${user.lastName}`, }) - if (!['pendingApproval', 'revisionRequested'].includes(collection.status)) - await collectionHelper.updateStatus(collection, 'reviewCompleted') + + if (['underReview'].includes(collection.status)) + collectionHelper.updateStatus({ newStatus: 'reviewCompleted' }) } await fragment.save() return res.status(200).json(recommendation) } catch (e) { - const notFoundError = await helpers.handleNotFoundError(e, 'Item') + const notFoundError = await services.handleNotFoundError(e, 'Item') return res.status(notFoundError.status).json({ error: notFoundError.message, }) diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js index f00c899edcddaf3339814de655fbc1d83246acc7..53ae3e90411ee519901840258d8776a40ef9aeee 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js @@ -1,13 +1,16 @@ -const helpers = require('../../helpers/helpers') const uuid = require('uuid') -const authsomeHelper = require('../../helpers/authsome') -const collectionHelper = require('../../helpers/Collection') -const userHelper = require('../../helpers/User') -const get = require('lodash/get') +const { chain } = require('lodash') +const { + Email, + services, + authsome: authsomeHelper, + Fragment, + Collection, +} = require('pubsweet-component-helper-service') module.exports = models => async (req, res) => { const { recommendation, comments, recommendationType } = req.body - if (!helpers.checkForUndefinedParams(recommendationType)) + if (!services.checkForUndefinedParams(recommendationType)) return res.status(400).json({ error: 'Recommendation type is required.' }) const reqUser = await models.User.find(req.user) @@ -21,17 +24,17 @@ 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 helpers.handleNotFoundError(e, 'Item') + const notFoundError = await services.handleNotFoundError(e, 'Item') return res.status(notFoundError.status).json({ 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) @@ -39,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(), @@ -51,100 +55,78 @@ module.exports = models => async (req, res) => { newRecommendation.recommendation = recommendation || undefined newRecommendation.comments = comments || undefined const UserModel = models.User + const collectionHelper = new Collection({ collection }) + const fragmentHelper = new Fragment({ fragment }) + const parsedFragment = await fragmentHelper.getFragmentData({ + handlingEditor: collection.handlingEditor, + }) - const { - title, - submittingAuthor, - type, - heRecommendation, - } = await collectionHelper.getFragmentAndAuthorData({ + const baseUrl = services.getBaseUrl(req) + const authors = await fragmentHelper.getAuthorData({ UserModel }) + + const email = new Email({ UserModel, - fragment, collection, + parsedFragment, + baseUrl, + authors, }) - const baseUrl = helpers.getBaseUrl(req) - const authorName = `${submittingAuthor.firstName} ${ - submittingAuthor.lastName - }` + const FragmentModel = models.Fragment + if (reqUser.editorInChief || reqUser.admin) { - if (recommendation === 'return-to-handling-editor') - await collectionHelper.updateStatus(collection, 'reviewCompleted') - else { - await collectionHelper.updateFinalStatusByRecommendation( - collection, + if (recommendation === 'return-to-handling-editor') { + collectionHelper.updateStatus({ newStatus: 'reviewCompleted' }) + 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, - ) - await userHelper.setupAuthorsEmailData({ - baseUrl, - UserModel, - collection, - fragment: { title, submittingAuthor }, - comments: get(heRecommendation, 'comments'), + }) + email.setupAuthorsEmail({ requestToRevision: false, publish: recommendation === 'publish', + FragmentModel, }) - await userHelper.setupManuscriptDecisionEmailForHe({ - UserModel, - fragment: { title, submittingAuthor }, - collection: { - customId: collection.customId, - handlingEditor: collection.handlingEditor, - }, + email.setupHandlingEditorEmail({ publish: recommendation === 'publish', }) - - await userHelper.setupReviewersEmail({ - UserModel, - collection, - fragment: { - title, - authorName, - recommendations: fragment.recommendations, - }, + email.parsedFragment.recommendations = fragment.recommendations + email.setupReviewersEmail({ recommendation, isSubmitted: true, + agree: true, + FragmentModel, }) } } else if (recommendationType === 'editorRecommendation') { - await collectionHelper.updateStatusByRecommendation( - collection, - recommendation, - ) - await userHelper.setupReviewersEmail({ - UserModel, - collection, - fragment: { - title, - authorName, - recommendations: fragment.recommendations, - }, + collectionHelper.updateStatusByRecommendation({ recommendation, + isHandlingEditor: true, }) - await userHelper.setupNoResponseReviewersEmailData({ - baseUrl, - UserModel, - collection, - fragment: { title, authorName, type }, + email.setupReviewersEmail({ + recommendation, + agree: true, + FragmentModel, }) - await userHelper.setupEiCRecommendationEmailData({ - baseUrl, - UserModel, - collection, + email.setupReviewersEmail({ agree: false, FragmentModel: models.Fragment }) + email.setupEiCEmail({ recommendation, - fragment: { title, id: fragment.id, authorName }, comments: newRecommendation.comments, }) - if (['minor', 'major'].includes(recommendation)) - await userHelper.setupAuthorsEmailData({ - baseUrl, - UserModel, - collection, - fragment: { title, id: fragment.id, submittingAuthor }, - comments: newRecommendation.comments, + if (['minor', 'major'].includes(recommendation)) { + email.parsedFragment.newComments = newRecommendation.comments + email.setupAuthorsEmail({ requestToRevision: true, }) + } } - fragment.recommendations.push(newRecommendation) await fragment.save() return res.status(200).json(newRecommendation) diff --git a/packages/component-manuscript-manager/src/tests/fixtures/collections.js b/packages/component-manuscript-manager/src/tests/fixtures/collections.js deleted file mode 100644 index bef6132199981a79b87ea86440146eaab5891883..0000000000000000000000000000000000000000 --- a/packages/component-manuscript-manager/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-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..b5afab0749a5c28f77bee042b30aeee90f179345 --- /dev/null +++ b/packages/component-manuscript-manager/src/tests/fragments/patch.test.js @@ -0,0 +1,157 @@ +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 { newVersion } = testFixtures.fragments + const res = await requests.sendRequest({ + body, + userId: user.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: newVersion.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 { noParentFragment } = testFixtures.fragments + + const res = await requests.sendRequest({ + body, + userId: user.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: noParentFragment.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 + // const collection = { + // ...fCollection, + // fragments: [...fCollection.fragments, '123'], + // } + 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 request to revision has been found.', + ) + }) + it('should return an error when the request user is not the owner', async () => { + const { author } = testFixtures.users + const { newVersion } = testFixtures.fragments + const { collection } = testFixtures.collections + + const res = await requests.sendRequest({ + body, + userId: author.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: newVersion.id, + }, + }) + + expect(res.statusCode).toBe(403) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Unauthorized.') + }) + it('should return an error when no previous version exists', async () => { + const { user } = testFixtures.users + const { fragment } = testFixtures.fragments + const { collection } = testFixtures.collections + collection.fragments.length = 1 + + 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 previous version has been found.') + }) +}) diff --git a/packages/component-manuscript-manager/src/tests/fragments/post.test.js b/packages/component-manuscript-manager/src/tests/fragments/post.test.js new file mode 100644 index 0000000000000000000000000000000000000000..1da5c72f731cdc11512706c2709d014dd2ed1930 --- /dev/null +++ b/packages/component-manuscript-manager/src/tests/fragments/post.test.js @@ -0,0 +1,86 @@ +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/post' +const route = { + path: '/api/collections/:collectionId/fragments/:fragmentId/submit', +} +describe('Post 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 { noParentFragment } = testFixtures.fragments + + const res = await requests.sendRequest({ + body, + userId: user.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: noParentFragment.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') + }) +}) 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/Files.js b/packages/component-manuscript/src/components/Files.js index 8a9c4057dcde3b1afd47989544bc7860ef6ac655..cb3315829631061e90b79fee25372d9c76507e11 100644 --- a/packages/component-manuscript/src/components/Files.js +++ b/packages/component-manuscript/src/components/Files.js @@ -5,7 +5,12 @@ import styled, { css } from 'styled-components' import { FileItem } from 'pubsweet-components-faraday/src/components/Files' const Files = ({ - files: { manuscripts = [], coverLetter = [], supplementary = [] }, + files: { + manuscripts = [], + coverLetter = [], + supplementary = [], + responseToReviewers = [], + }, }) => ( <Root> {!!manuscripts.length && ( @@ -41,6 +46,17 @@ const Files = ({ ))} </Fragment> )} + {!!responseToReviewers.length && ( + <Fragment> + <Header> + <span>Response to Reviewers</span> + <div /> + </Header> + {responseToReviewers.map(file => ( + <FileItem compact id={file.id} key={file.id} {...file} /> + ))} + </Fragment> + )} </Root> ) @@ -60,9 +76,9 @@ const Header = styled.div` flex-direction: row; & span { - ${defaultText}; margin-right: ${th('subGridUnit')}; margin-top: ${th('subGridUnit')}; + ${defaultText}; text-transform: uppercase; } diff --git a/packages/component-manuscript/src/components/ManuscriptDetails.js b/packages/component-manuscript/src/components/ManuscriptDetails.js index df5e50181ab2ca80665c94378130903321deed7e..5c8545b657c15853562e220dfe4b00babe969607 100644 --- a/packages/component-manuscript/src/components/ManuscriptDetails.js +++ b/packages/component-manuscript/src/components/ManuscriptDetails.js @@ -7,8 +7,12 @@ import { Authors, Files } from './' import { Expandable } from '../molecules/' const ManuscriptDetails = ({ - collection: { authors = [] }, - fragment: { conflicts = {}, files = {}, metadata: { abstract = '' } }, + fragment: { + files = {}, + authors = [], + conflicts = {}, + metadata: { abstract = '' }, + }, }) => ( <Root> <Expandable label="Details" startExpanded> diff --git a/packages/component-manuscript/src/components/ManuscriptHeader.js b/packages/component-manuscript/src/components/ManuscriptHeader.js index caa38eb70f31265b39428a8227d36c032f68fd46..19735221355a3c92462c483faba1513c812d4403 100644 --- a/packages/component-manuscript/src/components/ManuscriptHeader.js +++ b/packages/component-manuscript/src/components/ManuscriptHeader.js @@ -30,10 +30,13 @@ const ManuscriptDetails = ({ version, project, journal }) => { <Row> <LeftDetails flex={3}> <StatusLabel>{mapStatusToLabel(project)}</StatusLabel> - <DateParser timestamp={get(version, 'submitted')}> + <DateParser + durationThreshold={0} + timestamp={get(version, 'submitted')} + > {(timestamp, days) => ( <DateField> - {timestamp} ({days}) + {timestamp} ({days} ago) </DateField> )} </DateParser> diff --git a/packages/component-manuscript/src/components/ManuscriptLayout.js b/packages/component-manuscript/src/components/ManuscriptLayout.js index ea6b9532eb0b048a250ae750f0d4c6357c9faf44..ce3745ce0117574d4688b99c38a3480e9a14c183 100644 --- a/packages/component-manuscript/src/components/ManuscriptLayout.js +++ b/packages/component-manuscript/src/components/ManuscriptLayout.js @@ -26,8 +26,6 @@ const ManuscriptLayout = ({ history, currentUser, editorInChief, - updateManuscript, - canSeeEditorialComments, editorialRecommendations, project = {}, version = {}, @@ -53,16 +51,15 @@ const ManuscriptLayout = ({ project={project} version={version} /> - <ManuscriptDetails collection={project} fragment={version} /> + <ManuscriptDetails fragment={version} /> <ReviewsAndReports project={project} version={version} /> - {canSeeEditorialComments && - editorialRecommendations.length > 0 && ( - <EditorialComments - editorInChief={editorInChief} - project={project} - recommendations={editorialRecommendations} - /> - )} + {editorialRecommendations.length > 0 && ( + <EditorialComments + editorInChief={editorInChief} + project={project} + recommendations={editorialRecommendations} + /> + )} </Container> <SideBar flex={1}> <SideBarActions project={project} version={version} /> diff --git a/packages/component-manuscript/src/components/ManuscriptPage.js b/packages/component-manuscript/src/components/ManuscriptPage.js index df4b1eaac76cebaf609b4b5fe0d5768e25eb2979..a832022fdb722c6e9fe0dd077639eed86e449147 100644 --- a/packages/component-manuscript/src/components/ManuscriptPage.js +++ b/packages/component-manuscript/src/components/ManuscriptPage.js @@ -32,7 +32,6 @@ import { import ManuscriptLayout from './ManuscriptLayout' import { parseSearchParams, redirectToError } from './utils' -import { canSeeEditorialComments } from '../../../component-faraday-selectors' export default compose( setDisplayName('ManuscriptPage'), @@ -41,10 +40,7 @@ export default compose( withState('editorInChief', 'setEiC', 'N/A'), ConnectPage(({ match }) => [ actions.getCollection({ id: match.params.project }), - actions.getFragment( - { id: match.params.project }, - { id: match.params.version }, - ), + actions.getFragments({ id: match.params.project }), ]), connect( (state, { match }) => ({ @@ -53,10 +49,9 @@ export default compose( hasManuscriptFailure: hasManuscriptFailure(state), version: selectFragment(state, match.params.version), project: selectCollection(state, match.params.project), - editorialRecommendations: selectEditorialRecommendations(state), - canSeeEditorialComments: canSeeEditorialComments( + editorialRecommendations: selectEditorialRecommendations( state, - match.params.project, + match.params.version, ), }), { @@ -64,6 +59,7 @@ export default compose( getSignedUrl, clearCustomError, reviewerDecision, + getFragment: actions.getFragment, getCollection: actions.getCollection, updateVersion: actions.updateFragment, }, @@ -99,6 +95,7 @@ export default compose( replace, history, location, + getFragment, getCollection, reviewerDecision, setEditorInChief, @@ -111,11 +108,15 @@ export default compose( } const collectionId = match.params.project + const fragmentId = match.params.version const { agree, invitationId } = parseSearchParams(location.search) if (agree === 'true') { replace(location.pathname) - reviewerDecision(invitationId, collectionId, true) - .then(() => getCollection({ id: match.params.project })) + reviewerDecision(invitationId, collectionId, fragmentId, true) + .then(() => { + getCollection({ id: collectionId }) + getFragment({ id: collectionId }, { id: fragmentId }) + }) .catch(redirectToError(replace)) } diff --git a/packages/component-manuscript/src/components/ReviewReportCard.js b/packages/component-manuscript/src/components/ReviewReportCard.js index ff357bec2509e18d1a2d164f1f8a26945ec0f8cf..9617ac681434f4718e7494dd11a2fba86f679724 100644 --- a/packages/component-manuscript/src/components/ReviewReportCard.js +++ b/packages/component-manuscript/src/components/ReviewReportCard.js @@ -12,6 +12,7 @@ import { ShowMore } from './' const ReviewReportCard = ({ i = 0, report = {}, + showBorder = false, journal: { recommendations }, }) => { const hasReviewer = !isEmpty(get(report, 'user')) @@ -24,7 +25,7 @@ const ReviewReportCard = ({ ) return ( - <Root hasReviewer={hasReviewer}> + <Root showBorder={showBorder}> {hasReviewer && ( <Row> <Text> @@ -117,7 +118,7 @@ const Root = styled.div` margin: auto; border: none; padding: 0; - ${({ hasReviewer }) => (hasReviewer ? cardStyle : null)}; + ${({ showBorder }) => (showBorder ? cardStyle : null)}; ` const Text = styled.div` ${defaultText}; diff --git a/packages/component-manuscript/src/components/ReviewReportsList.js b/packages/component-manuscript/src/components/ReviewReportsList.js new file mode 100644 index 0000000000000000000000000000000000000000..ee47831817b963a6dc04a3d0361ffed6d2a98c64 --- /dev/null +++ b/packages/component-manuscript/src/components/ReviewReportsList.js @@ -0,0 +1,21 @@ +import React, { Fragment } from 'react' +import { ReviewReportCard } from './' + +const ReviewReportsList = ({ recommendations, showBorder }) => ( + <Fragment> + {recommendations.length ? ( + recommendations.map((r, index) => ( + <ReviewReportCard + i={index + 1} + key={r.id} + report={r} + showBorder={showBorder} + /> + )) + ) : ( + <div>No reports submitted yet.</div> + )} + </Fragment> +) + +export default ReviewReportsList diff --git a/packages/component-manuscript/src/components/ReviewerReportForm.js b/packages/component-manuscript/src/components/ReviewerReportForm.js index 946bdb61278e7c2f535cb63a450b158beff505c8..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' @@ -38,9 +37,8 @@ import { withModal, ConfirmationModal, } from 'pubsweet-component-modal/src/components' + import { - selectError, - selectFetching, createRecommendation, updateRecommendation, } from 'pubsweet-components-faraday/src/redux/recommendations' @@ -54,12 +52,13 @@ import { const guidelinesLink = 'https://about.hindawi.com/authors/peer-review-at-hindawi/' +const TextAreaField = input => <Textarea {...input} height={70} rows={6} /> + const ReviewerReportForm = ({ addFile, fileError, removeFile, changeField, - errorRequest, isSubmitting, handleSubmit, fileFetching, @@ -108,38 +107,32 @@ const ReviewerReportForm = ({ )} </Row> <Row> - <FullWidth> + <FullWidth className="full-width"> <ValidatedField - component={input => ( - <Textarea - {...input} - hasError={input.validationStatus === 'error'} - onChange={e => changeField('public', e.target.value)} - readOnly={fileFetching.review} - rows={6} - /> - )} + component={TextAreaField} name="public" - readOnly={fileFetching.review} validate={isEmpty(formValues.files) ? [required] : []} /> </FullWidth> </Row> {formValues.files && ( - <Row left> - {formValues.files.map(file => ( - <FileItem - compact - id={file.id} - key={file.id} - {...file} - removeFile={removeFile} - /> - ))} - </Row> + <Fragment> + <Row left> + {formValues.files.map(file => ( + <FileItem + compact + id={file.id} + key={file.id} + {...file} + removeFile={removeFile} + /> + ))} + </Row> + </Fragment> )} {formValues.hasConfidential ? ( <Fragment> + <Spacing /> <Row> <Label> Note for the editorial team <i>Not shared with the author</i> @@ -154,17 +147,8 @@ const ReviewerReportForm = ({ <Row> <FullWidth> <ValidatedField - component={input => ( - <Textarea - {...input} - hasError={input.validationStatus === 'error'} - onChange={e => changeField('confidential', e.target.value)} - readOnly={fileFetching.review} - rows={6} - /> - )} + component={TextAreaField} name="confidential" - readOnly={fileFetching.review} validate={[required]} /> </FullWidth> @@ -184,11 +168,6 @@ const ReviewerReportForm = ({ <ErrorText>{fileError}</ErrorText> </Row> )} - {errorRequest && ( - <Row> - <ErrorText>{errorRequest}</ErrorText> - </Row> - )} <Row> <ActionButton onClick={handleSubmit}> Submit report </ActionButton> <AutosaveIndicator @@ -201,8 +180,7 @@ const ReviewerReportForm = ({ const ModalWrapper = compose( connect(state => ({ - modalError: selectError(state), - fetching: selectFetching(state), + fetching: false, })), )(({ fetching, ...rest }) => ( <ConfirmationModal {...rest} isFetching={fetching} /> @@ -212,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), @@ -267,7 +243,7 @@ export default compose( form: 'reviewerReport', onChange: onReviewChange, onSubmit: onReviewSubmit, - enableReinitialize: true, + enableReinitialize: false, keepDirtyOnReinitialize: true, }), )(ReviewerReportForm) @@ -328,7 +304,7 @@ const Textarea = styled.textarea` const Spacing = styled.div` flex: 1; - margin-top: ${th('gridUnit')}; + margin-top: calc(${th('gridUnit')} / 2); ` const FullWidth = styled.div` @@ -347,6 +323,10 @@ const Row = styled.div` flex: 1; flex-wrap: wrap; justify-content: ${({ left }) => (left ? 'left' : 'space-between')}; + + div[role='alert'] { + margin-top: 0; + } ` const ActionButton = styled(Button)` diff --git a/packages/component-manuscript/src/components/ReviewsAndReports.js b/packages/component-manuscript/src/components/ReviewsAndReports.js index b498bf92a2eab8ad79f3071057a1adf539c29efd..65b65ac5911654faa93b86737e6a840b0c5285de 100644 --- a/packages/component-manuscript/src/components/ReviewsAndReports.js +++ b/packages/component-manuscript/src/components/ReviewsAndReports.js @@ -1,8 +1,9 @@ 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 { withRouter } from 'react-router-dom' import { compose, withHandlers, lifecycle, withProps } from 'recompose' import { ReviewerBreakdown } from 'pubsweet-components-faraday/src/components/Invitations' import ReviewersDetailsList from 'pubsweet-components-faraday/src/components/Reviewers/ReviewersDetailsList' @@ -12,11 +13,14 @@ import { getCollectionReviewers, selectFetchingReviewers, } from 'pubsweet-components-faraday/src/redux/reviewers' -import { selectRecommendations } from 'pubsweet-components-faraday/src/redux/recommendations' +import { selectReviewRecommendations } from 'pubsweet-components-faraday/src/redux/recommendations' +import { + canSeeReviewersReports, + currentUserIsAuthor, +} from 'pubsweet-component-faraday-selectors' import { Tabs, Expandable } from '../molecules' -import { ReviewReportCard, ReviewerReportForm } from './' -import { canSeeReviewersReports } from '../../../component-faraday-selectors' +import { ReviewReportCard, ReviewerReportForm, ReviewReportsList } from './' const getTabSections = (collectionId, reviewers, recommendations = []) => [ { @@ -29,35 +33,24 @@ const getTabSections = (collectionId, reviewers, recommendations = []) => [ { key: 2, label: 'Reviewer Reports', - content: ( - <Fragment> - {recommendations.length ? ( - recommendations.map((r, index) => ( - <ReviewReportCard i={index + 1} key={r.id} report={r} /> - )) - ) : ( - <div>No reports submitted yet.</div> - )} - </Fragment> - ), + content: <ReviewReportsList recommendations={recommendations} showBorder />, }, ] const ReviewsAndReports = ({ - report, project, version, + isAuthor, isReviewer, mappedReviewers, mappedRecommendations, canSeeReviewersReports, - review = {}, - reviewers = [], + reviewerRecommendation, recommendations = [], }) => ( <Fragment> {canSeeReviewersReports && ( - <Root> + <Root id="reviews-and-reports"> <Expandable label="Reviewers & Reports" rightHTML={ @@ -80,38 +73,48 @@ const ReviewsAndReports = ({ </Root> )} {isReviewer && ( - <Root id="review-report"> + <Root id="reviewer-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} /> )} </Expandable> </Root> )} + {isAuthor && + !!recommendations.length && ( + <Root id="review-reports"> + <Expandable label="Reports" startExpanded> + <ReviewReportsList recommendations={recommendations} showBorder /> + </Expandable> + </Root> + )} </Fragment> ) export default compose( + withRouter, 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), + isAuthor: currentUserIsAuthor(state, version.id), + recommendations: selectReviewRecommendations(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,12 +130,21 @@ export default compose( withProps(({ recommendations = [] }) => ({ review: head(recommendations), report: head(recommendations.filter(r => r.submittedOn)), + reviewerRecommendation: head(recommendations), })), lifecycle({ componentDidMount() { const { getReviewers, canSeeReviewersReports } = this.props canSeeReviewersReports && getReviewers() }, + componentWillReceiveProps(nextProps) { + const { match, canSeeReviewersReports, getReviewers } = this.props + const version = get(match, 'params.version') + const nextVersion = get(nextProps, 'match.params.version') + if (version !== nextVersion) { + canSeeReviewersReports && getReviewers() + } + }, }), )(ReviewsAndReports) diff --git a/packages/component-manuscript/src/components/SideBarActions.js b/packages/component-manuscript/src/components/SideBarActions.js index 01f60511fa7ccfb30a467976c768c876c80e4fe2..76a9369e78aecb0fca7ef61a44ae14629345773a 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, version }) => ({ + canMakeDecision: canMakeDecision(state, project), + canMakeRevision: canMakeRevision(state, project, version), + 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/index.js b/packages/component-manuscript/src/components/index.js index 3509c110448651a67ba4e2659d1bf1bb30abf404..dadf8d4e6ffd637ee1faed3f90c00d99978b5c20 100644 --- a/packages/component-manuscript/src/components/index.js +++ b/packages/component-manuscript/src/components/index.js @@ -13,3 +13,4 @@ export { default as ManuscriptVersion } from './ManuscriptVersion' export { default as ReviewsAndReports } from './ReviewsAndReports' export { default as EditorialComments } from './EditorialComments' export { default as ReviewerReportForm } from './ReviewerReportForm' +export { default as ReviewReportsList } from './ReviewReportsList' diff --git a/packages/component-manuscript/src/components/utils.js b/packages/component-manuscript/src/components/utils.js index d9785efb43eeaa1501c8f74b40d292c0d308a4c0..5b2221a561a185987d824271e9dac2fc7fa6b926 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 { get, find, capitalize, omit, isEmpty, debounce } from 'lodash' +import { actions } from 'pubsweet-client/src' +import { change as changeForm } from 'redux-form' import { autosaveRequest, autosaveSuccess, @@ -72,9 +74,7 @@ export const parseSearchParams = url => { export const parseVersionOptions = (fragments = []) => fragments.map(f => ({ value: f.id, - label: `Version ${f.version} - updated on ${moment(f.submitted).format( - 'DD.MM.YYYY', - )}`, + label: `Version ${f.version}`, })) const alreadyAnswered = `You have already answered this invitation.` @@ -88,7 +88,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 +102,7 @@ export const parseReviewResponseToForm = (review = {}) => { } export const parseReviewRequest = (review = {}) => { - if (isEmpty(review)) return null + if (isEmpty(review)) return {} const comments = [ { public: true, @@ -135,23 +135,23 @@ const onChange = ( values, dispatch, { project, version, createRecommendation, updateRecommendation }, - previousValues, ) => { const newValues = parseReviewRequest(values) - const prevValues = parseReviewRequest(previousValues) - - if (!isEqual(newValues, prevValues)) { - dispatch(autosaveRequest()) - if (newValues.id) { - updateRecommendation(project.id, version.id, newValues) - .then(r => dispatch(autosaveSuccess(get(r, 'updatedOn')))) - .catch(e => dispatch(autosaveFailure(e))) - } else { - createRecommendation(project.id, version.id, newValues) - .then(r => dispatch(autosaveSuccess(get(r, 'updatedOn')))) - .catch(e => dispatch(autosaveFailure(e))) - } + // if (!isEqual(newValues, prevValues)) { + dispatch(autosaveRequest()) + if (newValues.id) { + updateRecommendation(project.id, version.id, newValues) + .then(r => dispatch(autosaveSuccess(get(r, 'updatedOn')))) + .catch(e => dispatch(autosaveFailure(e))) + } else { + createRecommendation(project.id, version.id, newValues) + .then(r => { + dispatch(changeForm('reviewerReport', 'id', r.id)) + return dispatch(autosaveSuccess(get(r, 'updatedOn'))) + }) + .catch(e => dispatch(autosaveFailure(e))) } + // } } export const onReviewChange = debounce(onChange, 1000, { maxWait: 5000 }) @@ -178,7 +178,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({ id: project.id })) + hideModal() + }) }, onCancel: hideModal, }) diff --git a/packages/component-manuscript/src/molecules/AuthorsWithTooltip.js b/packages/component-manuscript/src/molecules/AuthorsWithTooltip.js index 79532b138eb7b9aefda592ea107e58b6273a0b22..65e3645df6b138f53668f496428c4e0c50347160 100644 --- a/packages/component-manuscript/src/molecules/AuthorsWithTooltip.js +++ b/packages/component-manuscript/src/molecules/AuthorsWithTooltip.js @@ -64,7 +64,7 @@ const AuthorsWithTooltip = ({ {authors.map( ( { - userId, + id, isSubmitting, isCorresponding, email = '', @@ -83,7 +83,7 @@ const AuthorsWithTooltip = ({ email={email} isCorresponding={isCorresponding} isSubmitting={isSubmitting} - key={userId} + key={id} > <DefaultComponent arr={arr} diff --git a/packages/component-modal/src/components/withModal.js b/packages/component-modal/src/components/withModal.js index 18f16ebcb917ffc7242b4242c6676fb8a127c771..35bb923623393be380a2af970af01ada1733a88e 100644 --- a/packages/component-modal/src/components/withModal.js +++ b/packages/component-modal/src/components/withModal.js @@ -23,12 +23,13 @@ const withModal = mapperFn => BaseComponent => compose(connect(mapState, mapDispatch))(baseProps => { const { modalComponent: Component, overlayColor } = mapperFn(baseProps) const { + modalKey, + showModal, hideModal, modalProps, modalError, + setModalError, modalsVisibility, - modalKey, - showModal, ...rest } = baseProps return ( 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..9c663beae1962d9fc13141f8439dc0a84214ae08 --- /dev/null +++ b/packages/component-user-manager/config/authsome-mode.js @@ -0,0 +1,3 @@ +const authsomeMode = require('xpub-faraday/config/authsome-mode') + +module.exports = authsomeMode diff --git a/packages/component-user-manager/config/default.js b/packages/component-user-manager/config/default.js index 350b9de7761a91153fff07c37b29603005d36c33..9950c9b354fa8710a4f043a07ac2b86a3cf6bd2f 100644 --- a/packages/component-user-manager/config/default.js +++ b/packages/component-user-manager/config/default.js @@ -1,19 +1,3 @@ -module.exports = { - mailer: { - from: 'test@example.com', - }, - 'invite-reset-password': { - url: - process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || - 'http://localhost:3000/invite', - }, - roles: { - global: ['admin', 'editorInChief', 'author', 'handlingEditor'], - collection: ['handlingEditor', 'reviewer'], - inviteRights: { - admin: ['admin', 'editorInChief', 'author'], - editorInChief: ['handlingEditor'], - handlingEditor: ['reviewer'], - }, - }, -} +const defaultConfig = require('xpub-faraday/config/default') + +module.exports = defaultConfig diff --git a/packages/component-user-manager/config/test.js b/packages/component-user-manager/config/test.js index a1e52fc0b730d1b5e7836ac08eb6b0188b3c13ae..9950c9b354fa8710a4f043a07ac2b86a3cf6bd2f 100644 --- a/packages/component-user-manager/config/test.js +++ b/packages/component-user-manager/config/test.js @@ -1,20 +1,3 @@ -module.exports = { - mailer: { - from: 'test@example.com', - }, - 'invite-reset-password': { - url: - process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || - 'http://localhost:3000/invite', - }, - roles: { - global: ['admin', 'editorInChief', 'author', 'handlingEditor'], - collection: ['handlingEditor', 'reviewer', 'author'], - inviteRights: { - admin: ['admin', 'editorInChief', 'author', 'handlingEditor', 'author'], - editorInChief: ['handlingEditor'], - handlingEditor: ['reviewer'], - author: ['author'], - }, - }, -} +const defaultConfig = require('xpub-faraday/config/default') + +module.exports = defaultConfig 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/package.json b/packages/component-user-manager/package.json index 0d58cca77bc2e2b3c6162fae78e292c2d0a60e08..c65b0553de09100b8c477dfdab49531406d95536 100644 --- a/packages/component-user-manager/package.json +++ b/packages/component-user-manager/package.json @@ -25,7 +25,8 @@ "peerDependencies": { "@pubsweet/logger": "^0.0.1", "pubsweet-component-mail-service": "0.0.1", - "pubsweet-server": "^1.0.1" + "pubsweet-server": "^1.0.1", + "pubsweet-component-helper-service": "0.0.1" }, "devDependencies": { "apidoc": "^0.17.6", 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/Users.js b/packages/component-user-manager/src/Users.js index 3fdfb1b60b5d7428daa93b84599c5f9024d18f12..1c5c159ddfd35f3d654dc3e593c1c74acaed283e 100644 --- a/packages/component-user-manager/src/Users.js +++ b/packages/component-user-manager/src/Users.js @@ -30,7 +30,7 @@ const Invite = app => { * "editorInChief": false, * "handlingEditor": false * } - * @apiErrorExample {json} Invite user errors + * @apiErrorExample {json} Reset password errors * HTTP/1.1 400 Bad Request * HTTP/1.1 404 Not Found */ @@ -38,6 +38,56 @@ const Invite = app => { '/api/users/reset-password', require('./routes/users/resetPassword')(app.locals.models), ) + /** + * @api {post} /api/users/confirm Confirm user + * @apiGroup Users + * @apiParamExample {json} Body + * { + * "userId": "valid-user-id", + * "confirmationToken": "12312321" + * } + * @apiSuccessExample {json} Success + * HTTP/1.1 200 OK + * { + * "id": "a6184463-b17a-42f8-b02b-ae1d755cdc6b", + * "type": "user", + * "admin": false, + * "email": "email@example.com", + * "teams": [], + * "username": "email@example.com", + * "fragments": [], + * "collections": [], + * "isConfirmed": true, + * "editorInChief": false, + * "handlingEditor": false + * } + * @apiErrorExample {json} Forgot Password errors + * HTTP/1.1 400 Bad Request + * HTTP/1.1 404 Not Found + */ + app.post( + '/api/users/confirm', + require('./routes/users/confirm')(app.locals.models), + ) + /** + * @api {post} /api/users/forgot-password Forgot password + * @apiGroup Users + * @apiParamExample {json} Body + * { + * "email": "email@example.com", + * } + * @apiSuccessExample {json} Success + * HTTP/1.1 200 OK + * { + * "message": "A password reset email has been sent to email@example.com" + * } + * @apiErrorExample {json} Forgot Password errors + * HTTP/1.1 400 Bad Request + */ + app.post( + '/api/users/forgot-password', + require('./routes/users/forgotPassword')(app.locals.models), + ) } module.exports = Invite 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..b39893eb77b350f0e1357bda51266ff4a1654063 --- /dev/null +++ b/packages/component-user-manager/src/routes/fragmentsUsers/delete.js @@ -0,0 +1,37 @@ +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 fragment = await models.Fragment.find(fragmentId) + 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 + user.save() + + fragment.authors = fragment.authors || [] + fragment.authors = fragment.authors.filter(author => author.id !== userId) + fragment.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/confirm.js b/packages/component-user-manager/src/routes/users/confirm.js new file mode 100644 index 0000000000000000000000000000000000000000..ace2592bff912eaa12bb8b6ac0a7c26fee52c61e --- /dev/null +++ b/packages/component-user-manager/src/routes/users/confirm.js @@ -0,0 +1,35 @@ +const { token } = require('pubsweet-server/src/authentication') +const { services } = require('pubsweet-component-helper-service') + +module.exports = ({ User }) => async (req, res) => { + const { userId, confirmationToken } = req.body + + if (!services.checkForUndefinedParams(userId, confirmationToken)) + return res.status(400).json({ error: 'Missing required params' }) + + let user + try { + user = await User.find(userId) + + if (user.confirmationToken !== confirmationToken) { + return res.status(400).json({ error: 'Wrong confirmation token.' }) + } + + if (user.isConfirmed) + return res.status(400).json({ error: 'User is already confirmed.' }) + + user.isConfirmed = true + delete user.confirmationToken + await user.save() + + return res.status(200).json({ + ...user, + token: token.create(user), + }) + } catch (e) { + const notFoundError = await services.handleNotFoundError(e, 'User') + return res.status(notFoundError.status).json({ + error: notFoundError.message, + }) + } +} diff --git a/packages/component-user-manager/src/routes/users/forgotPassword.js b/packages/component-user-manager/src/routes/users/forgotPassword.js new file mode 100644 index 0000000000000000000000000000000000000000..cbc5d3b686af0f74086420176bfe57bfcd00e635 --- /dev/null +++ b/packages/component-user-manager/src/routes/users/forgotPassword.js @@ -0,0 +1,50 @@ +const logger = require('@pubsweet/logger') +const { services } = require('pubsweet-component-helper-service') +const mailService = require('pubsweet-component-mail-service') + +module.exports = models => async (req, res) => { + const { email } = req.body + if (!services.checkForUndefinedParams(email)) + return res.status(400).json({ error: 'Email address is required.' }) + + try { + const user = await models.User.findByEmail(email) + if (user.passwordResetTimestamp) { + const resetDate = new Date(user.passwordResetTimestamp) + const hoursPassed = Math.floor( + (new Date().getTime() - resetDate) / 3600000, + ) + if (hoursPassed < 24) { + return res + .status(400) + .json({ error: 'A password reset has already been requested.' }) + } + } + + user.passwordResetToken = generatePasswordHash() + user.passwordResetTimestamp = Date.now() + await user.save() + + mailService.sendSimpleEmail({ + toEmail: user.email, + user, + emailType: 'forgot-password', + dashboardUrl: services.getBaseUrl(req), + }) + } catch (e) { + logger.error( + `A forgot password request has been made on an non-existent email: ${email}`, + ) + } + + res.status(200).json({ + message: `A password reset email has been sent to ${email}.`, + }) +} + +const generatePasswordHash = () => + Array.from({ length: 4 }, () => + Math.random() + .toString(36) + .slice(4), + ).join('') diff --git a/packages/component-user-manager/src/routes/users/resetPassword.js b/packages/component-user-manager/src/routes/users/resetPassword.js index 6b6dc173e2db18ac9bd666505cda16c7042cb9fd..70257a1fb7ba13bddc50c8e8043f4a533f06d2a7 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 @@ -23,11 +23,9 @@ module.exports = models => async (req, res) => { let { user } = validateResponse - if (user.isConfirmed) - return res.status(400).json({ error: 'User is already confirmed' }) - req.body.isConfirmed = true delete user.passwordResetToken + delete user.passwordResetTimestamp delete req.body.token user = await user.updateProperties(req.body) 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/confirm.test.js b/packages/component-user-manager/src/tests/users/confirm.test.js new file mode 100644 index 0000000000000000000000000000000000000000..6f139d1f227c51ea1f8e990464921a7a1da01068 --- /dev/null +++ b/packages/component-user-manager/src/tests/users/confirm.test.js @@ -0,0 +1,83 @@ +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 + +const { user, author } = fixtures.users +jest.mock('pubsweet-component-mail-service', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), +})) + +const reqBody = { + userId: user.id, + confirmationToken: user.confirmationToken, +} + +const notFoundError = new Error() +notFoundError.name = 'NotFoundError' +notFoundError.status = 404 +const forgotPasswordPath = '../../routes/users/confirm' + +describe('Users confirm 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.userId + const req = httpMocks.createRequest({ body }) + + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Missing required params') + }) + it('should return an error when the confirmation token does not match', async () => { + body.confirmationToken = 'invalid-token' + + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Wrong confirmation token.') + }) + it('should return an error when the user does not exist', async () => { + body.userId = 'invalid-user-id' + + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(404) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('User not found') + }) + it('should return an error when the user is already confirmed', async () => { + body.userId = author.id + body.confirmationToken = author.confirmationToken + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('User is already confirmed.') + }) + it('should return success when the body is correct', async () => { + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + const data = JSON.parse(res._getData()) + expect(data.token).toBeDefined() + }) +}) diff --git a/packages/component-user-manager/src/tests/users/forgotPassword.test.js b/packages/component-user-manager/src/tests/users/forgotPassword.test.js new file mode 100644 index 0000000000000000000000000000000000000000..e6f51c974dabb9e51ff1dc0063c43a8b99ada457 --- /dev/null +++ b/packages/component-user-manager/src/tests/users/forgotPassword.test.js @@ -0,0 +1,78 @@ +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 + +const { user, author } = fixtures.users +jest.mock('pubsweet-component-mail-service', () => ({ + sendSimpleEmail: jest.fn(), + sendNotificationEmail: jest.fn(), +})) + +const reqBody = { + email: user.email, +} + +const notFoundError = new Error() +notFoundError.name = 'NotFoundError' +notFoundError.status = 404 +const forgotPasswordPath = '../../routes/users/forgotPassword' + +describe('Users forgot password 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() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('Email address is required.') + }) + it('should return an error when the user has already requested a password reset', async () => { + body.email = author.email + + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + expect(res.statusCode).toBe(400) + const data = JSON.parse(res._getData()) + expect(data.error).toEqual('A password reset has already been requested.') + }) + it('should return success when the body is correct', async () => { + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.message).toEqual( + `A password reset email has been sent to ${body.email}.`, + ) + }) + it('should return success if the email is non-existent', async () => { + body.email = 'email@example.com' + const req = httpMocks.createRequest({ body }) + const res = httpMocks.createResponse() + await require(forgotPasswordPath)(models)(req, res) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + expect(data.message).toEqual( + `A password reset email has been sent to ${body.email}.`, + ) + }) +}) 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..130526eb7f9bca6bf3cb00fd24a7e96cd3d8e2b7 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 } = 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,57 @@ 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 - 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 72de7cfb330156155dfd5e4f1e3c4d0a04f1dc8c..7d2840160e3e7212749ca1b010b0b9ccf687a594 100644 --- a/packages/component-wizard/package.json +++ b/packages/component-wizard/package.json @@ -9,14 +9,15 @@ "dist" ], "dependencies": { - "@pubsweet/ui": "^3.1.0", + "@pubsweet/ui": "4.1.3", + "@pubsweet/ui-toolkit": "latest", "moment": "^2.20.1", "react-dnd": "^2.5.4", "react-dnd-html5-backend": "^2.5.4", "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..c4caa8b12f1bb533d416e01c1d2c716ed6908d0f 100644 --- a/packages/component-wizard/src/components/WizardFormStep.js +++ b/packages/component-wizard/src/components/WizardFormStep.js @@ -1,12 +1,17 @@ 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' import { autosaveRequest } from '../redux/autosave' +import { + submitRevision, + isRevisionFlow, + submitManuscript as submitNewManuscript, +} from '../redux/conversion' const wizardSelector = formValueSelector('wizard') @@ -14,9 +19,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)) { @@ -30,29 +34,36 @@ const onChange = ( } } -const submitManuscript = ( +const submitManuscript = ({ values, - dispatch, + version, project, + history, + dispatch, + redirectPath = '/', +}) => { + submitNewManuscript(project.id, version.id) + .then(() => { + history.push(redirectPath, { + project: project.id, + customId: project.customId, + version: version.id, + }) + }) + .catch(error => { + if (error.validationErrors) { + throw new SubmissionError() + } + }) +} + +const submitFragmentRevision = ({ version, + project, history, redirectPath = '/', -) => { - dispatch( - actions.updateFragment(project, { - id: version.id, - submitted: new Date(), - ...values, - }), - ) - .then(() => - dispatch( - actions.updateCollection({ - id: project.id, - status: 'submitted', - }), - ), - ) +}) => { + submitRevision(project.id, version.id) .then(() => { history.push(redirectPath, { project: project.id, @@ -71,14 +82,15 @@ const onSubmit = ( values, dispatch, { - nextStep, isFinal, history, project, version, + nextStep, confirmation, - wizard: { confirmationModal, submissionRedirect, formSectionKeys }, + isRevisionFlow, toggleConfirmation, + wizard: { confirmationModal, submissionRedirect, formSectionKeys }, ...rest }, ) => { @@ -86,20 +98,27 @@ const onSubmit = ( nextStep() } else if (confirmationModal && !confirmation) { toggleConfirmation() - } else { - const newValues = pick(values, formSectionKeys) - submitManuscript( - newValues, - dispatch, + } else if (isRevisionFlow) { + submitFragmentRevision({ + version, project, + history, + redirectPath: submissionRedirect, + }) + } else { + submitManuscript({ version, + project, history, - submissionRedirect, - ) + dispatch, + redirectPath: submissionRedirect, + values: pick(values, formSectionKeys), + }) } } export default compose( + setDisplayName('SubmitWizard'), getContext({ history: PropTypes.object, isFinal: PropTypes.bool, @@ -111,13 +130,35 @@ export default compose( confirmation: PropTypes.bool, toggleConfirmation: PropTypes.func, }), - withProps(({ version, wizard }) => ({ - initialValues: pick(version, wizard.formSectionKeys), - readonly: !!get(version, 'submitted'), - })), - connect((state, { wizard: { formSectionKeys } }) => ({ + connect((state, { wizard: { formSectionKeys }, project, version }) => ({ formValues: wizardSelector(state, ...formSectionKeys), + isRevisionFlow: isRevisionFlow(state, project, version), })), + withProps( + ({ + version, + isFirst, + isFinal, + isRevisionFlow, + wizard: { + formSectionKeys, + backText = 'Back', + nextText = 'Next', + cancelText = 'Cancel', + submitText = 'Submit Manuscript', + revisionText = 'Submit Revision', + }, + }) => ({ + readonly: !!get(version, 'submitted'), + initialValues: pick(version, formSectionKeys), + buttons: { + backText: isFirst ? cancelText : backText, + nextText: !isFinal // eslint-disable-line + ? nextText + : isRevisionFlow ? revisionText : submitText, + }, + }), + ), reduxForm({ form: 'wizard', forceUnregisterOnUnmount: true, diff --git a/packages/component-wizard/src/components/WizardStep.js b/packages/component-wizard/src/components/WizardStep.js index 869bbd53c983e36b04ef7f0edc7b7689a5fb5450..a9a873575b7e77019ba0a515635dbf798ca4eb67 100644 --- a/packages/component-wizard/src/components/WizardStep.js +++ b/packages/component-wizard/src/components/WizardStep.js @@ -6,21 +6,22 @@ import { ValidatedField, Button, th } from '@pubsweet/ui' import AutosaveIndicator from './AutosaveIndicator' export default ({ - children: stepChildren, title, - subtitle, - buttons, - nextStep, - prevStep, - handleSubmit, + wizard, isFinal, isFirst, history, + nextStep, + subtitle, + prevStep, formValues, - wizard, dispatchFns, + handleSubmit, confirmation, + isRevisionFlow, toggleConfirmation, + children: stepChildren, + buttons: { backText, nextText }, wizard: { confirmationModal: ConfirmationModal }, ...rest }) => ( @@ -39,7 +40,6 @@ export default ({ validate, dependsOn, renderComponent: Comp, - format, parse, ...rest }) => { @@ -50,18 +50,18 @@ export default ({ return null } return ( - <ValidatedField - component={input => ( - <div data-test={fieldId}> - <Comp {...rest} {...input} {...dispatchFns} />{' '} - </div> - )} - format={format} - key={fieldId} - name={fieldId} - parse={parse} - validate={validate} - /> + <CustomValidatedField className="custom-field" key={fieldId}> + <ValidatedField + component={input => ( + <div data-test={fieldId}> + <Comp {...rest} {...input} {...dispatchFns} /> + </div> + )} + name={fieldId} + parse={parse} + validate={validate} + /> + </CustomValidatedField> ) }, )} @@ -70,14 +70,10 @@ export default ({ data-test="button-prev" onClick={isFirst ? () => history.push('/') : prevStep} > - {isFirst - ? `${wizard.cancelText || 'Cancel'}` - : `${wizard.backText || 'Back'}`} + {backText} </Button> <Button data-test="button-next" primary type="submit"> - {isFinal - ? `${wizard.submitText || 'Submit Manuscript'}` - : `${wizard.nextText || 'Next'}`} + {nextText} </Button> </ButtonContainer> {confirmation && ( @@ -90,6 +86,15 @@ export default ({ </Root> ) // #region styles + +const CustomValidatedField = styled.div` + div { + div:last-child { + margin-top: 0; + } + } +` + const Root = styled.div` align-items: stretch; background-color: ${th('colorTextReverse')}; diff --git a/packages/component-wizard/src/redux/conversion.js b/packages/component-wizard/src/redux/conversion.js index a4043ca5dd365aadc19f2f7a9e7908e89a592c6a..b88ae87c8175ffbbfbcba7d92ff1286932821795 100644 --- a/packages/component-wizard/src/redux/conversion.js +++ b/packages/component-wizard/src/redux/conversion.js @@ -1,7 +1,7 @@ -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' +import { create, update } from 'pubsweet-client/src/helpers/api' /* constants */ export const CREATE_DRAFT_REQUEST = 'CREATE_DRAFT_REQUEST' @@ -12,11 +12,6 @@ export const createDraftRequest = () => ({ type: CREATE_DRAFT_REQUEST, }) -export const createDraftSuccess = draft => ({ - type: CREATE_DRAFT_SUCCESS, - draft, -}) - /* utils */ const generateCustomId = () => moment @@ -24,19 +19,22 @@ const generateCustomId = () => .toString() .slice(-7) -const addSubmittingAuthor = (user, collectionId) => { +export const isRevisionFlow = (state, collection, fragment = {}) => + collection.fragments.length > 1 && !fragment.submitted + +/* actions */ +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, }) } -/* actions */ export const createDraftSubmission = history => (dispatch, getState) => { const currentUser = getState().currentUser.user return dispatch( @@ -50,8 +48,11 @@ export const createDraftSubmission = history => (dispatch, getState) => { return dispatch( actions.createFragment(collection, { created: new Date(), // TODO: set on server + collectionId: collection.id, files: { + manuscripts: [], supplementary: [], + coverLetter: [], }, fragmentType: 'version', metadata: {}, @@ -63,7 +64,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 @@ -74,6 +75,41 @@ export const createDraftSubmission = history => (dispatch, getState) => { }) } +export const submitManuscript = (collectionId, fragmentId) => + create(`/collections/${collectionId}/fragments/${fragmentId}/submit`) + +export const createRevision = ( + collection, + previousVersion, + history, +) => dispatch => { + // copy invitations only if minor revision + const { + id, + submitted, + recommendations, + invitations, + ...prev + } = previousVersion + return dispatch( + actions.createFragment(collection, { + ...prev, + invitations: invitations.filter(inv => inv.isAccepted), + 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 + }) +} + +export const submitRevision = (collId, fragId) => + update(`/collections/${collId}/fragments/${fragId}/submit`) + /* reducer */ const initialState = { complete: undefined, diff --git a/packages/components-faraday/package.json b/packages/components-faraday/package.json index e6e08d40317a695c3aedff0b059a2a7a12eb7608..729e5ebdbf6769f3f0065943e96c68f12ef0e215 100644 --- a/packages/components-faraday/package.json +++ b/packages/components-faraday/package.json @@ -4,7 +4,8 @@ "main": "src", "license": "MIT", "dependencies": { - "@pubsweet/ui": "^3.1.0", + "@pubsweet/ui": "4.1.3", + "@pubsweet/ui-toolkit": "latest", "moment": "^2.22.1", "prop-types": "^15.5.10", "react": "^16.1.0", @@ -15,7 +16,14 @@ "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" + }, + "scripts": { + "test": "jest" + }, + "jest": { + "verbose": true, + "testRegex": "/src/.*.test.js$" } } diff --git a/packages/components-faraday/src/components/Admin/AddEditUser.js b/packages/components-faraday/src/components/Admin/AddEditUser.js index 08223f1067bbcd57b9f9e4be533c21bac1654b27..2b76bde79831c4296b5bfd4c555fa5c178c538e7 100644 --- a/packages/components-faraday/src/components/Admin/AddEditUser.js +++ b/packages/components-faraday/src/components/Admin/AddEditUser.js @@ -49,6 +49,7 @@ const AddEditUser = ({ user, history, error, + submitting, }) => ( <Root> <FormContainer onSubmit={handleSubmit}> @@ -68,7 +69,7 @@ const AddEditUser = ({ )} <Row> <Button onClick={history.goBack}>Back</Button> - <Button primary type="submit"> + <Button disabled={submitting} primary type="submit"> Save user </Button> </Row> diff --git a/packages/components-faraday/src/components/Admin/EditUserForm.js b/packages/components-faraday/src/components/Admin/EditUserForm.js index 20971b7faf88ed16396bc5efd7c6e9d98f905c67..95025eea1ab0eaf5174df5a7abd2d5c357890daa 100644 --- a/packages/components-faraday/src/components/Admin/EditUserForm.js +++ b/packages/components-faraday/src/components/Admin/EditUserForm.js @@ -108,11 +108,17 @@ const Row = styled.div` display: flex; flex-direction: row; margin: calc(${th('subGridUnit')}*3) 0; + div[role='alert'] { + margin-top: 0; + } ` const RowItem = styled.div` flex: 1; margin-right: calc(${th('subGridUnit')}*3); + label + div[role='alert'] { + margin-top: 0; + } ` const Title = styled.h4` diff --git a/packages/components-faraday/src/components/Admin/utils.js b/packages/components-faraday/src/components/Admin/utils.js index 9d1204cbbeaf5a4d05a82b63f5e8de425ac4f2b1..1029db45e7ae91d1919fa005dbb927398badd507 100644 --- a/packages/components-faraday/src/components/Admin/utils.js +++ b/packages/components-faraday/src/components/Admin/utils.js @@ -48,7 +48,8 @@ export const parseUpdateUser = values => { export const handleFormError = error => { const err = get(error, 'response') if (err) { - const errorMessage = get(JSON.parse(err), 'error') + const errorMessage = + get(JSON.parse(err), 'error') || get(JSON.parse(err), 'message') throw new SubmissionError({ _error: errorMessage || 'Something went wrong', }) diff --git a/packages/components-faraday/src/components/AppBar/AppBar.js b/packages/components-faraday/src/components/AppBar/AppBar.js index 9c04e8949ce0a1374f74314f3b920346534ec5dd..22f1f147483990676db2bebf431ade8038b2ce4e 100644 --- a/packages/components-faraday/src/components/AppBar/AppBar.js +++ b/packages/components-faraday/src/components/AppBar/AppBar.js @@ -6,63 +6,107 @@ import { withRouter } from 'react-router-dom' import styled, { withTheme } from 'styled-components' import { withState, withHandlers, compose } from 'recompose' +import { userNotConfirmed } from 'pubsweet-component-faraday-selectors' + const AppBar = ({ + goTo, + user, + brand, + theme, expanded, toggleMenu, - brand, - user, - goTo, currentUser, onLogoutClick, - theme, + shouldShowConfirmation, }) => ( - <Root> - <Brand> - {React.cloneElement(brand, { - onClick: goTo('/'), - })} - </Brand> - {user && ( - <User> - <div onClick={toggleMenu}> - <Icon color={theme.colorText}>user</Icon> - <span> - {get(user, 'firstName') || get(user, 'username') || 'User'} - </span> - <Icon color={theme.colorText}>chevron-down</Icon> - </div> - {expanded && ( - <Dropdown> - <DropdownOption>Settings</DropdownOption> - {currentUser.admin && ( - <DropdownOption onClick={goTo('/admin')}> - Admin dashboard - </DropdownOption> - )} - <DropdownOption onClick={onLogoutClick}>Logout</DropdownOption> - </Dropdown> - )} - </User> + <Root className="appbar"> + <Row bordered className="row"> + <Brand> + {React.cloneElement(brand, { + onClick: goTo('/'), + })} + </Brand> + {user && ( + <User> + <div onClick={toggleMenu}> + <Icon color={theme.colorText}>user</Icon> + <span> + {get(user, 'firstName') || get(user, 'username') || 'User'} + </span> + <Icon color={theme.colorText}>chevron-down</Icon> + </div> + {expanded && ( + <Dropdown> + <DropdownOption>Settings</DropdownOption> + {currentUser.admin && ( + <DropdownOption onClick={goTo('/admin')}> + Admin dashboard + </DropdownOption> + )} + <DropdownOption onClick={onLogoutClick}>Logout</DropdownOption> + </Dropdown> + )} + </User> + )} + </Row> + {shouldShowConfirmation && ( + <Row bgColor="salmon" centered className="row"> + <ConfirmationText> + Your author account is not confirmed. Check your email. + </ConfirmationText> + </Row> )} {expanded && <ToggleOverlay onClick={toggleMenu} />} </Root> ) +export default compose( + withRouter, + withTheme, + connect(state => ({ + currentUser: get(state, 'currentUser.user'), + shouldShowConfirmation: userNotConfirmed(state), + })), + withState('expanded', 'setExpanded', false), + withHandlers({ + toggleMenu: ({ setExpanded }) => () => { + setExpanded(v => !v) + }, + goTo: ({ setExpanded, history }) => path => () => { + setExpanded(v => false) + history.push(path) + }, + }), +)(AppBar) + // #region styled-components const Root = styled.div` align-items: center; - box-shadow: ${th('dropShadow')}; + background-color: #ffffff; font-family: ${th('fontInterface')}; display: flex; - justify-content: space-between; - height: 60px; + flex-direction: column; flex-grow: 1; + width: 100vw; + position: fixed; - width: 100%; + top: 0; z-index: 10; - background-color: #ffffff; ` +const Row = styled.div` + align-items: center; + align-self: stretch; + background-color: ${({ bgColor }) => bgColor || 'transparent'}; + box-shadow: ${({ bordered }) => (bordered ? th('dropShadow') : 'none')}; + display: flex; + justify-content: ${({ centered }) => + centered ? 'center' : 'space-between;'}; +` + +const ConfirmationText = styled.span` + font-size: ${th('fontSizeBaseSmall')}; +` const Brand = styled.div` padding: 10px 20px; cursor: pointer; @@ -92,12 +136,12 @@ const User = styled.div` const Dropdown = styled.div` background-color: ${th('colorBackground')}; - border: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')}; position: absolute; right: 20px; top: 60px; width: calc(${th('gridUnit')} * 8); z-index: 10; + border: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')}; ` const DropdownOption = styled.div` @@ -126,21 +170,3 @@ const ToggleOverlay = styled.div` opacity: 0; ` // #endregion - -export default compose( - withRouter, - withTheme, - connect(state => ({ - currentUser: get(state, 'currentUser.user'), - })), - withState('expanded', 'setExpanded', false), - withHandlers({ - toggleMenu: ({ setExpanded }) => () => { - setExpanded(v => !v) - }, - goTo: ({ setExpanded, history }) => path => () => { - setExpanded(v => false) - history.push(path) - }, - }), -)(AppBar) 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..023eaa2c99b3243b6b7d9a010facd6ae17f87513 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,28 @@ export default compose( reduxForm({ form: 'author', enableReinitialize: true, + destroyOnUnmount: false, onSubmit: ( values, dispatch, - { authors = [], addAuthor, setEditMode, setFormAuthors, reset, match }, + { reset, match, changeForm, addAuthor, setEditMode, 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..81a2561a58d7d6d3a21b4dde5b9cd1a6da263730 100644 --- a/packages/components-faraday/src/components/AuthorList/AuthorList.js +++ b/packages/components-faraday/src/components/AuthorList/AuthorList.js @@ -1,40 +1,40 @@ 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' import styled from 'styled-components' import { withRouter } from 'react-router-dom' +import { selectCurrentVersion } from 'xpub-selectors' 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 +50,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} @@ -83,9 +83,11 @@ export default compose( withRouter, getContext({ version: PropTypes.object, project: PropTypes.object }), connect( - state => ({ - currentUser: state.currentUser.user, + (state, { project }) => ({ error: getAuthorError(state), + currentUser: get(state, 'currentUser.user'), + version: selectCurrentVersion(state, project), + authorForm: wizardSelector(state, 'authorForm'), }), { addAuthor, @@ -95,86 +97,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/AssignHEModal.js b/packages/components-faraday/src/components/Dashboard/AssignHEModal.js index eb201ac3b5ed9a70ff97f57f435f8710d1b37bd2..31422da54264f232d1cf04b0f3f4ecb5034c5f80 100644 --- a/packages/components-faraday/src/components/Dashboard/AssignHEModal.js +++ b/packages/components-faraday/src/components/Dashboard/AssignHEModal.js @@ -6,7 +6,7 @@ import { compose } from 'recompose' import { connect } from 'react-redux' import styled from 'styled-components' import { actions } from 'pubsweet-client' -import { th, Icon, Spinner } from '@pubsweet/ui' +import { th, Icon, ErrorText, Spinner } from '@pubsweet/ui' import { selectFetching, @@ -61,7 +61,7 @@ class AssignHEModal extends React.Component { render() { const { searchInput } = this.state - const { editors, hideModal, isFetching } = this.props + const { editors, hideModal, isFetching, modalError } = this.props const filteredEditors = this.filterEditors(editors) return ( <RootModal> @@ -105,6 +105,7 @@ class AssignHEModal extends React.Component { ))} </ModalContent> </ScrollContainer> + <CustomError>{modalError}</CustomError> </RootModal> ) } @@ -125,6 +126,11 @@ export default compose( )(AssignHEModal) // #region styled-components +const CustomError = ErrorText.extend` + font-family: ${th('fontReading')}; + margin: ${th('subGridUnit')} 0; +` + const SubtitleRow = styled.div` display: flex; justify-content: space-between; diff --git a/packages/components-faraday/src/components/Dashboard/Dashboard.js b/packages/components-faraday/src/components/Dashboard/Dashboard.js index c740018baa68358e10070e8457e3d53019086945..190c45f88577b15dee6746099b4db3c5ffe8da23 100644 --- a/packages/components-faraday/src/components/Dashboard/Dashboard.js +++ b/packages/components-faraday/src/components/Dashboard/Dashboard.js @@ -1,29 +1,26 @@ import React from 'react' import styled from 'styled-components' import { Button, th } from '@pubsweet/ui' -import { compose, withHandlers } from 'recompose' +import { compose, withProps } from 'recompose' import { DashboardItems, DashboardFilters } from './' const Dashboard = ({ - filters, - getItems, - dashboard, - currentUser, - filterItems, - filterValues, deleteProject, + dashboardItems, + canCreateDraft, getFilterOptions, changeFilterValue, createDraftSubmission, - ...rest + getDefaultFilterValue, }) => ( - <Root> + <Root className="dashboard"> <Header> <Heading>Manuscripts</Heading> <HeaderButtons> <Button data-test="new-manuscript" + disabled={!canCreateDraft} onClick={createDraftSubmission} primary > @@ -33,26 +30,17 @@ const Dashboard = ({ </Header> <DashboardFilters changeFilterValue={changeFilterValue} + getDefaultFilterValue={getDefaultFilterValue} getFilterOptions={getFilterOptions} /> - <DashboardItems deleteProject={deleteProject} list={getItems()} /> + <DashboardItems deleteProject={deleteProject} list={dashboardItems} /> </Root> ) export default compose( - withHandlers({ - getItems: ({ - filters, - dashboard, - currentUser, - filterItems, - filterValues = {}, - }) => () => - filterItems(dashboard.all).sort((a, b) => { - if (filterValues.order === 'newest') return a.created - b.created < 0 - return a.created - b.created > 0 - }), - }), + withProps(({ dashboard, filterItems }) => ({ + dashboardItems: filterItems(dashboard.all), + })), )(Dashboard) // #region styles @@ -61,6 +49,8 @@ const Root = styled.div` flex-direction: column; margin: auto; max-width: 60em; + min-height: 50vh; + overflow: auto; ` const Header = styled.div` diff --git a/packages/components-faraday/src/components/Dashboard/DashboardCard.js b/packages/components-faraday/src/components/Dashboard/DashboardCard.js index 82f9a8698c43046994924dcc4e59c40b0a313043..59638dda62cd0f0059f530d3a11105723c954d82 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardCard.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardCard.js @@ -1,8 +1,9 @@ import React from 'react' import { get } from 'lodash' -import { connect } from 'react-redux' import PropTypes from 'prop-types' -import { Button, Icon, th } from '@pubsweet/ui' +import { connect } from 'react-redux' +import { th } from '@pubsweet/ui-toolkit' +import { Button, Icon } from '@pubsweet/ui' import styled, { css, withTheme } from 'styled-components' import { compose, getContext, setDisplayName } from 'recompose' import { DateParser } from 'pubsweet-components-faraday/src/components' @@ -15,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' @@ -84,7 +85,9 @@ const DashboardCard = ({ <Icon>download</Icon> </ClickableIcon> </ZipFiles> - {!project.status && ( + {(!project.status || + project.status === 'draft' || + !submittedDate) && ( <ActionButtons data-test="button-resume-submission" onClick={() => @@ -116,18 +119,20 @@ const DashboardCard = ({ <ManuscriptType title={manuscriptMeta}> {manuscriptMeta} </ManuscriptType> - {project.status ? ( - <Details - data-test="button-details" - onClick={() => - history.push( - `/projects/${project.id}/versions/${version.id}/details`, - ) - } - > - Details - <Icon primary>chevron-right</Icon> - </Details> + {project.status && project.status !== 'draft' ? ( + submittedDate && ( + <Details + data-test="button-details" + onClick={() => + history.push( + `/projects/${project.id}/versions/${version.id}/details`, + ) + } + > + Details + <Icon primary>chevron-right</Icon> + </Details> + ) ) : ( <DeleteManuscript deleteProject={() => deleteProject(project)} @@ -137,45 +142,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={version.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 } @@ -184,11 +192,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/DashboardFilters.js b/packages/components-faraday/src/components/Dashboard/DashboardFilters.js index 0d0cfbd37e1011ff5b9ea562abd77b16e67d8e43..6be689907a69989c4293f5e198fd9039cb894d53 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardFilters.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardFilters.js @@ -4,44 +4,31 @@ import { Menu, th } from '@pubsweet/ui' import { compose, withHandlers } from 'recompose' const DashboardFilters = ({ - // view, - status, - listView, - createdAt, - changeSort, - changeFilter, getFilterOptions, changeFilterValue, + getDefaultFilterValue, }) => ( - <Root> - <FiltersContainer> - <span>Filter view:</span> - <FilterGroup> - <span>Owner</span> - <Menu - inline - onChange={changeFilterValue('owner')} - options={getFilterOptions('owner')} - /> - </FilterGroup> - <FilterGroup> - <span>Status</span> - <Menu - inline - onChange={changeFilterValue('status')} - options={getFilterOptions('status')} - /> - </FilterGroup> - <FilterGroup> - <span>Sort</span> - <Menu - inline - onChange={changeFilterValue('order')} - options={getFilterOptions('order')} - /> - </FilterGroup> - </FiltersContainer> - </Root> + <FiltersContainer> + <span>Filter view:</span> + <FilterGroup> + <span>Priority</span> + <Menu + inline + onChange={changeFilterValue('priority')} + options={getFilterOptions('priority')} + value={getDefaultFilterValue('priority')} + /> + </FilterGroup> + <FilterGroup> + <span>Sort</span> + <Menu + inline + onChange={changeFilterValue('order')} + options={getFilterOptions('order')} + value={getDefaultFilterValue('order')} + /> + </FilterGroup> + </FiltersContainer> ) export default compose( @@ -53,19 +40,14 @@ export default compose( )(DashboardFilters) // #region styles - -const Root = styled.div` +const FiltersContainer = styled.div` + align-items: center; border-bottom: ${th('borderDefault')}; color: ${th('colorPrimary')}; display: flex; - justify-content: space-between; + justify-content: flex-start; margin: calc(${th('subGridUnit')} * 2) 0; padding-bottom: calc(${th('subGridUnit')} * 2); -` - -const FiltersContainer = styled.div` - align-items: center; - display: flex; > span { align-self: flex-end; @@ -79,6 +61,8 @@ const FilterGroup = styled.div` display: flex; flex-direction: column; margin-left: calc(${th('subGridUnit')} * 2); + > div { + min-width: 200px; + } ` - // #endregion diff --git a/packages/components-faraday/src/components/Dashboard/DashboardPage.js b/packages/components-faraday/src/components/Dashboard/DashboardPage.js index 48dd1bfd37e6c67d4d92489197eb435a65b32a4c..dce45132340db9e88b54fd63e0f61dc1fada9446 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardPage.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardPage.js @@ -9,9 +9,14 @@ import { compose, withContext } from 'recompose' import { newestFirst, selectCurrentUser } from 'xpub-selectors' import { createDraftSubmission } from 'pubsweet-component-wizard/src/redux/conversion' +import { + userNotConfirmed, + getUserPermissions, +} from 'pubsweet-component-faraday-selectors' + import { Dashboard } from './' -import withFilters from './withFilters' import { getHandlingEditors } from '../../redux/editors' +import { priorityFilter, importanceSort, withFiltersHOC } from '../Filters' export default compose( ConnectPage(() => [actions.getCollections(), actions.getUsers()]), @@ -19,7 +24,7 @@ export default compose( state => { const { collections, conversion } = state const currentUser = selectCurrentUser(state) - + const canCreateDraft = !userNotConfirmed(state) const sortedCollections = newestFirst(collections) const dashboard = { @@ -35,9 +40,18 @@ export default compose( reviewer => reviewer && reviewer.user === currentUser.id, ), ), + all: sortedCollections, } - return { collections, conversion, currentUser, dashboard } + const userPermissions = getUserPermissions(state) + return { + dashboard, + conversion, + collections, + currentUser, + canCreateDraft, + userPermissions, + } }, (dispatch, { history }) => ({ deleteProject: collection => @@ -53,50 +67,9 @@ export default compose( ), withRouter, withJournal, - withFilters({ - status: { - options: [ - { label: 'All', value: 'all' }, - { label: 'Submitted', value: 'submitted' }, - { label: 'Draft', value: 'draft' }, - { label: 'HE Invited', value: 'heInvited' }, - { label: 'HE Assigned', value: 'heAssigned' }, - { label: 'Reviewers Invited', value: 'reviewersInvited' }, - { label: 'Under Review', value: 'underReview' }, - ], - filterFn: filterValue => item => { - if (filterValue === 'all' || filterValue === '') return true - const itemStatus = get(item, 'status') - if (!itemStatus && filterValue === 'draft') { - return true - } - return itemStatus === filterValue - }, - }, - owner: { - options: [ - { label: 'Everyone', value: 'all' }, - { label: 'My work', value: 'me' }, - { label: `Other's work`, value: 'other' }, - ], - filterFn: (filterValue, { currentUser }) => item => { - if (filterValue === 'all' || filterValue === '') return true - const itemOwnerIds = item.owners.map(o => o.id) - if (filterValue === 'me') { - return itemOwnerIds.includes(currentUser.id) - } else if (filterValue === 'other') { - return !itemOwnerIds.includes(currentUser.id) - } - return false - }, - }, - order: { - options: [ - { label: 'Newest first', value: 'newest' }, - { label: 'Oldest first', value: 'oldest' }, - ], - filterFn: () => () => true, - }, + withFiltersHOC({ + priority: priorityFilter, + order: importanceSort, }), withContext( { diff --git a/packages/components-faraday/src/components/Dashboard/EditorInChiefActions.js b/packages/components-faraday/src/components/Dashboard/EditorInChiefActions.js index 342f530f148193ab803f78ec8898578c08f3f878..c00ce76139a9ce20065c4fe728581c06e625282e 100644 --- a/packages/components-faraday/src/components/Dashboard/EditorInChiefActions.js +++ b/packages/components-faraday/src/components/Dashboard/EditorInChiefActions.js @@ -98,6 +98,7 @@ export default compose( onConfirm: () => assignHandlingEditor(get(editor, 'email'), project.id, true).then( () => { + getCollections() hideModal() showModal({ type: 'success', @@ -170,6 +171,7 @@ const AssignButton = styled(Button)` background-color: ${th('colorPrimary')}; color: ${th('colorTextReverse')}; height: calc(${th('subGridUnit')} * 5); + padding: 0; text-align: center; text-transform: uppercase; ` diff --git a/packages/components-faraday/src/components/Dashboard/HandlingEditorActions.js b/packages/components-faraday/src/components/Dashboard/HandlingEditorActions.js index 82907e31d9d00c1fa355324265aaa4bf4b110b61..380e50fc3f4e0b23a723884097a956f2efc694d3 100644 --- a/packages/components-faraday/src/components/Dashboard/HandlingEditorActions.js +++ b/packages/components-faraday/src/components/Dashboard/HandlingEditorActions.js @@ -28,10 +28,10 @@ const DeclineModal = compose( value={reason} /> <div data-test="he-buttons"> - <Button onClick={hideModal}>Cancel</Button> - <Button onClick={onConfirm(reason)} primary> + <DecisionButton onClick={hideModal}>Cancel</DecisionButton> + <DecisionButton onClick={onConfirm(reason)} primary> Decline - </Button> + </DecisionButton> </div> </DeclineRoot> )) @@ -119,6 +119,8 @@ const DecisionButton = styled(Button)` background-color: ${({ primary }) => primary ? th('colorPrimary') : th('backgroundColorReverse')}; height: calc(${th('subGridUnit')} * 5); + margin-left: ${th('gridUnit')}; + padding: 0; text-align: center; ` @@ -158,7 +160,5 @@ const DeclineRoot = styled.div` } ` -const Root = styled.div` - margin-left: ${th('gridUnit')}; -` +const Root = styled.div`` // #endregion 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 6dece3361cd054c3615dd6df96e0be77ae086e92..45a32fdb91a2d83c174d77729f3c76c77bef9118 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), ) @@ -95,8 +99,10 @@ const DecisionButton = styled(Button)` background-color: ${({ primary }) => primary ? th('colorPrimary') : th('backgroundColorReverse')}; color: ${({ primary }) => - primary ? th('colorTextReverse') : th('colorPrimary')}); + primary ? th('colorTextReverse') : th('colorPrimary')}; height: calc(${th('subGridUnit')} * 5); + margin-left: ${th('gridUnit')}; + padding: 0; text-align: center; ` // #endregion diff --git a/packages/components-faraday/src/components/Dashboard/withFilters.js b/packages/components-faraday/src/components/Dashboard/withFilters.js deleted file mode 100644 index b51b7b6eba14585b15653a6481480b7c8bfa0193..0000000000000000000000000000000000000000 --- a/packages/components-faraday/src/components/Dashboard/withFilters.js +++ /dev/null @@ -1,30 +0,0 @@ -import { get } from 'lodash' -import { compose, withState, withHandlers } from 'recompose' - -export default config => Component => { - const filterFns = Object.entries(config).map(([filterKey, { filterFn }]) => ({ - key: filterKey, - fn: filterFn, - })) - const filterValues = Object.keys(config).reduce( - (acc, el) => ({ ...acc, [el]: '' }), - {}, - ) - return compose( - withState('filterValues', 'setFilterValues', filterValues), - withHandlers({ - getFilterOptions: () => key => get(config, `${key}.options`) || [], - changeFilterValue: ({ setFilterValues }) => filterKey => value => { - setFilterValues(v => ({ - ...v, - [filterKey]: value, - })) - }, - filterItems: ({ filterValues, ...props }) => items => - filterFns.reduce( - (acc, { key, fn }) => acc.filter(fn(filterValues[key], props)), - items, - ), - }), - )(Component) -} diff --git a/packages/components-faraday/src/components/Files/Files.js b/packages/components-faraday/src/components/Files/Files.js index 3bf85519bc16e665a18fdbb36410cab86258a06c..f99ea6586e41b6af2ac0cabfefb960a3c9f48e8d 100644 --- a/packages/components-faraday/src/components/Files/Files.js +++ b/packages/components-faraday/src/components/Files/Files.js @@ -1,11 +1,12 @@ -import React from 'react' -import { get } from 'lodash' +import React, { Fragment } from 'react' import { th } from '@pubsweet/ui' import PropTypes from 'prop-types' +import { get, isEqual } from 'lodash' import { connect } from 'react-redux' import styled from 'styled-components' import { withRouter } from 'react-router-dom' import { change as changeForm } from 'redux-form' +import { selectCurrentVersion } from 'xpub-selectors' import { compose, lifecycle, @@ -15,6 +16,7 @@ import { withHandlers, } from 'recompose' import { SortableList } from 'pubsweet-components-faraday/src/components' +import { isRevisionFlow } from 'pubsweet-component-wizard/src/redux/conversion' import FileSection from './FileSection' import { @@ -31,10 +33,11 @@ const Files = ({ moveItem, removeFile, changeList, + isRevisionFlow, dropSortableFile, }) => ( - <div> - <Error show={error}>Error uploading file, please try again.</Error> + <Fragment> + <Error show={error}> File error, please try again.</Error> <FileSection addFile={addFile('manuscripts')} allowedFileExtensions={['pdf', 'doc', 'docx']} @@ -65,23 +68,43 @@ const Files = ({ changeList={changeList} dropSortableFile={dropSortableFile} files={get(files, 'coverLetter') || []} - isLast + isLast={!isRevisionFlow} listId="coverLetter" maxFiles={1} moveItem={moveItem('coverLetter')} removeFile={removeFile('coverLetter')} title="Cover letter" /> - </div> + {isRevisionFlow && ( + <FileSection + addFile={addFile('responseToReviewers')} + allowedFileExtensions={['pdf', 'doc', 'docx']} + changeList={changeList} + dropSortableFile={dropSortableFile} + files={get(files, 'responseToReviewers') || []} + isLast={isRevisionFlow} + listId="responseToReviewer" + maxFiles={1} + moveItem={moveItem('responseToReviewers')} + removeFile={removeFile('responseToReviewers')} + title="Response to reviewers" + /> + )} + </Fragment> ) export default compose( + getContext({ + project: PropTypes.object, + version: PropTypes.object, + }), withRouter, - getContext({ version: PropTypes.object, project: PropTypes.object }), connect( - state => ({ - isFetching: getRequestStatus(state), + (state, { project, version }) => ({ error: getFileError(state), + isFetching: getRequestStatus(state), + version: selectCurrentVersion(state, project), + isRevisionFlow: isRevisionFlow(state, project, version), }), { changeForm, @@ -93,6 +116,7 @@ export default compose( manuscripts: [], coverLetter: [], supplementary: [], + responseToReviewers: [], }), lifecycle({ componentDidMount() { @@ -101,8 +125,16 @@ export default compose( manuscripts: get(files, 'manuscripts') || [], coverLetter: get(files, 'coverLetter') || [], supplementary: get(files, 'supplementary') || [], + responseToReviewers: get(files, 'responseToReviewers') || [], })) }, + componentWillReceiveProps(nextProps) { + const { setFiles, version: { files: previousFiles } } = this.props + const { version: { files } } = nextProps + if (!isEqual(previousFiles, files)) { + setFiles(files) + } + }, }), withHandlers({ dropSortableFile: ({ files, setFiles, changeForm }) => ( @@ -137,10 +169,10 @@ export default compose( }, addFile: ({ files, - uploadFile, + version, setFiles, + uploadFile, changeForm, - version, }) => type => file => { uploadFile(file, type, version.id) .then(file => { @@ -149,9 +181,7 @@ export default compose( [type]: [...files[type], file], } setFiles(newFiles) - setTimeout(() => { - changeForm('wizard', 'files', newFiles) - }, 1000) + changeForm('wizard', 'files', newFiles) }) .catch(e => console.error(`Couldn't upload file.`, e)) }, @@ -173,13 +203,16 @@ export default compose( version, }) => type => id => e => { e.preventDefault() - deleteFile(id) - const newFiles = { - ...files, - [type]: files[type].filter(f => f.id !== id), - } - setFiles(newFiles) - changeForm('wizard', 'files', files) + deleteFile(id, type) + .then(() => { + const newFiles = { + ...files, + [type]: files[type].filter(f => f.id !== id), + } + setFiles(newFiles) + changeForm('wizard', 'files', newFiles) + }) + .catch(e => console.error(`Couldn't delete file.`, e)) }, }), withContext( diff --git a/packages/components-faraday/src/components/Filters/importanceSort.js b/packages/components-faraday/src/components/Filters/importanceSort.js new file mode 100644 index 0000000000000000000000000000000000000000..c2491a9219a11728956995948c5c5338025715e8 --- /dev/null +++ b/packages/components-faraday/src/components/Filters/importanceSort.js @@ -0,0 +1,35 @@ +import { get } from 'lodash' + +import { utils } from './' +import cfg from '../../../../xpub-faraday/config/default' + +const statuses = get(cfg, 'statuses') +export const SORT_VALUES = { + MORE_IMPORTANT: 'more_important', + LESS_IMPORTANT: 'less_important', +} + +const options = [ + { label: 'Important first', value: SORT_VALUES.MORE_IMPORTANT }, + { label: 'Less important first', value: SORT_VALUES.LESS_IMPORTANT }, +] + +const sortFn = sortValue => (item1, item2) => { + const item1Importance = utils.getCollectionImportance(statuses, item1) + const item2Importance = utils.getCollectionImportance(statuses, item2) + + if (item1Importance - item2Importance === 0) { + return item1.created - item2.created + } + + if (sortValue === SORT_VALUES.MORE_IMPORTANT) { + return item2Importance - item1Importance + } + return item1Importance - item2Importance +} + +export default { + sortFn, + options, + type: 'sort', +} diff --git a/packages/components-faraday/src/components/Filters/importanceSort.test.js b/packages/components-faraday/src/components/Filters/importanceSort.test.js new file mode 100644 index 0000000000000000000000000000000000000000..b2c270dbbcdc40a45b9fb2f521714f8805c1bd29 --- /dev/null +++ b/packages/components-faraday/src/components/Filters/importanceSort.test.js @@ -0,0 +1,63 @@ +import fixturesService from 'pubsweet-component-fixture-service' + +import { importanceSort } from './' +import { SORT_VALUES } from './importanceSort' + +const { sortFn } = importanceSort +const { fixtures: { collections: { collection } } } = fixturesService + +describe('Importance sort', () => { + describe('Important items first', () => { + // the more important collection is already before the less important one + it('should return a negative value', () => { + const sortResult = sortFn(SORT_VALUES.MORE_IMPORTANT)( + { ...collection, status: 'pendingApproval' }, + { ...collection, status: 'heAssigned' }, + ) + expect(sortResult).toBeLessThan(0) + }) + // the more important collection is after a less important one + it('should return a positive value', () => { + const sortResult = sortFn(SORT_VALUES.MORE_IMPORTANT)( + { ...collection, status: 'heAssigned' }, + { ...collection, status: 'pendingApproval' }, + ) + expect(sortResult).toBeGreaterThan(0) + }) + }) + + describe('Less important items first', () => { + it('should return a positive value', () => { + const sortResult = sortFn(SORT_VALUES.LESS_IMPORTANT)( + { ...collection, status: 'pendingApproval' }, + { ...collection, status: 'heAssigned' }, + ) + expect(sortResult).toBeGreaterThan(0) + }) + it('should return a negative value', () => { + const sortResult = sortFn(SORT_VALUES.LESS_IMPORTANT)( + { ...collection, status: 'heAssigned' }, + { ...collection, status: 'pendingApproval' }, + ) + expect(sortResult).toBeLessThan(0) + }) + }) + + describe('Sort by date if both have the same', () => { + it('should place older item before newer item', () => { + const sortResult = sortFn(SORT_VALUES.LESS_IMPORTANT)( + { ...collection, status: 'heAssigned', created: Date.now() + 2000 }, + { ...collection, status: 'heAssigned', created: Date.now() }, + ) + expect(sortResult).toBeGreaterThan(0) + }) + + it('should not move items', () => { + const sortResult = sortFn(SORT_VALUES.LESS_IMPORTANT)( + { ...collection, status: 'heAssigned', created: Date.now() }, + { ...collection, status: 'heAssigned', created: Date.now() + 2000 }, + ) + expect(sortResult).toBeLessThan(0) + }) + }) +}) diff --git a/packages/components-faraday/src/components/Filters/index.js b/packages/components-faraday/src/components/Filters/index.js new file mode 100644 index 0000000000000000000000000000000000000000..cec651407a1906360e0be3b88876740fde6ba1aa --- /dev/null +++ b/packages/components-faraday/src/components/Filters/index.js @@ -0,0 +1,6 @@ +import * as utils from './utils' + +export { utils } +export { default as withFiltersHOC } from './withFilters' +export { default as priorityFilter } from './priorityFilter' +export { default as importanceSort } from './importanceSort' diff --git a/packages/components-faraday/src/components/Filters/priorityFilter.js b/packages/components-faraday/src/components/Filters/priorityFilter.js new file mode 100644 index 0000000000000000000000000000000000000000..47ffbc88cb6193ba43299838d7474ff58d6337bc --- /dev/null +++ b/packages/components-faraday/src/components/Filters/priorityFilter.js @@ -0,0 +1,53 @@ +import { get } from 'lodash' + +import { utils } from './' +import cfg from '../../../../xpub-faraday/config/default' + +const statuses = get(cfg, 'statuses') + +export const FILTER_VALUES = { + ALL: 'all', + NEEDS_ATTENTION: 'needsAttention', + IN_PROGRESS: 'inProgress', + ARCHIVED: 'archived', +} + +const options = [ + { label: 'All', value: FILTER_VALUES.ALL }, + { label: 'Needs Attention', value: FILTER_VALUES.NEEDS_ATTENTION }, + { label: 'In Progress', value: FILTER_VALUES.IN_PROGRESS }, + { label: 'Archived', value: FILTER_VALUES.ARCHIVED }, +] + +const archivedStatuses = ['withdrawn', 'accepted', 'rejected'] + +const filterFn = (filterValue, { currentUser, userPermissions = [] }) => ({ + id = '', + fragments = [], + status = 'draft', +}) => { + if (filterValue === FILTER_VALUES.ARCHIVED) { + return archivedStatuses.includes(status) + } + const permission = userPermissions.find( + ({ objectId }) => objectId === id || fragments.includes(objectId), + ) + const userRole = utils.getUserRole(currentUser, get(permission, 'role')) + switch (filterValue) { + case FILTER_VALUES.NEEDS_ATTENTION: + return get(statuses, `${status}.${userRole}.needsAttention`) + case FILTER_VALUES.IN_PROGRESS: + return ( + !archivedStatuses.includes(status) && + !get(statuses, `${status}.${userRole}.needsAttention`) + ) + default: + return true + } +} + +export default { + options, + filterFn, + type: 'filter', +} diff --git a/packages/components-faraday/src/components/Filters/priorityFilter.test.js b/packages/components-faraday/src/components/Filters/priorityFilter.test.js new file mode 100644 index 0000000000000000000000000000000000000000..ac2f07d2f7f5f4ffa937073fe3d96ea1bc6c88ec --- /dev/null +++ b/packages/components-faraday/src/components/Filters/priorityFilter.test.js @@ -0,0 +1,436 @@ +import fixturesService from 'pubsweet-component-fixture-service' + +import { FILTER_VALUES } from './priorityFilter' +import { priorityFilter, utils } from './' + +const { + fixtures: { collections: { collection }, users, teams }, +} = fixturesService + +const { filterFn } = priorityFilter + +describe('Priority filter function for reviewersInvited status', () => { + describe('ALL', () => { + it('should return true if ALL is selected', () => { + const filterResult = filterFn(FILTER_VALUES.ALL, { currentUser: {} })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeTruthy() + }) + }) + + describe('NEEDS ATTENTION', () => { + it('should return falsy for AUTHOR', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.author, + userPermissions: [utils.parsePermission(teams.authorTeam)], + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeFalsy() + }) + + it('should return truthy for REVIEWER', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.reviewer, + userPermissions: [utils.parsePermission(teams.revTeam)], + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeTruthy() + }) + it('should return truthy for HANDLING EDITOR', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.handlingEditor, + userPermissions: [utils.parsePermission(teams.heTeam)], + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeTruthy() + }) + it('should return falsy for EDITOR IN CHIEF', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.editorInChief, + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeFalsy() + }) + it('should return truthy for ADMIN', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.admin, + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeTruthy() + }) + }) + + describe('IN PROGRESS', () => { + it('should return truthy for AUTHOR', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.author, + userPermissions: [utils.parsePermission(teams.authorTeam)], + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeTruthy() + }) + + it('should return falsy for REVIEWER', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.reviewer, + userPermissions: [utils.parsePermission(teams.revTeam)], + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeFalsy() + }) + it('should return falsy for HANDLING EDITOR', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.handlingEditor, + userPermissions: [utils.parsePermission(teams.heTeam)], + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeFalsy() + }) + it('should return truthy for EDITOR IN CHIEF', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.editorInChief, + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeTruthy() + }) + it('should return falsy for ADMIN', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.admin, + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeFalsy() + }) + }) + + describe('ARCHIVED', () => { + it('should return falsy', () => { + const filterResult = filterFn(FILTER_VALUES.ARCHIVED, { + currentUser: users.admin, + })({ + ...collection, + status: 'reviewersInvited', + }) + expect(filterResult).toBeFalsy() + }) + }) +}) + +describe('Priority filter function for technicalChecks status', () => { + describe('ALL', () => { + it('should return true if ALL is selected', () => { + const filterResult = filterFn(FILTER_VALUES.ALL, { currentUser: {} })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeTruthy() + }) + }) + + describe('NEEDS ATTENTION', () => { + it('should return falsy for AUTHOR', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.author, + userPermissions: [utils.parsePermission(teams.authorTeam)], + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeFalsy() + }) + + it('should return truthy for REVIEWER', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.reviewer, + userPermissions: [utils.parsePermission(teams.revTeam)], + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeFalsy() + }) + it('should return truthy for HANDLING EDITOR', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.handlingEditor, + userPermissions: [utils.parsePermission(teams.heTeam)], + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeFalsy() + }) + it('should return falsy for EDITOR IN CHIEF', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.editorInChief, + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeFalsy() + }) + it('should return truthy for ADMIN', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.admin, + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeTruthy() + }) + }) + + describe('IN PROGRESS', () => { + it('should return truthy for AUTHOR', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.author, + userPermissions: [utils.parsePermission(teams.authorTeam)], + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeTruthy() + }) + + it('should return falsy for REVIEWER', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.reviewer, + userPermissions: [utils.parsePermission(teams.revTeam)], + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeTruthy() + }) + it('should return falsy for HANDLING EDITOR', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.handlingEditor, + userPermissions: [utils.parsePermission(teams.heTeam)], + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeTruthy() + }) + it('should return truthy for EDITOR IN CHIEF', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.editorInChief, + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeTruthy() + }) + it('should return falsy for ADMIN', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.admin, + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeFalsy() + }) + }) + + describe('ARCHIVED', () => { + it('should return falsy', () => { + const filterResult = filterFn(FILTER_VALUES.ARCHIVED, { + currentUser: users.admin, + })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeFalsy() + }) + }) +}) + +describe('Priority filter function for pendingApproval status', () => { + describe('ALL', () => { + it('should return true if ALL is selected', () => { + const filterResult = filterFn(FILTER_VALUES.ALL, { currentUser: {} })({ + ...collection, + status: 'technicalChecks', + }) + expect(filterResult).toBeTruthy() + }) + }) + + describe('NEEDS ATTENTION', () => { + it('should return falsy for AUTHOR', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.author, + userPermissions: [utils.parsePermission(teams.authorTeam)], + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeFalsy() + }) + + it('should return truthy for REVIEWER', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.reviewer, + userPermissions: [utils.parsePermission(teams.revTeam)], + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeFalsy() + }) + it('should return truthy for HANDLING EDITOR', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.handlingEditor, + userPermissions: [utils.parsePermission(teams.heTeam)], + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeFalsy() + }) + it('should return falsy for EDITOR IN CHIEF', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.editorInChief, + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeTruthy() + }) + it('should return truthy for ADMIN', () => { + const filterResult = filterFn(FILTER_VALUES.NEEDS_ATTENTION, { + currentUser: users.admin, + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeTruthy() + }) + }) + + describe('IN PROGRESS', () => { + it('should return truthy for AUTHOR', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.author, + userPermissions: [utils.parsePermission(teams.authorTeam)], + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeTruthy() + }) + + it('should return falsy for REVIEWER', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.reviewer, + userPermissions: [utils.parsePermission(teams.revTeam)], + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeTruthy() + }) + it('should return falsy for HANDLING EDITOR', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.handlingEditor, + userPermissions: [utils.parsePermission(teams.heTeam)], + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeTruthy() + }) + it('should return truthy for EDITOR IN CHIEF', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.editorInChief, + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeFalsy() + }) + it('should return falsy for ADMIN', () => { + const filterResult = filterFn(FILTER_VALUES.IN_PROGRESS, { + currentUser: users.admin, + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeFalsy() + }) + }) + + describe('ARCHIVED', () => { + it('should return falsy', () => { + const filterResult = filterFn(FILTER_VALUES.ARCHIVED, { + currentUser: users.admin, + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeFalsy() + }) + }) +}) + +describe('Priority filter function for archived statuses', () => { + it('should show rejected manuscripts', () => { + const filterResult = filterFn(FILTER_VALUES.ARCHIVED, { + currentUser: users.admin, + })({ + ...collection, + status: 'rejected', + }) + expect(filterResult).toBeTruthy() + }) + + it('should show withdrawn manuscripts', () => { + const filterResult = filterFn(FILTER_VALUES.ARCHIVED, { + currentUser: users.admin, + })({ + ...collection, + status: 'withdrawn', + }) + expect(filterResult).toBeTruthy() + }) + + it('should show accepted manuscripts', () => { + const filterResult = filterFn(FILTER_VALUES.ARCHIVED, { + currentUser: users.admin, + })({ + ...collection, + status: 'accepted', + }) + expect(filterResult).toBeTruthy() + }) + + it('should not show pendingApproval manuscripts', () => { + const filterResult = filterFn(FILTER_VALUES.ARCHIVED, { + currentUser: users.admin, + })({ + ...collection, + status: 'pendingApproval', + }) + expect(filterResult).toBeFalsy() + }) +}) diff --git a/packages/components-faraday/src/components/Filters/utils.js b/packages/components-faraday/src/components/Filters/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..8dabf8b2f1c77b530bf761937f6a1b8fb96329a8 --- /dev/null +++ b/packages/components-faraday/src/components/Filters/utils.js @@ -0,0 +1,42 @@ +import { get } from 'lodash' + +export const hydrateFilters = defaultValues => { + const filterValues = localStorage.getItem('filterValues') + if (filterValues) return JSON.parse(filterValues) + return defaultValues +} + +export const makeFilterFunctions = config => + Object.entries(config) + .filter(([filterKey, { type }]) => type === 'filter') + .map(([filterKey, { filterFn }]) => ({ + key: filterKey, + fn: filterFn, + })) + +export const makeSortFunction = config => { + const [sortKey, { sortFn }] = Object.entries(config).find( + ([filterKey, { type }]) => type !== 'filter', + ) + return { + sortKey, + sortFn, + } +} + +export const makeFilterValues = config => + Object.keys(config).reduce((acc, el) => ({ ...acc, [el]: '' }), {}) + +export const getUserRole = (user, role) => { + if (user.admin) return 'admin' + if (user.editorInChief) return 'editorInChief' + return role +} + +export const parsePermission = permission => ({ + objectId: permission.object.id, + role: permission.group, +}) + +export const getCollectionImportance = (statuses, item) => + get(statuses, `${get(item, 'status') || 'draft'}.importance`) diff --git a/packages/components-faraday/src/components/Filters/withFilters.js b/packages/components-faraday/src/components/Filters/withFilters.js new file mode 100644 index 0000000000000000000000000000000000000000..d7a089f935293cb6345cfec1f1ccccca19e602fc --- /dev/null +++ b/packages/components-faraday/src/components/Filters/withFilters.js @@ -0,0 +1,46 @@ +import { get } from 'lodash' +import { compose, withState, withHandlers } from 'recompose' + +import { utils } from './' + +export default config => Component => { + const filterFns = utils.makeFilterFunctions(config) + const filterValues = utils.makeFilterValues(config) + const { sortKey, sortFn } = utils.makeSortFunction(config) + + return compose( + withState( + 'filterValues', + 'setFilterValues', + utils.hydrateFilters(filterValues), + ), + withHandlers({ + getFilterOptions: () => key => get(config, `${key}.options`) || [], + getDefaultFilterValue: ({ filterValues }) => key => + get(filterValues, key) || '', + changeFilterValue: ({ setFilterValues }) => filterKey => value => { + // ugly but recompose doesn't pass the new state in the callback function + let newState = {} + setFilterValues( + v => { + newState = { + ...v, + [filterKey]: value, + } + return newState + }, + () => { + localStorage.setItem('filterValues', JSON.stringify(newState)) + }, + ) + }, + filterItems: ({ filterValues, ...props }) => items => + filterFns + .reduce( + (acc, { key, fn }) => acc.filter(fn(filterValues[key], props)), + items, + ) + .sort(sortFn(filterValues[sortKey], props)), + }), + )(Component) +} 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 b7da20c1b2c2fcc4e7571a83a202f88150e0a1bc..0a53c9423317ac06571ebf6470b6ee16b957fb0f 100644 --- a/packages/components-faraday/src/components/MakeDecision/DecisionForm.js +++ b/packages/components-faraday/src/components/MakeDecision/DecisionForm.js @@ -1,39 +1,28 @@ 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, { css } from 'styled-components' import { reduxForm, formValueSelector } from 'redux-form' import { compose, setDisplayName, withProps } from 'recompose' -import { - th, - 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, RowItem, - Textarea, Subtitle, RootContainer, FormContainer, + TextAreaField, + CustomRadioGroup, } = FormItems const Form = RootContainer.withComponent(FormContainer) @@ -41,9 +30,7 @@ const DecisionForm = ({ aHERec, decision, hideModal, - isFetching, handleSubmit, - recommendationError, heRecommendation: { reason, message = '' }, }) => ( <Form onSubmit={handleSubmit}> @@ -70,6 +57,7 @@ const DecisionForm = ({ <ValidatedField component={input => ( <CustomRadioGroup + className="custom-radio-group" justify={reason ? 'space-between' : 'space-around'} > <RadioGroup @@ -88,32 +76,21 @@ const DecisionForm = ({ <RowItem vertical> <Label>Comments for Handling Editor</Label> <ValidatedField - component={input => <Textarea {...input} height={70} />} + component={TextAreaField} name="messageToHE" validate={[required]} /> </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> @@ -124,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: { @@ -144,31 +123,33 @@ export default compose( dispatch, { showModal, + hideModal, fragmentId, collectionId, + getFragments, getCollections, createRecommendation, }, ) => { const recommendation = parseFormValues(values) - createRecommendation(collectionId, fragmentId, recommendation).then(r => { - getCollections() - showModal({ - title: 'Decision submitted', - cancelText: 'OK', - }) - }) + createRecommendation(collectionId, fragmentId, recommendation).then( + () => { + showModal({ + onCancel: () => { + getCollections() + getFragments() + hideModal() + }, + title: 'Decision submitted', + cancelText: 'OK', + }) + }, + ) }, }), )(DecisionForm) // #region styled-components -const defaultText = css` - color: ${th('colorText')}; - font-family: ${th('fontReading')}; - font-size: ${th('fontSizeBaseSmall')}; -` - const IconButton = styled.div` align-self: flex-end; cursor: pointer; @@ -185,17 +166,4 @@ const BoldSubtitle = Subtitle.extend` font-weight: bold; margin-left: 5px; ` - -const CustomRadioGroup = styled.div` - div { - flex-direction: row; - justify-content: ${({ justify }) => justify || 'space-between'}; - label { - span:last-child { - font-style: normal; - ${defaultText}; - } - } - } -` // #endregion diff --git a/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js b/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js index 1b06f395f9e2780f5ede69aa8684bc32b81a6732..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,16 +64,21 @@ export default compose( resetForm, fragmentId, collectionId, + getFragments, getCollections, createRecommendation, }) => values => { const recommendation = utils.parseRecommendationValues(values) createRecommendation(collectionId, fragmentId, recommendation).then(r => { resetForm('recommendation') - getCollections() showModal({ title: 'Recommendation sent', cancelText: 'OK', + onCancel: () => { + getCollections() + getFragments() + hideModal() + }, }) }) }, diff --git a/packages/components-faraday/src/components/MakeRecommendation/StepOne.js b/packages/components-faraday/src/components/MakeRecommendation/StepOne.js index fc20adb61b276af9f16aabb22e81fec792d2f026..6185a09a1677c060032875ce80ebf83c4e1139be 100644 --- a/packages/components-faraday/src/components/MakeRecommendation/StepOne.js +++ b/packages/components-faraday/src/components/MakeRecommendation/StepOne.js @@ -5,7 +5,7 @@ import { RadioGroup, ValidatedField, Button } from '@pubsweet/ui' import { utils } from './' import { FormItems } from '../UIComponents' -const { RootContainer, Row, RowItem, Title } = FormItems +const { Row, Title, RowItem, RootContainer, CustomRadioGroup } = FormItems const StepOne = ({ hideModal, disabled, onSubmit }) => ( <RootContainer> @@ -14,11 +14,16 @@ const StepOne = ({ hideModal, disabled, onSubmit }) => ( <RowItem> <ValidatedField component={input => ( - <RadioGroup - name="decision" - options={utils.recommendationOptions} - {...input} - /> + <CustomRadioGroup + className="custom-radio-group" + justify="space-between" + > + <RadioGroup + name="decision" + options={utils.recommendationOptions} + {...input} + /> + </CustomRadioGroup> )} name="decision" /> diff --git a/packages/components-faraday/src/components/MakeRecommendation/StepTwo.js b/packages/components-faraday/src/components/MakeRecommendation/StepTwo.js index 2e554d6ccb2d8f863416d3711ebeb469c14a0109..7081d58f04ce2e3aa7ecc3ebce86a92aa99ed396 100644 --- a/packages/components-faraday/src/components/MakeRecommendation/StepTwo.js +++ b/packages/components-faraday/src/components/MakeRecommendation/StepTwo.js @@ -23,9 +23,10 @@ const { Label, Title, RowItem, - Textarea, + TextAreaField, RootContainer, FormContainer, + CustomRadioGroup, } = FormItems const Form = RootContainer.withComponent(FormContainer) @@ -51,19 +52,13 @@ const StepTwo = ({ <Row> <RowItem vertical> <Label>Message for Editor in Chief (optional)</Label> - <ValidatedField - component={input => <Textarea {...input} height={70} />} - name="message.eic" - /> + <ValidatedField component={TextAreaField} name="message.eic" /> </RowItem> </Row> <Row> <RowItem vertical> <Label>Message for Author (optional)</Label> - <ValidatedField - component={input => <Textarea {...input} height={70} />} - name="message.author" - /> + <ValidatedField component={TextAreaField} name="message.author" /> </RowItem> </Row> {recommendationError && ( @@ -76,35 +71,37 @@ const StepTwo = ({ </Fragment> ) : ( <Fragment> - <CustomRow> + <Row> <RowItem vertical> <Label>REVISION TYPE</Label> <ValidatedField component={input => ( - <RadioGroup - name="revision.revision-type" - {...input} - options={utils.revisionOptions} - /> + <CustomRadioGroup justify="flex-start"> + <RadioGroup + name="revision.revision-type" + {...input} + options={utils.revisionOptions} + /> + </CustomRadioGroup> )} name="revision.revisionType" validate={[required]} /> </RowItem> - </CustomRow> - <CustomRow> + </Row> + <Row> <RowItem vertical> <Label> REASON & DETAILS <SubLabel>Required</SubLabel> </Label> <ValidatedField - component={input => <Textarea {...input} />} + component={TextAreaField} name="revision.reason" validate={[required]} /> </RowItem> - </CustomRow> + </Row> {!hasNote ? ( <Row> <RowItem> @@ -114,7 +111,7 @@ const StepTwo = ({ </Row> ) : ( <Fragment> - <CustomRow withMargin> + <Row noMargin> <RowItem flex={2}> <Label> INTERNAL NOTE @@ -127,15 +124,15 @@ const StepTwo = ({ </IconButton> <TextButton>Remove</TextButton> </CustomRowItem> - </CustomRow> - <CustomRow> + </Row> + <Row noMargin> <RowItem> <ValidatedField - component={input => <Textarea {...input} height={70} />} + component={TextAreaField} name="revision.internal-note" /> </RowItem> - </CustomRow> + </Row> </Fragment> )} </Fragment> @@ -210,9 +207,9 @@ const IconButton = styled.div` const CustomRowItem = RowItem.extend` align-items: center; justify-content: flex-end; -` -const CustomRow = Row.extend` - margin: ${({ withMargin }) => `${withMargin ? 6 : 0}px 0px`}; + & > div { + justify-content: flex-end; + } ` // #endregion diff --git a/packages/components-faraday/src/components/Reviewers/InviteReviewers.js b/packages/components-faraday/src/components/Reviewers/InviteReviewers.js index bee6e98b5d26856b85a9192f7b27375419abc06e..b6087f87534c4a756da957e77144c91b7d026286 100644 --- a/packages/components-faraday/src/components/Reviewers/InviteReviewers.js +++ b/packages/components-faraday/src/components/Reviewers/InviteReviewers.js @@ -1,5 +1,6 @@ import React, { Fragment } from 'react' import { connect } from 'react-redux' +import { actions } from 'pubsweet-client' import styled, { css } from 'styled-components' import { Icon, Button, th, Spinner } from '@pubsweet/ui' import { compose, withHandlers, lifecycle } from 'recompose' @@ -31,15 +32,20 @@ const InviteReviewersModal = compose( fetchingInvite: selectFetchingInvite(state), fetchingReviewers: selectFetchingReviewers(state), }), - { getCollectionReviewers }, + { getCollectionReviewers, getCollections: actions.getCollections }, ), withHandlers({ getReviewers: ({ + versionId, collectionId, setReviewers, getCollectionReviewers, }) => () => { - getCollectionReviewers(collectionId) + getCollectionReviewers(collectionId, versionId) + }, + closeModal: ({ getCollections, hideModal }) => () => { + getCollections() + hideModal() }, }), lifecycle({ @@ -50,10 +56,10 @@ const InviteReviewersModal = compose( }), )( ({ - hideModal, onConfirm, showModal, versionId, + closeModal, collectionId, getReviewers, reviewerError, @@ -63,7 +69,7 @@ const InviteReviewersModal = compose( invitations = [], }) => ( <Root> - <CloseIcon data-test="icon-modal-hide" onClick={hideModal}> + <CloseIcon data-test="icon-modal-hide" onClick={closeModal}> <Icon primary>x</Icon> </CloseIcon> @@ -76,6 +82,7 @@ const InviteReviewersModal = compose( isFetching={fetchingInvite} reviewerError={reviewerError} reviewers={reviewers} + versionId={versionId} /> <Row> @@ -193,6 +200,7 @@ const AssignButton = styled(Button)` background-color: ${th('colorPrimary')}; color: ${th('colorTextReverse')}; height: calc(${th('subGridUnit')} * 5); + padding: 0; text-align: center; ` // #endregion diff --git a/packages/components-faraday/src/components/Reviewers/ReviewerForm.js b/packages/components-faraday/src/components/Reviewers/ReviewerForm.js index 465ed9dcb2ae9e3c37e138ca3f8e3fee5aefc84a..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() }) @@ -96,6 +96,7 @@ export default compose( const FormButton = styled(Button)` height: calc(${th('subGridUnit')} * 5); margin: ${th('subGridUnit')}; + padding: 0; ` const Err = styled.span` 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/components/Reviewers/ReviewersDetailsList.js b/packages/components-faraday/src/components/Reviewers/ReviewersDetailsList.js index 3b69d0fb2a5563e425abeaf9cdd985fc910d6105..e64aa55891df9a45d62c42f54c9a37a87c11c126 100644 --- a/packages/components-faraday/src/components/Reviewers/ReviewersDetailsList.js +++ b/packages/components-faraday/src/components/Reviewers/ReviewersDetailsList.js @@ -48,18 +48,20 @@ const TR = ({ )} </td> <DateParser timestamp={r.invitedOn}> - {timestamp => <td>{timestamp}</td>} + {timestamp => <td width="150">{timestamp}</td>} </DateParser> - <td> + <td width="200"> <StatusText> - {`${r.status === 'accepted' ? 'Agreed: ' : `${r.status}: `}`} + {`${r.status === 'accepted' ? 'Agreed' : r.status}`} </StatusText> - <DateParser timestamp={r.respondedOn}> - {timestamp => <DateText>{timestamp}</DateText>} - </DateParser> + {r.respondedOn && ( + <DateParser timestamp={r.respondedOn}> + {timestamp => <DateText>{`: ${timestamp}`}</DateText>} + </DateParser> + )} </td> <DateParser timestamp={submittedOn}> - {timestamp => <td>{timestamp}</td>} + {timestamp => <td width="150">{timestamp}</td>} </DateParser> <td width={100}> {r.status === 'pending' && ( @@ -81,31 +83,29 @@ const ReviewersDetailsList = ({ }) => reviewers.length > 0 ? ( <Root> - <ScrollContainer> - <Table> - <thead> - <tr> - <td>Full Name</td> - <td>Invited On</td> - <td>Responded On</td> - <td>Submitted On</td> - <td /> - </tr> - </thead> - <tbody> - {reviewers.map((r, index) => ( - <TR - index={index} - key={r.email} - renderAcceptedLabel={renderAcceptedLabel} - reviewer={r} - showConfirmResend={showConfirmResend} - showConfirmRevoke={showConfirmRevoke} - /> - ))} - </tbody> - </Table> - </ScrollContainer> + <Table> + <thead> + <tr> + <td>Full Name</td> + <td width="150">Invited On</td> + <td width="200">Responded On</td> + <td width="150">Submitted On</td> + <td width="100" /> + </tr> + </thead> + <tbody> + {reviewers.map((r, index) => ( + <TR + index={index} + key={`${`${r.email} ${index}`}`} + renderAcceptedLabel={renderAcceptedLabel} + reviewer={r} + showConfirmResend={showConfirmResend} + showConfirmRevoke={showConfirmRevoke} + /> + ))} + </tbody> + </Table> </Root> ) : ( <div> No reviewers details </div> @@ -203,11 +203,6 @@ const StatusText = ReviewerEmail.extend` const DateText = ReviewerEmail.extend`` -const ScrollContainer = styled.div` - align-self: stretch; - flex: 1; - overflow: auto; -` const Root = styled.div` align-items: stretch; align-self: stretch; @@ -216,13 +211,28 @@ const Root = styled.div` flex-direction: column; justify-content: flex-start; height: 25vh; + display: table; ` const Table = styled.table` border-spacing: 0; border-collapse: collapse; width: 100%; - + thead { + display: table; + width: calc(100% - 1em); + } + tbody { + overflow: auto; + max-height: 180px; + margin-bottom: ${th('gridUnit')}; + display: block; + tr { + display: table; + width: 100%; + table-layout: fixed; + } + } & thead tr { ${defaultText}; border-bottom: ${th('borderDefault')}; diff --git a/packages/components-faraday/src/components/SignUp/AuthorSignup.js b/packages/components-faraday/src/components/SignUp/AuthorSignup.js new file mode 100644 index 0000000000000000000000000000000000000000..40f08b2fa2a95ca0c300d26a7ede8934f607f710 --- /dev/null +++ b/packages/components-faraday/src/components/SignUp/AuthorSignup.js @@ -0,0 +1,197 @@ +import React, { Fragment } from 'react' +import { reduxForm } from 'redux-form' +import { th } from '@pubsweet/ui-toolkit' +import { required } from 'xpub-validators' +import { compose, withState } from 'recompose' +import styled, { css } from 'styled-components' +import { Icon, Button, TextField, ValidatedField } from '@pubsweet/ui' + +import { FormItems } from '../UIComponents' + +const { Row, RowItem, Label, RootContainer, FormContainer } = FormItems + +const Step1 = ({ handleSubmit }) => ( + <CustomFormContainer onSubmit={handleSubmit}> + <Fragment> + <CustomRow noMargin> + <CustomRowItem vertical> + <Label>Email</Label> + <ValidatedField + component={TextField} + name="email" + validate={[required]} + /> + </CustomRowItem> + </CustomRow> + <CustomRow> + <CustomRowItem vertical> + <Label>Password</Label> + <ValidatedField + component={TextField} + name="password" + validate={[required]} + /> + </CustomRowItem> + </CustomRow> + <CustomRow> + <CustomRowItem vertical> + <Label>Confirm password</Label> + <ValidatedField + component={TextField} + name="confirmPassword" + validate={[required]} + /> + </CustomRowItem> + </CustomRow> + </Fragment> + <Button primary type="submit"> + Next + </Button> + </CustomFormContainer> +) + +const AuthorSignupStep1 = reduxForm({ + form: 'authorSignup', + destroyOnUnmount: false, + enableReinitialize: true, + forceUnregisterOnUnmount: true, +})(Step1) + +const Step2 = ({ handleSubmit }) => ( + <CustomFormContainer onSubmit={handleSubmit}> + <Fragment> + <CustomRow noMargin> + <CustomRowItem vertical> + <Label>First name</Label> + <ValidatedField + component={TextField} + name="firstName" + validate={[required]} + /> + </CustomRowItem> + </CustomRow> + <CustomRow noMargin> + <CustomRowItem vertical> + <Label>Last name</Label> + <ValidatedField + component={TextField} + name="lastName" + validate={[required]} + /> + </CustomRowItem> + </CustomRow> + <CustomRow noMargin> + <CustomRowItem vertical> + <Label>Affiliation</Label> + <ValidatedField + component={TextField} + name="affiliation" + validate={[required]} + /> + </CustomRowItem> + </CustomRow> + <CustomRow noMargin> + <CustomRowItem vertical> + <Label>Title</Label> + <ValidatedField + component={TextField} + name="title" + validate={[required]} + /> + </CustomRowItem> + </CustomRow> + </Fragment> + <Button primary type="submit"> + Submit + </Button> + </CustomFormContainer> +) + +const AuthorSignupStep2 = reduxForm({ + form: 'authorSignup', + destroyOnUnmount: false, + forceUnregisterOnUnmount: true, + onSubmit: null, +})(Step2) + +const AuthorWizard = ({ step, changeStep, history }) => ( + <CustomRootContainer> + <IconButton onClick={history.goBack}> + <Icon primary size={3}> + x + </Icon> + </IconButton> + <Title>Author Signup</Title> + {step === 0 && <AuthorSignupStep1 onSubmit={() => changeStep(1)} />} + {step === 1 && <AuthorSignupStep2 />} + </CustomRootContainer> +) + +export default compose(withState('step', 'changeStep', 0))(AuthorWizard) + +// #region styled-components +const verticalPadding = css` + padding: ${th('subGridUnit')} 0; +` + +const CustomRow = Row.extend` + div[role='alert'] { + margin-top: 0; + } +` + +const CustomRowItem = RowItem.extend` + & > div { + flex: 1; + + & > div { + max-width: 400px; + width: 400px; + } + } +` + +const CustomRootContainer = RootContainer.extend` + align-items: center; + border: ${th('borderDefault')}; + position: relative; +` + +const CustomFormContainer = FormContainer.extend` + align-items: center; + display: flex; + flex-direction: column; + justify-content: flex-start; +` + +const Title = styled.span` + font-family: ${th('fontHeading')}; + font-size: ${th('fontSizeHeading5')}; + ${verticalPadding}; +` + +const IconButton = styled.button` + align-items: center; + background-color: ${th('backgroundColorReverse')}; + border: none; + color: ${th('colorPrimary')}; + cursor: ${({ hide }) => (hide ? 'auto' : 'pointer')}; + display: flex; + font-family: ${th('fontInterface')}; + font-size: ${th('fontSizeBaseSmall')}; + opacity: ${({ hide }) => (hide ? 0 : 1)}; + text-align: left; + + position: absolute; + top: ${th('subGridUnit')}; + right: ${th('subGridUnit')}; + + &:active, + &:focus { + outline: none; + } + &:hover { + opacity: 0.7; + } +` +// #endregion diff --git a/packages/components-faraday/src/components/SignUp/ConfirmAccount.js b/packages/components-faraday/src/components/SignUp/ConfirmAccount.js new file mode 100644 index 0000000000000000000000000000000000000000..2a685b927f621271162a134a24794a56b8fcfc54 --- /dev/null +++ b/packages/components-faraday/src/components/SignUp/ConfirmAccount.js @@ -0,0 +1,62 @@ +import React from 'react' +import { connect } from 'react-redux' +import { Button } from '@pubsweet/ui' +import styled from 'styled-components' +import { th } from '@pubsweet/ui-toolkit' +import { compose, lifecycle, withState } from 'recompose' + +import { parseSearchParams } from '../utils' +import { confirmUser } from '../../redux/users' + +const ConfirmAccount = ({ message, history }) => ( + <Root> + <Title>{message}</Title> + <Button onClick={() => history.replace('/')} primary> + Go to Dashboard + </Button> + </Root> +) + +const confirmMessage = `Your account has been successfully confirmed. Welcome to Hindawi!` +const errorMessage = `Something went wrong with your account confirmation. Please try again.` + +export default compose( + connect(null, { confirmUser }), + withState('message', 'setConfirmMessage', 'Loading...'), + lifecycle({ + componentDidMount() { + const { location, confirmUser, setConfirmMessage } = this.props + const { confirmationToken, userId } = parseSearchParams(location.search) + if (userId) { + confirmUser(userId, confirmationToken) + .then(() => { + setConfirmMessage(confirmMessage) + }) + .catch(() => { + // errors are still gobbled up by pubsweet + setConfirmMessage(errorMessage) + }) + } + }, + }), +)(ConfirmAccount) + +// #region styled components +const Root = styled.div` + color: ${th('colorText')}; + margin: 0 auto; + text-align: center; + width: 70vw; + + a { + color: ${th('colorText')}; + } +` + +const Title = styled.div` + color: ${th('colorPrimary')}; + font-size: ${th('fontSizeHeading5')}; + font-family: ${th('fontHeading')}; + margin: 10px auto; +` +// #endregion diff --git a/packages/components-faraday/src/components/SignUp/ReviewerDecline.js b/packages/components-faraday/src/components/SignUp/ReviewerDecline.js index 74f726dd83b84c55f25a7ff531081c3590aa1ca5..bb023319c639fe92ec3f17f2fc48ecc95269ad3f 100644 --- a/packages/components-faraday/src/components/SignUp/ReviewerDecline.js +++ b/packages/components-faraday/src/components/SignUp/ReviewerDecline.js @@ -39,10 +39,14 @@ export default compose( invitationToken, reviewerDecline, replace, + fragmentId, } = this.props - reviewerDecline(invitationId, collectionId, invitationToken).catch( - redirectToError(replace), - ) + reviewerDecline( + invitationId, + collectionId, + fragmentId, + invitationToken, + ).catch(redirectToError(replace)) }, }), )(ReviewerDecline) diff --git a/packages/components-faraday/src/components/SignUp/ReviewerInviteDecision.js b/packages/components-faraday/src/components/SignUp/ReviewerInviteDecision.js index b613bc9692415a3ecf1d8ce297c4228ec0a4abfe..f55c227cc963ce9101dde08b522e239cd43c15ee 100644 --- a/packages/components-faraday/src/components/SignUp/ReviewerInviteDecision.js +++ b/packages/components-faraday/src/components/SignUp/ReviewerInviteDecision.js @@ -27,6 +27,7 @@ const { const agreeText = `You have been invited to review a manuscript on the Hindawi platform. Please set a password and proceed to the manuscript.` const declineText = `You have decline to work on a manuscript.` +const PasswordField = input => <TextField {...input} type="password" /> const min8Chars = minChars(8) const ReviewerInviteDecision = ({ agree, @@ -42,10 +43,10 @@ const ReviewerInviteDecision = ({ {agree === 'true' && ( <FormContainer onSubmit={handleSubmit}> <Row> - <RowItem> + <RowItem vertical> <Label> Password </Label> <ValidatedField - component={input => <TextField {...input} type="password" />} + component={PasswordField} name="password" validate={[required, min8Chars]} /> diff --git a/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js b/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js index f7b21c17851e6a0d6dcffe32d66c3fdd11bf77c7..59644b62ba40509ce17cbac33f16fe97581ca97c 100644 --- a/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js +++ b/packages/components-faraday/src/components/SignUp/SignUpInvitationForm.js @@ -6,22 +6,24 @@ import { FormItems } from '../UIComponents' const { RootContainer, Title, Subtitle, Email, Err } = FormItems +const defaultSubtitle = `Your details have been pre-filled, please review and confirm before set +your password.` + const SignUpInvitation = ({ step, - email, - token, error, journal, + onSubmit, nextStep, + prevStep, initialValues, - submitConfirmation, + type, + subtitle = defaultSubtitle, + title = 'Add New Account Details', }) => ( <RootContainer bordered> - <Title>Add New Account Details</Title> - <Subtitle> - Your details have been pre-filled, please review and confirm before set - your password. - </Subtitle> + <Title>{title}</Title> + <Subtitle>{subtitle}</Subtitle> <Email>{initialValues.email}</Email> {error && <Err>Token expired or Something went wrong.</Err>} {step === 0 && ( @@ -37,7 +39,9 @@ const SignUpInvitation = ({ error={error} initialValues={initialValues} journal={journal} - onSubmit={submitConfirmation} + onSubmit={onSubmit} + prevStep={prevStep} + type={type} /> )} </RootContainer> diff --git a/packages/components-faraday/src/components/SignUp/SignUpInvitationPage.js b/packages/components-faraday/src/components/SignUp/SignUpInvitationPage.js index 0ad1a8f2a6d40d1bf780c290160f0cba6fb714e9..12462b277554942af36262523997b6e070ab51b6 100644 --- a/packages/components-faraday/src/components/SignUp/SignUpInvitationPage.js +++ b/packages/components-faraday/src/components/SignUp/SignUpInvitationPage.js @@ -1,59 +1,29 @@ -import { get } from 'lodash' import { withJournal } from 'xpub-journal' -import { SubmissionError } from 'redux-form' -import { create } from 'pubsweet-client/src/helpers/api' -import { loginUser } from 'pubsweet-component-login/actions' import { compose, withState, withProps, withHandlers } from 'recompose' import SignUpInvitation from './SignUpInvitationForm' - -const login = (dispatch, values, history) => - dispatch(loginUser(values)) - .then(() => { - history.push('/') - }) - .catch(error => { - const err = get(error, 'response') - if (err) { - const errorMessage = get(JSON.parse(err), 'error') - throw new SubmissionError({ - password: errorMessage || 'Something went wrong', - }) - } - }) - -const confirmUser = (email, token, history) => (values, dispatch) => { - const request = { ...values, email, token } - if (values) { - return create('/users/reset-password', request) - .then(r => { - const { username } = r - const { password } = values - login(dispatch, { username, password }, history) - }) - .catch(error => { - const err = get(error, 'response') - if (err) { - const errorMessage = get(JSON.parse(err), 'error') - throw new SubmissionError({ - _error: errorMessage || 'Something went wrong', - }) - } - }) - } -} +import { + confirmUser, + signUpUser, + resetUserPassword, + setNewPassword, +} from './utils' export default compose( withJournal, - withState('step', 'changeStep', 0), + withState( + 'step', + 'changeStep', + ({ type }) => (type === 'forgotPassword' || type === 'setPassword' ? 1 : 0), + ), withProps(({ location }) => { const params = new URLSearchParams(location.search) - const email = params.get('email') - const token = params.get('token') - const title = params.get('title') - const lastName = params.get('lastName') - const firstName = params.get('firstName') - const affiliation = params.get('affiliation') + const email = params.get('email') || '' + const token = params.get('token') || '' + const title = params.get('title') || '' + const lastName = params.get('lastName') || '' + const firstName = params.get('firstName') || '' + const affiliation = params.get('affiliation') || '' return { initialValues: { @@ -69,10 +39,26 @@ export default compose( withHandlers({ nextStep: ({ changeStep }) => () => changeStep(step => step + 1), prevStep: ({ changeStep }) => () => changeStep(step => step - 1), - submitConfirmation: ({ - initialValues: { email, token }, + confirmInvitation: ({ + initialValues: { email = '', token = '' }, history, - ...rest }) => confirmUser(email, token, history), + signUp: ({ history }) => signUpUser(history), + forgotPassword: ({ history }) => resetUserPassword(history), + setNewPassword: ({ history }) => setNewPassword(history), }), + withProps( + ({ type, signUp, confirmInvitation, forgotPassword, setNewPassword }) => { + switch (type) { + case 'forgotPassword': + return { onSubmit: forgotPassword } + case 'signup': + return { onSubmit: signUp } + case 'setPassword': + return { onSubmit: setNewPassword } + default: + return { onSubmit: confirmInvitation } + } + }, + ), )(SignUpInvitation) diff --git a/packages/components-faraday/src/components/SignUp/SignUpStep0.js b/packages/components-faraday/src/components/SignUp/SignUpStep0.js index fb3e7eb4eaf716546b66dc00647ff44148a4f6e6..f9249333ee6cd689a869366bcaf43ae1644d3602 100644 --- a/packages/components-faraday/src/components/SignUp/SignUpStep0.js +++ b/packages/components-faraday/src/components/SignUp/SignUpStep0.js @@ -2,26 +2,33 @@ import React from 'react' import { isUndefined } from 'lodash' import { reduxForm } from 'redux-form' import { required } from 'xpub-validators' -import { Button, ValidatedField, TextField, Menu } from '@pubsweet/ui' +import { Button, ValidatedField, TextField, Menu, Checkbox } from '@pubsweet/ui' import { FormItems } from '../UIComponents' -const { FormContainer, Row, RowItem, Label } = FormItems +const { + FormContainer, + Row, + RowItem, + Label, + PrivatePolicy, + DefaultText, +} = FormItems const Step0 = ({ journal, handleSubmit, initialValues, error }) => !isUndefined(initialValues) ? ( <FormContainer onSubmit={handleSubmit}> <Row> - <RowItem vertical> - <Label> First name* </Label> + <RowItem vertical withRightMargin> + <Label>First name*</Label> <ValidatedField component={TextField} name="firstName" validate={[required]} /> </RowItem> - <RowItem vertical> - <Label> Last name* </Label> + <RowItem vertical withRightMargin> + <Label>Last name*</Label> <ValidatedField component={TextField} name="lastName" @@ -30,8 +37,8 @@ const Step0 = ({ journal, handleSubmit, initialValues, error }) => </RowItem> </Row> <Row> - <RowItem vertical> - <Label> Affiliation* </Label> + <RowItem vertical withRightMargin> + <Label>Affiliation*</Label> <ValidatedField component={TextField} name="affiliation" @@ -39,8 +46,8 @@ const Step0 = ({ journal, handleSubmit, initialValues, error }) => /> </RowItem> - <RowItem vertical> - <Label> Title* </Label> + <RowItem vertical withRightMargin> + <Label>Title*</Label> <ValidatedField component={input => <Menu {...input} options={journal.title} />} name="title" @@ -48,10 +55,48 @@ const Step0 = ({ journal, handleSubmit, initialValues, error }) => /> </RowItem> </Row> + <Row justify="left"> + <ValidatedField + component={input => ( + <Checkbox checked={input.value} type="checkbox" {...input} /> + )} + name="agreeTC" + validate={[required]} + /> + <DefaultText> + By creating this account, you agree to the{' '} + <a + href="https://www.hindawi.com/terms/" + rel="noopener noreferrer" + target="_blank" + > + Terms of Service + </a>. + </DefaultText> + </Row> + <Row> + <PrivatePolicy> + This account information will be processed by us in accordance with + our Privacy Policy for the purpose of registering your Faraday account + and allowing you to use the services available via the Faraday + platform. Please read our{' '} + <a + href="https://www.hindawi.com/privacy/" + rel="noopener noreferrer" + target="_blank" + > + Privacy Policy + </a>{' '} + for further information. + </PrivatePolicy> + </Row> + <Row /> <Row> - <Button primary type="submit"> - CONFIRM & PROCEED TO SET PASSWORD - </Button> + <RowItem centered> + <Button primary type="submit"> + CONFIRM & PROCEED TO SET PASSWORD + </Button> + </RowItem> </Row> </FormContainer> ) : ( diff --git a/packages/components-faraday/src/components/SignUp/SignUpStep1.js b/packages/components-faraday/src/components/SignUp/SignUpStep1.js index f5469320fbd6d05098fbdd9c4f78da2005e6a39f..f221cccae62f26a8f7bbc1e0d22d9437cfb14b45 100644 --- a/packages/components-faraday/src/components/SignUp/SignUpStep1.js +++ b/packages/components-faraday/src/components/SignUp/SignUpStep1.js @@ -1,24 +1,104 @@ -import React from 'react' +import React, { Fragment } from 'react' import { reduxForm } from 'redux-form' import { required } from 'xpub-validators' import { Button, ValidatedField, TextField } from '@pubsweet/ui' import { FormItems } from '../UIComponents' +import { passwordValidator, emailValidator } from '../utils' -const { FormContainer, Row, RowItem, Label, Err } = FormItems +const { Row, Err, Label, RowItem, FormContainer } = FormItems -const Step1 = ({ journal, handleSubmit, error }) => ( - <FormContainer onSubmit={handleSubmit}> +const PasswordField = input => <TextField {...input} type="password" /> +const EmailField = input => <TextField {...input} type="email" /> + +const SignUpForm = () => ( + <Fragment> + <Row> + <RowItem vertical> + <Label>Email</Label> + <ValidatedField + component={EmailField} + name="email" + validate={[required, emailValidator]} + /> + </RowItem> + </Row> <Row> - <RowItem> - <Label> Password </Label> + <RowItem vertical> + <Label>Password</Label> <ValidatedField - component={input => <TextField {...input} type="password" />} + component={PasswordField} name="password" validate={[required]} /> </RowItem> </Row> + <Row> + <RowItem vertical> + <Label>Confirm password</Label> + <ValidatedField + component={PasswordField} + name="confirmPassword" + validate={[required]} + /> + </RowItem> + </Row> + </Fragment> +) + +const InviteForm = () => ( + <Fragment> + <Row> + <RowItem vertical> + <Label>Password</Label> + <ValidatedField + component={PasswordField} + name="password" + validate={[required]} + /> + </RowItem> + </Row> + <Row> + <RowItem vertical> + <Label>Confirm password</Label> + <ValidatedField + component={PasswordField} + name="confirmPassword" + validate={[required]} + /> + </RowItem> + </Row> + </Fragment> +) + +const ForgotEmailForm = () => ( + <Fragment> + <Row> + <RowItem vertical> + <Label>Email</Label> + <ValidatedField + component={EmailField} + name="email" + validate={[required, emailValidator]} + /> + </RowItem> + </Row> + </Fragment> +) + +const withoutBack = ['forgotPassword', 'setPassword'] +const Step1 = ({ + error, + prevStep, + submitting, + handleSubmit, + type = 'invite', +}) => ( + <FormContainer onSubmit={handleSubmit}> + {type === 'signup' && <SignUpForm />} + {type === 'setPassword' && <InviteForm />} + {type === 'forgotPassword' && <ForgotEmailForm />} + {type === 'invite' && <InviteForm />} {error && ( <Row> <RowItem> @@ -26,8 +106,15 @@ const Step1 = ({ journal, handleSubmit, error }) => ( </RowItem> </Row> )} + <Row /> + <Row> - <Button primary type="submit"> + {!withoutBack.includes(type) && ( + <Button onClick={prevStep} type="button"> + BACK + </Button> + )} + <Button disabled={submitting} primary type="submit"> CONFIRM </Button> </Row> @@ -38,4 +125,5 @@ export default reduxForm({ form: 'signUpInvitation', destroyOnUnmount: false, forceUnregisterOnUnmount: true, + validate: passwordValidator, })(Step1) diff --git a/packages/components-faraday/src/components/SignUp/index.js b/packages/components-faraday/src/components/SignUp/index.js index 8fc6a0dd3bbf70231a30792e4d78aa5594d848f4..d06b74c2b90e4e13790dc904c906288b50a4fe29 100644 --- a/packages/components-faraday/src/components/SignUp/index.js +++ b/packages/components-faraday/src/components/SignUp/index.js @@ -1,3 +1,5 @@ +export { default as AuthorSignup } from './AuthorSignup' +export { default as ConfirmAccount } from './ConfirmAccount' export { default as ReviewerSignUp } from './ReviewerSignUp' export { default as ReviewerDecline } from './ReviewerDecline' export { default as SignUpInvitationPage } from './SignUpInvitationPage' diff --git a/packages/components-faraday/src/components/SignUp/utils.js b/packages/components-faraday/src/components/SignUp/utils.js index 4f264402c54df9f81ce41908b74ce063b72d3e73..a46f99096ef2210e92ffa5c292846780009635da 100644 --- a/packages/components-faraday/src/components/SignUp/utils.js +++ b/packages/components-faraday/src/components/SignUp/utils.js @@ -1,4 +1,26 @@ /* eslint-disable */ +import { omit, get } from 'lodash' +import { create } from 'pubsweet-client/src/helpers/api' +import { loginUser } from 'pubsweet-component-login/actions' + +import { handleFormError } from '../utils' + +const generatePasswordHash = () => + Array.from({ length: 4 }, () => + Math.random() + .toString(36) + .slice(4), + ).join('') + +export const parseSignupAuthor = ({ token, confirmPassword, ...values }) => ({ + ...values, + admin: false, + isConfirmed: false, + editorInChief: false, + handlingEditor: false, + username: values.email, + confirmationToken: generatePasswordHash(), +}) export const parseSearchParams = url => { const params = new URLSearchParams(url) @@ -8,3 +30,60 @@ export const parseSearchParams = url => { } return parsedObject } + +export const login = (dispatch, values, history) => + dispatch(loginUser(values)) + .then(() => { + history.push('/') + }) + .catch(handleFormError) + +export const confirmUser = (email, token, history) => (values, dispatch) => { + const request = { ...values, email, token } + if (values) { + return create('/users/reset-password', omit(request, ['confirmPassword'])) + .then(r => { + const { username } = r + const { password } = values + login(dispatch, { username, password }, history) + }) + .catch(handleFormError) + } +} + +export const signUpUser = history => (values, dispatch) => + create('/users', parseSignupAuthor(values)) + .then(r => { + const { username } = r + const { password } = values + login(dispatch, { username, password }, history).then(() => { + create('/emails', { + email: values.email, + type: 'signup', + }) + }) + }) + .catch(handleFormError) + +export const resetUserPassword = history => ({ email }, dispatch) => + create(`/users/forgot-password`, { email }) + .then(r => { + const message = get(r, 'message') || 'Password reset email has been sent.' + history.push('/info-page', { + title: 'Reset Password', + content: message, + }) + }) + .catch(handleFormError) + +export const setNewPassword = history => ( + { email, token, password }, + dispatch, +) => + create(`/users/reset-password`, { email, token, password }) + .then(() => { + login(dispatch, { username: email, password }, history).then(() => + history.push('/'), + ) + }) + .catch(handleFormError) diff --git a/packages/components-faraday/src/components/UIComponents/ConfirmationPage.js b/packages/components-faraday/src/components/UIComponents/ConfirmationPage.js index 3f974da8f2e7384d696cdde1db84f56aa6b6dcdc..3c893e825b13c2dfa75564ee9a3b5f67f9f5aa01 100644 --- a/packages/components-faraday/src/components/UIComponents/ConfirmationPage.js +++ b/packages/components-faraday/src/components/UIComponents/ConfirmationPage.js @@ -81,6 +81,6 @@ const Title = styled.div` color: ${th('colorPrimary')}; font-size: ${th('fontSizeHeading5')}; font-family: ${th('fontHeading')}; - margin: 10px auto; + margin: ${th('gridUnit')} auto; ` // #endregion diff --git a/packages/components-faraday/src/components/UIComponents/FormItems.js b/packages/components-faraday/src/components/UIComponents/FormItems.js index 844b841f2b52b3847677e0859bccd95a470f9594..8f6ebf89faf00b45808573e56a929b1635470ee5 100644 --- a/packages/components-faraday/src/components/UIComponents/FormItems.js +++ b/packages/components-faraday/src/components/UIComponents/FormItems.js @@ -1,5 +1,15 @@ +import React from 'react' import { th } from '@pubsweet/ui' -import styled from 'styled-components' +import styled, { css } from 'styled-components' + +const defaultText = css` + color: ${th('colorText')}; + font-family: ${th('fontReading')}; + font-size: ${th('fontSizeBaseSmall')}; +` +export const DefaultText = styled.div` + ${defaultText}; +` export const RootContainer = styled.div` background-color: ${th('backgroundColorReverse')}; @@ -39,11 +49,16 @@ export const Email = styled.div` export const FormContainer = styled.form`` export const Row = styled.div` - align-items: center; + align-items: flex-start; display: flex; flex-direction: row; - justify-content: space-evenly; - margin: calc(${th('subGridUnit')} * 2) 0; + justify-content: ${({ justify }) => justify || 'space-evenly'}; + margin: ${({ noMargin }) => + noMargin ? 0 : css`calc(${th('subGridUnit')} * 2) 0`}; + + label + div[role='alert'] { + margin-top: 0; + } ` export const RowItem = styled.div` @@ -51,7 +66,12 @@ export const RowItem = styled.div` flex: ${({ flex }) => flex || 1}; flex-direction: ${({ vertical }) => (vertical ? 'column' : 'row')}; justify-content: ${({ centered }) => (centered ? 'center' : 'initial')}; - margin: 0 ${th('subGridUnit')}; + margin-right: ${({ withRightMargin }) => + withRightMargin ? th('gridUnit') : 0}; + + & > div { + flex: 1; + } ` export const Label = styled.div` @@ -66,7 +86,7 @@ export const Err = styled.span` color: ${th('colorError')}; font-family: ${th('fontReading')}; font-size: ${th('fontSizeBase')}; - margin-top: calc(${th('gridUnit')} * -1); + margin-top: 0; text-align: center; ` @@ -76,7 +96,7 @@ export const Textarea = styled.textarea` hasError ? th('colorError') : th('colorPrimary')}; font-size: ${th('fontSizeBaseSmall')}; font-family: ${th('fontWriting')}; - padding: calc(${th('subGridUnit')}*2); + padding: ${th('subGridUnit')}; outline: none; transition: all 300ms linear; @@ -92,3 +112,26 @@ export const Textarea = styled.textarea` background-color: ${th('colorBackgroundHue')}; } ` + +export const CustomRadioGroup = styled.div` + div { + flex-direction: row; + justify-content: ${({ justify }) => justify || 'space-between'}; + label { + span:last-child { + font-style: normal; + ${defaultText}; + } + } + } + & ~ div { + margin-top: 0; + } +` + +export const PrivatePolicy = styled.div` + ${defaultText}; + text-align: justify; +` + +export const TextAreaField = input => <Textarea {...input} height={70} /> diff --git a/packages/components-faraday/src/components/UIComponents/InfoPage.js b/packages/components-faraday/src/components/UIComponents/InfoPage.js new file mode 100644 index 0000000000000000000000000000000000000000..38e16bb14a1a49e448470ae2860cd9b87f102bdd --- /dev/null +++ b/packages/components-faraday/src/components/UIComponents/InfoPage.js @@ -0,0 +1,52 @@ +import React from 'react' +import styled from 'styled-components' +import { Button, th } from '@pubsweet/ui' + +const InfoPage = ({ + location: { + state: { + title = 'Successfully', + content = '', + path = '/', + buttonText = 'Go to Dashboard', + }, + }, + history, +}) => ( + <Root> + <Title>{title}</Title> + <Content>{content}</Content> + <Button onClick={() => history.push(path)} primary> + {buttonText} + </Button> + </Root> +) + +export default InfoPage + +// #region styles +const Root = styled.div` + color: ${th('colorText')}; + margin: 0 auto; + text-align: center; + width: 70vw; + + a { + color: ${th('colorText')}; + } +` + +const Title = styled.div` + color: ${th('colorPrimary')}; + font-size: ${th('fontSizeHeading5')}; + font-family: ${th('fontHeading')}; + margin: ${th('gridUnit')} auto; +` + +const Content = styled.p` + color: ${th('colorPrimary')}; + font-family: ${th('fontReading')}; + font-size: ${th('fontSizeBase')}; + margin: ${th('gridUnit')} auto; +` +// #endregion diff --git a/packages/components-faraday/src/components/UIComponents/index.js b/packages/components-faraday/src/components/UIComponents/index.js index 7ccd5732e09478b2c6463473098039f151a19754..5f22ea1c71cc274efe11bf2fdc777e0649a7377b 100644 --- a/packages/components-faraday/src/components/UIComponents/index.js +++ b/packages/components-faraday/src/components/UIComponents/index.js @@ -4,6 +4,7 @@ export { FormItems } export { default as Logo } from './Logo' export { default as Spinner } from './Spinner' export { default as NotFound } from './NotFound' +export { default as InfoPage } from './InfoPage' export { default as ErrorPage } from './ErrorPage' export { default as DateParser } from './DateParser' export { default as ConfirmationPage } from './ConfirmationPage' diff --git a/packages/components-faraday/src/components/index.js b/packages/components-faraday/src/components/index.js index 8765d426a79c94ef859e7f154d49d56a6e59d51e..525ddeb0919f6fc43f7c3a9869b490d110e55adb 100644 --- a/packages/components-faraday/src/components/index.js +++ b/packages/components-faraday/src/components/index.js @@ -1,13 +1,16 @@ import { Decision } from './MakeDecision' +import * as Components from './UIComponents' import { Recommendation } from './MakeRecommendation' export { default as Steps } from './Steps/Steps' export { default as Files } from './Files/Files' export { default as AppBar } from './AppBar/AppBar' export { default as AuthorList } from './AuthorList/AuthorList' +export { default as withVersion } from './Dashboard/withVersion.js' export { default as SortableList } from './SortableList/SortableList' export { Decision } +export { Components } export { Recommendation } export { DragHandle } from './AuthorList/FormItems' export { Dropdown, DateParser, Logo, Spinner } from './UIComponents' diff --git a/packages/components-faraday/src/components/utils.js b/packages/components-faraday/src/components/utils.js index 1780bd7a318d551ee2fb4dfa5a03c2dbb03be8e1..60a5639770622876a45f7c913667856f5be5b5fd 100644 --- a/packages/components-faraday/src/components/utils.js +++ b/packages/components-faraday/src/components/utils.js @@ -1,3 +1,4 @@ +import { SubmissionError } from 'redux-form' import { get, find, capitalize } from 'lodash' export const parseTitle = version => { @@ -55,6 +56,17 @@ export const handleError = fn => e => { fn(get(JSON.parse(e.response), 'error') || 'Oops! Something went wrong!') } +export const handleFormError = error => { + const err = get(error, 'response') + if (err) { + const errorMessage = + get(JSON.parse(err), 'error') || get(JSON.parse(err), 'message') + throw new SubmissionError({ + _error: errorMessage || 'Something went wrong', + }) + } +} + const emailRegex = new RegExp( /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i, //eslint-disable-line ) @@ -66,3 +78,28 @@ export const redirectToError = redirectFn => err => { const errorText = get(JSON.parse(err.response), 'error') redirectFn('/error-page', errorText || 'Oops! Something went wrong.') } + +export const passwordValidator = values => { + const errors = {} + if (!values.password) { + errors.password = 'Required' + } + if (!values.confirmPassword) { + errors.confirmPassword = 'Required' + } else if (values.confirmPassword !== values.password) { + errors.confirmPassword = 'Password mismatched' + } + + return errors +} + +export const parseSearchParams = url => { + const params = new URLSearchParams(url) + const parsedObject = {} + /* eslint-disable */ + for ([key, value] of params) { + parsedObject[key] = value + } + /* eslint-enable */ + return parsedObject +} 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/editors.js b/packages/components-faraday/src/redux/editors.js index 63cb3b837d2b43150e4487bcc0cb65fd60327e9d..17984b255e19f6a3e51ac84481487980774744c1 100644 --- a/packages/components-faraday/src/redux/editors.js +++ b/packages/components-faraday/src/redux/editors.js @@ -38,7 +38,7 @@ export const assignHandlingEditor = (email, collectionId) => dispatch => { }, err => { dispatch(editorsDone()) - return err + throw err }, ) } @@ -57,7 +57,7 @@ export const revokeHandlingEditor = ( }, err => { dispatch(editorsDone()) - return err + throw err }, ) } diff --git a/packages/components-faraday/src/redux/files.js b/packages/components-faraday/src/redux/files.js index a296178e9e7e0b996ba035bdf84adf4000529b24..63a616ed58a0ccf5b58cc023cd4917b07661c484 100644 --- a/packages/components-faraday/src/redux/files.js +++ b/packages/components-faraday/src/redux/files.js @@ -80,14 +80,17 @@ export const uploadFile = (file, type, fragmentId) => dispatch => { ) } -export const deleteFile = fileId => dispatch => { - dispatch(removeRequest()) +export const deleteFile = (fileId, type = 'manuscripts') => dispatch => { + dispatch(removeRequest(type)) return remove(`/files/${fileId}`) .then(r => { dispatch(removeSuccess()) return r }) - .catch(err => dispatch(removeFailure(err.message))) + .catch(err => { + dispatch(removeFailure(err.message)) + throw err + }) } export const getSignedUrl = fileId => dispatch => get(`/files/${fileId}`) @@ -95,6 +98,7 @@ export const getSignedUrl = fileId => dispatch => get(`/files/${fileId}`) // reducer export default (state = initialState, action) => { switch (action.type) { + case REMOVE_REQUEST: case UPLOAD_REQUEST: return { ...state, @@ -105,12 +109,14 @@ export default (state = initialState, action) => { }, } case UPLOAD_FAILURE: + case REMOVE_FAILURE: return { ...state, isFetching: initialState.isFetching, error: action.error, } case UPLOAD_SUCCESS: + case REMOVE_SUCCESS: return { ...state, isFetching: initialState.isFetching, diff --git a/packages/components-faraday/src/redux/recommendations.js b/packages/components-faraday/src/redux/recommendations.js index a85e13f8c34818166263789e08c10df7d76ff3be..4a62913c04cc48357bd9f82d65437732a36cf230 100644 --- a/packages/components-faraday/src/redux/recommendations.js +++ b/packages/components-faraday/src/redux/recommendations.js @@ -1,151 +1,40 @@ 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 = recommendations => ({ - type: GET_RECOMMENDATIONS_SUCCESS, - payload: { recommendations }, -}) - -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, ) +export const selectReviewRecommendations = (state, fragmentId) => + selectRecommendations(state, fragmentId).filter( + r => r.recommendationType === 'review', + ) // #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)) - } - }, ) -} -// #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: action.payload.recommendations, - } - 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..73a7501d1ac367974c29bd220b0d7787228176d2 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 @@ -173,11 +194,12 @@ export const reviewerDecision = ( export const reviewerDecline = ( invitationId, collectionId, + fragmentId, invitationToken, ) => dispatch => { dispatch(reviewerDecisionRequest()) return update( - `/collections/${collectionId}/invitations/${invitationId}/decline`, + `/collections/${collectionId}/fragments/${fragmentId}/invitations/${invitationId}/decline`, { invitationToken, }, diff --git a/packages/components-faraday/src/redux/users.js b/packages/components-faraday/src/redux/users.js index dd9cd57ab2db907fbbeb5bd31a46a90afb859c06..a4e6d1a12984cde20989701eadbeddae1728779b 100644 --- a/packages/components-faraday/src/redux/users.js +++ b/packages/components-faraday/src/redux/users.js @@ -1,4 +1,22 @@ import { get } from 'lodash' +import { create } from 'pubsweet-client/src/helpers/api' + +const LOGIN_SUCCESS = 'LOGIN_SUCCESS' + +const loginSuccess = user => ({ + type: LOGIN_SUCCESS, + token: user.token, + user, +}) export const currentUserIs = (state, role) => get(state, `currentUser.user.${role}`) + +export const confirmUser = (userId, confirmationToken) => dispatch => + create(`/users/confirm`, { + userId, + confirmationToken, + }).then(user => { + localStorage.setItem('token', user.token) + return dispatch(loginSuccess(user)) + }) 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/FaradayApp.js b/packages/xpub-faraday/app/FaradayApp.js index 8938b75560aa3fa57aadcdc95e2ec1e6ad4cab08..05d83b3361bea32267f77fc663ef3f834ae27717 100644 --- a/packages/xpub-faraday/app/FaradayApp.js +++ b/packages/xpub-faraday/app/FaradayApp.js @@ -6,14 +6,21 @@ import { actions } from 'pubsweet-client' import { withJournal } from 'xpub-journal' import { AppBar } from 'pubsweet-components-faraday/src/components' -const App = ({ children, currentUser, journal, logoutUser }) => ( - <Root> +const App = ({ + journal, + children, + logoutUser, + currentUser, + isAuthenticated, +}) => ( + <Root className="faraday-root"> <AppBar brand={journal.metadata.name} + isAuthenticated={isAuthenticated} onLogoutClick={logoutUser} user={currentUser} /> - <MainContainer>{children}</MainContainer> + <MainContainer className="faraday-main">{children}</MainContainer> </Root> ) @@ -21,6 +28,7 @@ export default compose( connect( state => ({ currentUser: state.currentUser.user, + isAuthenticated: state.currentUser.isAuthenticated, }), { logoutUser: actions.logoutUser }, ), @@ -35,7 +43,7 @@ const Root = styled.div` ` const MainContainer = styled.div` - padding: 90px 10px 40px; - min-height: 100vh; background-color: ${props => props.theme.backgroundColor || '#fff'}; + padding: 110px 10px 0 10px; + height: 100vh; ` diff --git a/packages/xpub-faraday/app/config/journal/submit-wizard.js b/packages/xpub-faraday/app/config/journal/submit-wizard.js index 8654c6a2eb9b2222c089799fcc8501812d3b467e..a7c54767357c09f25fe2d67e4e7084471749ad02 100644 --- a/packages/xpub-faraday/app/config/journal/submit-wizard.js +++ b/packages/xpub-faraday/app/config/journal/submit-wizard.js @@ -2,7 +2,7 @@ import React from 'react' import styled from 'styled-components' import uploadFileFn from 'xpub-upload' import { AbstractEditor, TitleEditor } from 'xpub-edit' -import { Menu, YesOrNo, TextField, CheckboxGroup } from '@pubsweet/ui' +import { Menu, YesOrNo, CheckboxGroup } from '@pubsweet/ui' import { required, minChars, minSize } from 'xpub-validators' import { AuthorList, Files } from 'pubsweet-components-faraday/src/components' @@ -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: [ @@ -102,7 +108,7 @@ export default { label: 'Manuscript & Authors Details', title: '3. Manuscript & Authors Details', subtitle: - 'Please provide the details of all the authors of this manuscript, in the order that they appear on the manuscript. Your details are already pre-filled since, in order tu submit a manuscript you must be one of the authors', + 'Please provide the details of all the authors of this manuscript, in the order that they appear on the manuscript. Your details are already pre-filled since, in order tu submit a manuscript you must be one of the authors.', children: [ { fieldId: 'metadata.title', @@ -147,7 +153,7 @@ export default { validate: [required], }, { - fieldId: 'editMode', + fieldId: 'authorForm', renderComponent: Spacing, validate: [editModeEnabled], }, @@ -163,7 +169,7 @@ export default { condition: 'yes', }, fieldId: 'conflicts.message', - renderComponent: TextField, + renderComponent: AbstractEditor, label: 'Conflict of interest details', validate: [required, min3Chars], }, @@ -174,7 +180,7 @@ export default { title: '4. Manuscript Files Upload', children: [ { - fieldId: 'file-upload', + fieldId: 'files', renderComponent: Files, validate: [requiredFiles], }, 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/app/routes.js b/packages/xpub-faraday/app/routes.js index bf161391ab7e1d48ff2c7d2241929e5160a7e68c..98d8d460a2558e90eb0670bb9e6965a0c63f5519 100644 --- a/packages/xpub-faraday/app/routes.js +++ b/packages/xpub-faraday/app/routes.js @@ -3,30 +3,31 @@ import { withProps } from 'recompose' import { Route, Switch } from 'react-router-dom' import { AuthenticatedComponent } from 'pubsweet-client' import Login from 'pubsweet-component-login/LoginContainer' -import Signup from 'pubsweet-component-signup/SignupContainer' import { Wizard } from 'pubsweet-component-wizard/src/components' import { ManuscriptPage } from 'pubsweet-component-manuscript/src/components' import DashboardPage from 'pubsweet-components-faraday/src/components/Dashboard' import { NotFound, - ConfirmationPage, + InfoPage, ErrorPage, + ConfirmationPage, } from 'pubsweet-components-faraday/src/components/UIComponents/' import { - AdminDashboard, AdminUsers, AdminRoute, + AdminDashboard, } from 'pubsweet-components-faraday/src/components/Admin' import AddEditUser from 'pubsweet-components-faraday/src/components/Admin/AddEditUser' import { - SignUpInvitationPage, + ConfirmAccount, ReviewerSignUp, + SignUpInvitationPage, } from 'pubsweet-components-faraday/src/components/SignUp' import FaradayApp from './FaradayApp' -const LoginPage = withProps({ passwordReset: false })(Login) +const LoginPage = withProps({ passwordReset: true })(Login) const PrivateRoute = ({ component: Component, ...rest }) => ( <Route @@ -43,7 +44,44 @@ const Routes = () => ( <FaradayApp> <Switch> <Route component={LoginPage} exact path="/login" /> - <Route component={Signup} exact path="/signup" /> + <Route component={SignUpInvitationPage} exact path="/invite" /> + <Route + component={routeParams => ( + <SignUpInvitationPage + subtitle={null} + title="Author signup" + type="signup" + {...routeParams} + /> + )} + exact + path="/signup" + /> + <Route + component={routeParams => ( + <SignUpInvitationPage + subtitle={null} + title="Reset password" + type="forgotPassword" + {...routeParams} + /> + )} + exact + path="/password-reset" + /> + <Route + component={routeParams => ( + <SignUpInvitationPage + subtitle={null} + title="Set new password" + type="setPassword" + {...routeParams} + /> + )} + exact + path="/forgot-password" + /> + <Route component={ConfirmAccount} exact path="/confirm-signup" /> <PrivateRoute component={DashboardPage} exact path="/" /> <PrivateRoute component={ConfirmationPage} @@ -63,7 +101,6 @@ const Routes = () => ( exact path="/projects/:project/versions/:version/submit" /> - <Route component={SignUpInvitationPage} exact path="/invite" /> <Route component={ReviewerSignUp} exact path="/invite-reviewer" /> <PrivateRoute component={ManuscriptPage} @@ -72,6 +109,7 @@ const Routes = () => ( /> <Route component={ErrorPage} exact path="/error-page" /> + <Route component={InfoPage} exact path="/info-page" /> <Route component={NotFound} /> </Switch> </FaradayApp> diff --git a/packages/xpub-faraday/config/authsome-helpers.js b/packages/xpub-faraday/config/authsome-helpers.js index 1b7642bfca5445c4c1651d8cbfcca8aad45ef8c5..49d976c4c1491b23f5ab7d1f062ba8ee96373457 100644 --- a/packages/xpub-faraday/config/authsome-helpers.js +++ b/packages/xpub-faraday/config/authsome-helpers.js @@ -1,10 +1,12 @@ -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') +const keysToOmit = ['email', 'id'] const publicStatusesPermissions = ['author', 'reviewer'] +const authorAllowedStatuses = ['revisionRequested', 'rejected', 'accepted'] const parseAuthorsData = (coll, matchingCollPerm) => { if (['reviewer'].includes(matchingCollPerm.permission)) { @@ -44,7 +46,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 +57,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 +74,130 @@ 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) +} + +const filterAuthorRecommendations = (recommendations, status, isLast) => { + const canViewRecommendations = authorAllowedStatuses.includes(status) + if (canViewRecommendations || !isLast) { + return recommendations.map(r => ({ + ...r, + comments: r.comments ? r.comments.filter(c => c.public) : [], + })) + } + return [] +} + +const stripeCollectionByRole = (coll = {}, role = '') => { + if (role === 'author') { + const { handlingEditor } = coll + + if (!authorAllowedStatuses.includes(coll.status)) { + return { + ...coll, + handlingEditor: handlingEditor && + handlingEditor.isAccepted && { + ...omit(handlingEditor, keysToOmit), + name: 'Assigned', + }, + } + } + } + return coll +} + +const stripeFragmentByRole = ({ + fragment = {}, + role = '', + status = 'draft', + user = {}, + isLast = false, +}) => { + const { recommendations, files, authors } = fragment + switch (role) { + case 'author': + return { + ...fragment, + recommendations: recommendations + ? filterAuthorRecommendations(recommendations, status, isLast) + : [], + } + case 'reviewer': + return { + ...fragment, + files: omit(files, ['coverLetter']), + authors: authors.map(a => omit(a, ['email'])), + recommendations: recommendations + ? recommendations.filter(r => r.userId === user.id) + : [], + } + default: + return fragment + } } module.exports = { + filterObjectData, parseAuthorsData, setPublicStatuses, - filterRefusedInvitations, - filterObjectData, getTeamsByPermissions, + filterRefusedInvitations, + isOwner, + isHandlingEditor, + getUserPermissions, + heIsInvitedToFragment, + hasPermissionForObject, + isInDraft, + hasFragmentInDraft, + stripeCollectionByRole, + stripeFragmentByRole, } diff --git a/packages/xpub-faraday/config/authsome-mode.js b/packages/xpub-faraday/config/authsome-mode.js index 948dd93e25476745ef93e4db143ff99f79ff8cec..143c49ac2b2ba29100d44a12c9a3352eda9f26e7 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, last } = 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,136 +50,230 @@ function unauthenticatedUser(operation, object) { return false } -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'), +const createPaths = ['/collections', '/collections/:collectionId/fragments'] + +async function applyAuthenticatedUserPolicy(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 ( - 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') { 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, + }) + + const { role } = userPermissions.find( + p => + p.objectId === collection.id || + collection.fragments.includes(p.objectId), + ) + const visibleStatus = get(statuses, `${status}.${role}.label`) + const parsedCollection = helpers.stripeCollectionByRole( + collection, + role, + ) + + return { + ...parsedCollection, + visibleStatus, + } + }, } } - 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)) { - return true + if (get(object, 'type') === 'fragment') { + if (helpers.isInDraft(object)) { + return helpers.isOwner({ user, object }) + } + + 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 + + const collectionId = get(object, 'collectionId') + const { status, fragments } = await context.models.Collection.find( + collectionId, + ) + + return { + filter: fragment => + helpers.stripeFragmentByRole({ + fragment, + role: permission.role, + status, + user, + isLast: last(fragments) === fragment.id, + }), } } - } - 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 everyone to create manuscripts and versions + if (createPaths.includes(object.path)) { + return true + } - if (user.teams.length !== 0 && ['GET'].includes(operation)) { - 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'], + }) } - return false + // allow owner to submit a manuscript + if ( + get(object, 'path') === + '/api/collections/:collectionId/fragments/:fragmentId/submit' + ) { + return helpers.isOwner({ user, object: object.fragment }) + } } - 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)) { + 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 } + + // 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) } +async function applyEditorInChiefPolicy(user, operation, object, context) { + if (operation === 'GET') { + if (get(object, 'type') === 'collection') { + return { + filter: collection => ({ + ...collection, + visibleStatus: get( + statuses, + `${collection.status}.editorInChief.label`, + ), + }), + } + } + } + return true +} + const authsomeMode = async (userId, operation, object, context) => { if (!userId) { return unauthenticatedUser(operation, object) @@ -243,11 +283,12 @@ const authsomeMode = async (userId, operation, object, context) => { // 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 (get(user, 'admin') || get(user, 'editorInChief')) { + return applyEditorInChiefPolicy(user, operation, object, context) + } if (user) { - return authenticatedUser(user, operation, object, context) + return applyAuthenticatedUserPolicy(user, operation, object, context) } return false diff --git a/packages/xpub-faraday/config/default.js b/packages/xpub-faraday/config/default.js index 70c2472c57724a1ae1c711fda8b99e007c742d26..19ac346db1ac276c83197297b728bbcf89cb83cc 100644 --- a/packages/xpub-faraday/config/default.js +++ b/packages/xpub-faraday/config/default.js @@ -40,6 +40,7 @@ module.exports = { port: 3000, logger, uploads: 'uploads', + secret: 'SECRET', }, 'pubsweet-client': { API_ENDPOINT: '/api', @@ -66,9 +67,15 @@ module.exports = { 'invite-reset-password': { url: process.env.PUBSWEET_INVITE_PASSWORD_RESET_URL || '/invite', }, + 'forgot-password': { + url: process.env.PUBSWEET_FORGOT_PASSWORD_URL || '/forgot-password', + }, 'invite-reviewer': { url: process.env.PUBSWEET_INVITE_REVIEWER_URL || '/invite-reviewer', }, + 'confirm-signup': { + url: process.env.PUBSWEET_CONFIRM_SIGNUP_URL || '/confirm-signup', + }, roles: { global: ['admin', 'editorInChief', 'author', 'handlingEditor'], collection: ['handlingEditor', 'reviewer', 'author'], @@ -91,48 +98,313 @@ module.exports = { ], statuses: { draft: { - public: 'Draft', - private: 'Draft', + importance: 1, + author: { + label: 'Complete Submission', + needsAttention: true, + }, + admin: { + label: 'Complete Submission', + needsAttention: true, + }, + }, + technicalChecks: { + importance: 2, + author: { + label: 'Submitted', + needsAttention: false, + }, + editorInChief: { + label: 'QA', + needsAttention: false, + }, + admin: { + label: 'Approve QA', + needsAttention: true, + }, }, submitted: { - public: 'Submitted', - private: 'Submitted', + importance: 3, + author: { + label: 'Submitted', + needsAttention: false, + }, + editorInChief: { + label: 'Assign HE', + needsAttention: true, + }, + admin: { + label: 'Assign HE', + needsAttention: true, + }, }, heInvited: { - public: 'Submitted', - private: 'Handling Editor Invited', + importance: 4, + author: { + label: 'HE Invited', + needsAttention: false, + }, + handlingEditor: { + label: 'Respond to Invite', + needsAttention: true, + }, + editorInChief: { + label: 'HE Invited', + needsAttention: false, + }, + admin: { + label: 'Respond to Invite', + needsAttention: true, + }, }, heAssigned: { - public: 'Handling Editor Assigned', - private: 'Handling Editor Assigned', + importance: 5, + author: { + label: 'HE Assigned', + needsAttention: false, + }, + handlingEditor: { + label: 'Invite Reviewers', + needsAttention: true, + }, + editorInChief: { + label: 'HE Assigned', + needsAttention: false, + }, + admin: { + label: 'Invite Reviewers', + needsAttention: true, + }, }, reviewersInvited: { - public: 'Reviewers Invited', - private: 'Reviewers Invited', + importance: 6, + author: { + label: 'Reviewers Invited', + needsAttention: false, + }, + handlingEditor: { + label: 'Check Review Process', + needsAttention: true, + }, + editorInChief: { + label: 'Reviewers Invited', + needsAttention: false, + }, + reviewer: { + label: 'Respond to Invite', + needsAttention: true, + }, + admin: { + label: 'Respond to Invite', + needsAttention: true, + }, }, underReview: { - public: 'Under Review', - private: 'Under Review', + importance: 7, + author: { + label: 'Under Review', + needsAttention: false, + }, + handlingEditor: { + label: 'Check Review Process', + needsAttention: true, + }, + editorInChief: { + label: 'Under Review', + needsAttention: false, + }, + reviewer: { + label: 'Complete Review', + needsAttention: true, + }, + admin: { + label: 'Complete Review', + needsAttention: true, + }, }, reviewCompleted: { - public: 'Under Review', - private: 'Review Completed', - }, - pendingApproval: { - public: 'Under Review', - private: 'Pending Approval', + importance: 8, + author: { + label: 'Review Completed', + needsAttention: false, + }, + handlingEditor: { + label: 'Make Recommendation', + needsAttention: true, + }, + editorInChief: { + label: 'Review Completed', + needsAttention: false, + }, + reviewer: { + label: 'Review Completed', + needsAttention: false, + }, + admin: { + label: 'Make Recommendation', + needsAttention: true, + }, }, revisionRequested: { - public: 'Revision Requested', - private: 'Revision Requested', + importance: 9, + author: { + label: 'Submit Revision', + needsAttention: true, + }, + handlingEditor: { + label: 'Revision Requested', + needsAttention: false, + }, + editorInChief: { + label: 'Revision Requested', + needsAttention: false, + }, + reviewer: { + label: 'Revision Requested', + needsAttention: false, + }, + admin: { + label: 'Submit Revision', + needsAttention: true, + }, + }, + pendingApproval: { + importance: 10, + author: { + label: 'Pending Approval', + needsAttention: false, + }, + handlingEditor: { + label: 'Pending Approval', + needsAttention: false, + }, + editorInChief: { + label: 'Make Decision', + needsAttention: true, + }, + reviewer: { + label: 'Pending Approval', + needsAttention: false, + }, + admin: { + label: 'Make Decision', + needsAttention: true, + }, }, rejected: { - public: 'Rejected', - private: 'Rejected', + importance: 11, + author: { + label: 'Rejected', + needsAttention: false, + }, + handlingEditor: { + label: 'Rejected', + needsAttention: false, + }, + editorInChief: { + label: 'Rejected', + needsAttention: false, + }, + reviewer: { + label: 'Rejected', + needsAttention: false, + }, + admin: { + label: 'Rejected', + needsAttention: false, + }, }, - published: { - public: 'Published', - private: 'Published', + inQA: { + importance: 12, + author: { + label: 'Pending approval', + needsAttention: false, + }, + handlingEditor: { + label: 'QA', + needsAttention: false, + }, + editorInChief: { + label: 'QA', + needsAttention: false, + }, + reviewer: { + label: 'QA', + needsAttention: false, + }, + admin: { + label: 'Approve QA', + needsAttention: true, + }, + }, + accepted: { + importance: 13, + author: { + label: 'Accepted', + needsAttention: false, + }, + handlingEditor: { + label: 'Accepted', + needsAttention: false, + }, + editorInChief: { + label: 'Accepted', + needsAttention: false, + }, + reviewer: { + label: 'Accepted', + needsAttention: false, + }, + admin: { + label: 'Accepted', + needsAttention: false, + }, + }, + withdrawalRequested: { + importance: 14, + author: { + label: 'Withdrawal Requested', + needsAttention: false, + }, + handlingEditor: { + label: 'Withdrawal Requested', + needsAttention: false, + }, + editorInChief: { + label: 'Approve Withdrawal', + needsAttention: true, + }, + reviewer: { + label: 'Withdrawal Requested', + needsAttention: false, + }, + admin: { + label: 'Approve Withdrawal', + needsAttention: true, + }, + }, + withdrawn: { + importance: 15, + author: { + label: 'Withdrawn', + needsAttention: false, + }, + handlingEditor: { + label: 'Withdrawn', + needsAttention: false, + }, + editorInChief: { + label: 'Withdrawn', + needsAttention: false, + }, + reviewer: { + label: 'Withdrawn', + needsAttention: false, + }, + admin: { + label: 'Withdrawn', + needsAttention: false, + }, }, }, 'manuscript-types': { diff --git a/packages/xpub-faraday/config/test.js b/packages/xpub-faraday/config/test.js new file mode 100644 index 0000000000000000000000000000000000000000..9950c9b354fa8710a4f043a07ac2b86a3cf6bd2f --- /dev/null +++ b/packages/xpub-faraday/config/test.js @@ -0,0 +1,3 @@ +const defaultConfig = require('xpub-faraday/config/default') + +module.exports = defaultConfig diff --git a/packages/xpub-faraday/config/upload-validations.js b/packages/xpub-faraday/config/upload-validations.js index 21b2412ef38c1f8aea0cf3f3e98faf486a3b7107..79f682e7416efb192c78574e39b13600fd4d473d 100644 --- a/packages/xpub-faraday/config/upload-validations.js +++ b/packages/xpub-faraday/config/upload-validations.js @@ -16,5 +16,12 @@ module.exports = { 'application/msword', ]) .error(new Error('Only Word documents and PDFs are allowed')), + responseToReviewers: Joi.any() + .valid([ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/pdf', + 'application/msword', + ]) + .error(new Error('Only Word documents and PDFs are allowed')), review: Joi.any(), } diff --git a/packages/xpub-faraday/config/validations.js b/packages/xpub-faraday/config/validations.js index ec5a914c3268276d718abf8d87a78aeba7c7909d..fa6222be84d3181f4942373e7b06a7b6cb6f9b3d 100644 --- a/packages/xpub-faraday/config/validations.js +++ b/packages/xpub-faraday/config/validations.js @@ -7,16 +7,15 @@ module.exports = { created: Joi.date(), title: Joi.string(), status: Joi.string(), - reviewers: Joi.array(), + visibleStatus: Joi.string(), customId: Joi.string(), - authors: Joi.array(), invitations: Joi.array(), handlingEditor: Joi.object(), - visibleStatus: Joi.string().allow(''), }, fragment: [ { fragmentType: Joi.valid('version').required(), + collectionId: Joi.string().required(), created: Joi.date(), version: Joi.number(), submitted: Joi.date(), @@ -65,6 +64,16 @@ module.exports = { signedUrl: Joi.string(), }), ), + responseToReviewers: Joi.array().items( + Joi.object({ + id: Joi.string(), + name: Joi.string().required(), + type: Joi.string(), + size: Joi.number(), + url: Joi.string(), + signedUrl: Joi.string(), + }), + ), }), notes: Joi.object({ fundingAcknowledgement: Joi.string(), @@ -73,21 +82,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(), @@ -129,6 +125,8 @@ module.exports = { editorInChief: Joi.boolean(), handlingEditor: Joi.boolean(), invitationToken: Joi.string().allow(''), + confirmationToken: Joi.string().allow(''), + agreeTC: Joi.boolean(), }, team: { group: Joi.string(), diff --git a/packages/xpub-faraday/package.json b/packages/xpub-faraday/package.json index ff555499ca38cc5796e800ee9956bdc6679b190b..e4e829deab3a05be7c02ee7c4bd237ed68d18ca0 100644 --- a/packages/xpub-faraday/package.json +++ b/packages/xpub-faraday/package.json @@ -8,8 +8,9 @@ "url": "https://gitlab.coko.foundation/xpub/xpub-faraday" }, "dependencies": { - "@pubsweet/ui": "^3.2.0", - "@pubsweet/component-aws-s3": "^1.0.4", + "@pubsweet/ui": "4.1.3", + "@pubsweet/ui-toolkit": "latest", + "@pubsweet/component-aws-s3": "^1.1.2", "aws-sdk": "^2.197.0", "babel-core": "^6.26.0", "config": "^1.26.2", @@ -35,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", @@ -62,6 +63,7 @@ "file-loader": "^1.1.5", "html-webpack-plugin": "^2.24.0", "joi-browser": "^10.0.6", + "jest": "^22.1.1", "react-hot-loader": "^3.1.1", "string-replace-loader": "^1.3.0", "style-loader": "^0.19.0", @@ -70,13 +72,20 @@ "webpack-dev-middleware": "^1.12.0", "webpack-hot-middleware": "^2.20.0" }, + "jest": { + "verbose": true, + "testRegex": "/tests/.*.test.js$" + }, "scripts": { "setupdb": "pubsweet setupdb ./", "start": "pubsweet start", "start:services": "docker-compose up postgres", "server": "pubsweet server", - "start-now": "echo $secret > config/local-development.json && npm run server", + "start-now": + "echo $secret > config/local-development.json && npm run server", "build": "NODE_ENV=production pubsweet build", - "clean": "rm -rf node_modules" + "clean": "rm -rf node_modules", + "debug": "pgrep -f startup/start.js | xargs kill -sigusr1", + "test": "jest" } } diff --git a/packages/xpub-faraday/static/favicon.ico b/packages/xpub-faraday/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8a6bcd88d0d0461a41667716bc4f4390b4ec61a3 --- /dev/null +++ b/packages/xpub-faraday/static/favicon.ico @@ -0,0 +1 @@ +AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAgCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8CAAAAAAAAAAAAAAAAAAAAAP///wEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJR0DzCAYwSphGID6ZFmANZ+bhR9IqCHhCadf9QloH7aKqKAnTCdfyoAAAAAAAAAAAAAAAAAAAAAAAAAAHleCFxkSwL/WkQB/14+AMhuZxnqP6Fu/ySpif8fpYj/JKaG/yy1jv8ion/6LJx8YgAAAAAAAAAAAAAAAHdeCVFiSgH/TzsC21U6BTAMv8wUH6GG2iKniP8xoXj/V4Q9/VqDTnwgoIV8KJt//iuviv8rmXxvAAAAAJV7ER1cQwH1TTsC41hCFhcAAAAAKJt8wymtiP8gmoPnZH01jphnAP+QZADY/wAAASuegTUpo3/kK66J/y2ffUN0WgObW0QB/08+DD0AAAAALZ1/VCqqhv8onH/+Lp9/SAAAAACEZgSxm3cA/4hqBYgAAAAANJZ4IiahePUon37Qb1oC8E03As8AAAAAAAAAACmcfKYrsYv/KZt6swAAAAAAAAAAiWkHaph1AP+GZwLFAAAAAAAAAAAbZ1G5JZl4/4RlAvlmTQLCAAAAAAAAAAAqnHywLLSO/yudfYAAAAAAAAAAAIVnBqiXdAD/hmkEtgAAAAAAAAAAFVE+1Rl/XviHaQSxkG4B/4FoDD0AAAAAK6CAaSyyjP8pnXzLAAAAAI5oB0KLZAH9k3EA/4ppCF4AAAAAG1dEUhllTP8jgmWmjGwMKI5qAvmHbQH6i2oGVAAAAAAcooi7H6+T/0mNVqaNYwDllHAA/4lnAsMAAAAAHV5NKxZSP/EVZk32NKWHIgAAAACEaQlNjm0C/5NxAf+LZACdcHsqeFqDPfmHcQv/kmkA/4hlAOJtVw4jE1JFTRZWQe8bbFP/KIhsUgAAAAAAAAAAAAAAAIVpC0GIawPkm3cA/5VuAP+TZgD/k2oA/317HP8shWP8EGlZ6BtsUv8ccFf8KopuWgAAAAAAAAAAAAAAAAAAAAAAAAAAhmsaE41tB4CKawLFimsCxZFkAnc0mXJiI6WKvCqffNMpnHuZN7SPKQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//8AAPvfAADgBwAAwAMAAIABAAAIAAAAEIgAADGMAAAxjAAAEQgAAAgQAACAAQAAwAMAAOAHAAD//wAA//8AAA== \ No newline at end of file diff --git a/packages/xpub-faraday/tests/authsome-helpers.test.js b/packages/xpub-faraday/tests/authsome-helpers.test.js new file mode 100644 index 0000000000000000000000000000000000000000..2ccc6e04217a55b9626a460e091fd0bf6f155850 --- /dev/null +++ b/packages/xpub-faraday/tests/authsome-helpers.test.js @@ -0,0 +1,164 @@ +const { cloneDeep, get } = require('lodash') +const fixturesService = require('pubsweet-component-fixture-service') +const ah = require('../config/authsome-helpers') + +describe('Authsome Helpers', () => { + let testFixtures = {} + beforeEach(() => { + testFixtures = cloneDeep(fixturesService.fixtures) + }) + it('stripeCollection - should return collection', () => { + const { collection } = testFixtures.collections + const result = ah.stripeCollectionByRole(collection) + expect(result).toBeTruthy() + }) + it('stripeFragment - should return fragment', () => { + const { fragment } = testFixtures.fragments + const result = ah.stripeFragmentByRole({ fragment }) + expect(result).toBeTruthy() + }) + + it('stripeCollection - author should not see accepted HE name before recommendation made', () => { + const { collection } = testFixtures.collections + collection.status = 'underReview' + collection.handlingEditor = { + ...collection.handlingEditor, + isAccepted: true, + } + + const result = ah.stripeCollectionByRole(collection, 'author') + const { handlingEditor = {} } = result + + expect(handlingEditor.email).toBeFalsy() + expect(handlingEditor.name).toEqual('Assigned') + }) + + it('stripeCollection - author should not see Assigned until HE accepted ', () => { + const { collection } = testFixtures.collections + collection.status = 'underReview' + collection.handlingEditor = { + ...collection.handlingEditor, + isAccepted: false, + } + + const result = ah.stripeCollectionByRole(collection, 'author') + const { handlingEditor = {} } = result + + expect(handlingEditor).toBeFalsy() + expect(handlingEditor.name).not.toEqual('Assigned') + }) + + it('stripeCollection - author should see HE name after recommendation made', () => { + const { collection } = testFixtures.collections + collection.status = 'revisionRequested' + + const result = ah.stripeCollectionByRole(collection, 'author') + const { handlingEditor = {} } = result + + expect(handlingEditor.name).not.toEqual('Assigned') + }) + + it('stripeCollection - other user than author should see HE name before recommendation made', () => { + const { collection } = testFixtures.collections + collection.status = 'underReview' + + const result = ah.stripeCollectionByRole(collection, 'admin') + const { handlingEditor = {} } = result + + expect(handlingEditor.name).not.toEqual('Assigned') + }) + + it('stripeCollection - other user than author should see HE name after recommendation made', () => { + const { collection } = testFixtures.collections + collection.status = 'revisionRequested' + + const result = ah.stripeCollectionByRole(collection, 'admin') + const { handlingEditor = {} } = result + + expect(handlingEditor.name).not.toEqual('Assigned') + }) + + it('stripeCollection - returns if collection does not have HE', () => { + const { collection } = testFixtures.collections + delete collection.handlingEditor + + const result = ah.stripeCollectionByRole(collection, 'admin') + expect(result.handlingEditor).toBeFalsy() + }) + + it('stripeFragment - reviewer should not see authors email', () => { + const { fragment } = testFixtures.fragments + const result = ah.stripeFragmentByRole({ fragment, role: 'reviewer' }) + const { authors = [] } = result + expect(authors[0].email).toBeFalsy() + }) + it('stripeFragment - other roles than reviewer should see authors emails', () => { + const { fragment } = testFixtures.fragments + const result = ah.stripeFragmentByRole({ fragment, role: 'author' }) + const { authors = [] } = result + + expect(authors[0].email).toBeTruthy() + }) + + it('stripeFragment - reviewer should not see cover letter', () => { + const { fragment } = testFixtures.fragments + const result = ah.stripeFragmentByRole({ fragment, role: 'reviewer' }) + const { files = {} } = result + expect(files.coverLetter).toBeFalsy() + }) + it('stripeFragment - reviewer should not see others reviews', () => { + const { fragment } = testFixtures.fragments + const result = ah.stripeFragmentByRole({ fragment, role: 'reviewer' }) + const { recommendations } = result + expect(recommendations).toEqual([]) + }) + + it('stripeFragment - author should not see recommendations if a decision has not been made', () => { + const { fragment } = testFixtures.fragments + fragment.recommendations = [ + { + comments: [ + { + content: 'private', + public: false, + }, + { + content: 'public', + public: true, + }, + ], + }, + ] + const { recommendations } = ah.stripeFragmentByRole({ + fragment, + role: 'author', + status: 'underReview', + isLast: true, + }) + expect(recommendations).toHaveLength(0) + }) + it('stripeFragment - author should see reviews only if recommendation has been made and only public ones', () => { + const { fragment } = testFixtures.fragments + fragment.recommendations = [ + { + comments: [ + { + content: 'private', + public: false, + }, + { + content: 'public', + public: true, + }, + ], + }, + ] + const result = ah.stripeFragmentByRole({ + fragment, + role: 'author', + status: 'revisionRequested', + }) + const publicComments = get(result, 'recommendations[0].comments') + expect(publicComments).toHaveLength(1) + }) +}) diff --git a/yarn.lock b/yarn.lock index 50f540d304096e6d8380f9142adddc0dd83b7c4c..efc87c5ed07ef412dee8ed8bb153ba68180c55ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -85,9 +85,9 @@ lodash "^4.2.0" to-fast-properties "^2.0.0" -"@pubsweet/component-aws-s3@^1.0.4": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@pubsweet/component-aws-s3/-/component-aws-s3-1.1.0.tgz#115c4f801bef17a214488de6bf586fe3800b1c11" +"@pubsweet/component-aws-s3@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@pubsweet/component-aws-s3/-/component-aws-s3-1.1.2.tgz#ef7c6c7f22a19ce6f547412b73ab8de3fc81c3ee" dependencies: archiver "^2.1.1" aws-sdk "^2.185.0" @@ -164,6 +164,14 @@ typeface-fira-sans-condensed "^0.0.43" typeface-vollkorn "^0.0.43" +"@pubsweet/ui-toolkit@latest": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@pubsweet/ui-toolkit/-/ui-toolkit-1.0.0.tgz#df05b54e7bbfabcb10c7afc2991752e1087d2298" + dependencies: + color "^3.0.0" + lodash "^4.17.4" + styled-components "^3.2.5" + "@pubsweet/ui@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@pubsweet/ui/-/ui-3.0.0.tgz#b8915ce2b2729e66fd5628ecf7855f1d740270a5" @@ -186,6 +194,28 @@ redux-form "^7.0.3" styled-components "^2.4.0" +"@pubsweet/ui@4.1.3": + version "4.1.3" + resolved "https://registry.yarnpkg.com/@pubsweet/ui/-/ui-4.1.3.tgz#a8c65aa69618505a1e3777f4d18c3d676b800ee9" + dependencies: + babel-jest "^21.2.0" + classnames "^2.2.5" + enzyme "^3.2.0" + enzyme-adapter-react-16 "^1.1.1" + invariant "^2.2.3" + lodash "^4.17.4" + prop-types "^15.5.10" + react "^16.2.0" + react-dom "^16.2.0" + react-feather "^1.0.8" + react-redux "^5.0.2" + react-router-dom "^4.2.2" + react-tag-autocomplete "^5.5.0" + recompose "^0.26.0" + redux "^3.6.0" + redux-form "^7.0.3" + styled-components "^3.2.5" + "@pubsweet/ui@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@pubsweet/ui/-/ui-3.1.0.tgz#24c25c29fc36e34b9f654fe4378502232f8204fa" @@ -2145,7 +2175,7 @@ collapse-white-space@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.3.tgz#4b906f670e5a963a87b76b0e1689643341b6023c" -color-convert@^1.3.0, color-convert@^1.9.0: +color-convert@^1.3.0, color-convert@^1.9.0, color-convert@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed" dependencies: @@ -2161,6 +2191,13 @@ color-string@^0.3.0: dependencies: color-name "^1.0.0" +color-string@^1.5.2: + version "1.5.2" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.2.tgz#26e45814bc3c9a7cbd6751648a41434514a773a9" + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color@^0.11.0: version "0.11.4" resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764" @@ -2169,6 +2206,13 @@ color@^0.11.0: color-convert "^1.3.0" color-string "^0.3.0" +color@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a" + dependencies: + color-convert "^1.9.1" + color-string "^1.5.2" + colormin@^1.0.5: version "1.1.2" resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133" @@ -3281,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" @@ -4468,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" @@ -4746,6 +4794,10 @@ is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" +is-arrayish@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.1.tgz#c2dfc386abaa0c3e33c48db3fe87059e69065efd" + is-binary-path@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" @@ -8263,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" @@ -8793,6 +8858,12 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + dependencies: + is-arrayish "^0.3.1" + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"