Commit 70a3f54d authored by Yannis Barlas's avatar Yannis Barlas

Merge branch '120-decision-email' into 'master'

feat: send email to authors when decision is made

Closes #120

See merge request !113
parents a0188136 1ad059dc
Pipeline #4966 passed with stages
in 4 minutes and 15 seconds
{
"name": "pubsweet-component-xpub-review-backend",
"version": "0.1.0",
"main": "src",
"scripts": {
"test": "jest"
},
"keywords": [],
"author": "Collaborative Knowledge Foundation",
"license": "MIT",
"dependencies": {
"config": "^1.26.1",
"moment": "^2.18.1",
"nodemailer": "^4.0.1"
},
"devDependencies": {
"body-parser": "^1.17.2",
"jest": "^22.4.2",
"superagent": "^3.8.2",
"supertest": "^3.0.0"
},
"peerDependencies": {
"@pubsweet/logger": ">=0.0.1",
"pubsweet-component-xpub-review": "^0.0.1",
"pubsweet-server": ">=1.0.0-alpha.1"
}
}
module.exports = {
backend: () => require('./reviewBackend.js'),
}
const { pick } = require('lodash')
const config = require('config')
const logger = require('@pubsweet/logger')
const User = require('pubsweet-server/src/models/User')
const Fragment = require('pubsweet-server/src/models/Fragment')
const Collection = require('pubsweet-server/src/models/Collection')
const authsome = require('pubsweet-server/src/helpers/authsome')
const AuthorizationError = require('pubsweet-server/src/errors/AuthorizationError')
const transport = require('./transport')
module.exports = app => {
app.patch('/api/make-decision', async (req, res, next) => {
try {
const version = await Fragment.find(req.body.versionId)
const project = await Collection.find(req.body.projectId)
const authors = await Promise.all(version.owners.map(id => User.find(id)))
const canViewVersion = await authsome.can(req.user, 'GET', version)
const canPatchVersion = await authsome.can(req.user, 'PATCH', version)
if (!canPatchVersion || !canViewVersion) throw new AuthorizationError()
let versionUpdateData = { rev: version.rev, decision: req.body.decision }
if (canPatchVersion.filter) {
versionUpdateData = canPatchVersion.filter(versionUpdateData)
}
await version.updateProperties(versionUpdateData)
let nextVersionData
let projectUpdateData = { rev: project.rev }
let message
switch (version.decision.recommendation) {
case 'accept':
projectUpdateData.status = 'accepted'
message = '<p>Your manuscript has been accepted</p>'
break
case 'reject':
projectUpdateData.status = 'rejected'
message = '<p>Your manuscript has been rejected</p>'
break
case 'revise': {
projectUpdateData.status = 'revising'
message = '<p>Revisions to your manuscript have been requested</p>'
const cloned = pick(version, [
'source',
'metadata',
'declarations',
'suggestions',
'files',
'notes',
])
nextVersionData = {
fragmentType: 'version',
created: new Date(),
...cloned,
version: version.version + 1,
}
break
}
default:
throw new Error('Unknown decision type')
}
message += version.decision.note.content
let nextVersion
let canViewNextVersion
if (nextVersionData) {
const canCreateVersion = await authsome.can(
req.user,
'POST',
nextVersionData,
)
if (!canCreateVersion) throw new AuthorizationError()
if (canCreateVersion.filter) {
nextVersionData = canCreateVersion.filter(nextVersionData)
}
nextVersion = new Fragment(nextVersionData)
canViewNextVersion = await authsome.can(req.user, 'GET', nextVersion)
}
const canViewProject = await authsome.can(req.user, 'GET', project)
const canPatchProject = await authsome.can(req.user, 'PATCH', project)
if (!canPatchProject || !canViewProject) throw new AuthorizationError()
if (canPatchProject.filter) {
projectUpdateData = canPatchProject.filter(projectUpdateData)
}
await project.updateProperties(projectUpdateData)
await Promise.all([
version.save(),
project.save(),
nextVersion && nextVersion.save(),
])
const authorEmails = authors.map(user => user.email)
logger.info(`Sending decision email to ${authorEmails}`)
await transport.sendMail({
from: config.get('mailer.from'),
to: authorEmails,
subject: 'Decision made',
html: message,
})
res.send({
version: canViewVersion.filter
? canViewVersion.filter(version)
: version,
project: canViewProject.filter
? canViewProject.filter(project)
: project,
nextVersion:
canViewNextVersion && canViewNextVersion.filter
? canViewNextVersion.filter(nextVersion)
: nextVersion,
})
} catch (err) {
next(err)
}
})
}
process.env.SUPPRESS_NO_CONFIG_WARNING = true
process.env.NODE_CONFIG = '{"mailer":{"from":"sender@example.com"}}'
const express = require('express')
const supertest = require('supertest')
const bodyParser = require('body-parser')
// mocks
jest.mock('./transport', () => ({ sendMail: jest.fn() }))
jest.mock('pubsweet-server/src/models/User', () => ({
find: jest.fn(() => ({ email: 'author@example.org' })),
}))
jest.mock('pubsweet-server/src/models/Fragment', () => ({
find: jest.fn(() => ({
version: 1,
owners: [{}],
updateProperties(update) {
Object.assign(this, update)
},
save: () => {},
})),
}))
jest.mock('pubsweet-server/src/models/Collection', () => ({
find: jest.fn(() => ({
updateProperties: () => {},
save: () => {},
})),
}))
jest.mock('pubsweet-server/src/helpers/authsome', () => ({
can: jest.fn(() => true),
}))
const authsome = require('pubsweet-server/src/helpers/authsome')
const transport = require('./transport')
const component = require('./reviewBackend')
function makeApp() {
const app = express()
app.use(bodyParser.json())
component(app)
return supertest(app)
}
describe('/api/make-decision route', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('sends email on acceptance', async () => {
const app = makeApp()
const response = await app.patch('/api/make-decision').send({
decision: { recommendation: 'accept', note: { content: 'blah blah' } },
versionId: 1,
projectId: 2,
})
expect(response.body.version).toBeDefined()
expect(response.body.project).toBeDefined()
expect(response.body.nextVersion).not.toBeDefined()
expect(transport.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
from: 'sender@example.com',
to: ['author@example.org'],
subject: 'Decision made',
}),
)
})
it('rejects if not authorised', async () => {
authsome.can.mockReturnValue(false)
const app = makeApp()
const response = await app.patch('/api/make-decision').send({
decision: { recommendation: 'accept', note: { content: 'blah blah' } },
versionId: 1,
projectId: 2,
})
expect(response.status).toBe(403)
expect(transport.sendMail).not.toHaveBeenCalled()
})
})
const config = require('config')
const nodemailer = require('nodemailer')
// SMTP transport options: https://nodemailer.com/smtp/
const options = config.get('mailer.transport')
module.exports = nodemailer.createTransport(options)
import { debounce, pick } from 'lodash'
import { debounce } from 'lodash'
import { compose, withProps } from 'recompose'
import { connect } from 'react-redux'
import { withRouter } from 'react-router-dom'
......@@ -14,66 +14,6 @@ import {
import uploadFile from 'xpub-upload'
import DecisionLayout from './decision/DecisionLayout'
// TODO: this should happen on the server
const handleDecision = (project, version) => dispatch =>
dispatch(
actions.updateFragment(project, {
decision: version.decision,
id: version.id,
rev: version.rev,
}),
).then(() => {
const cloned = pick(version, [
'source',
'metadata',
'declarations',
'suggestions',
'files',
'notes',
])
switch (version.decision.recommendation) {
case 'accept':
return dispatch(
actions.updateCollection({
id: project.id,
rev: project.rev,
status: 'accepted',
}),
)
case 'reject':
return dispatch(
actions.updateCollection({
id: project.id,
rev: project.rev,
status: 'rejected',
}),
)
case 'revise':
return dispatch(
actions.updateCollection({
id: project.id,
rev: project.rev,
status: 'revising',
}),
).then(() =>
dispatch(
actions.createFragment(project, {
fragmentType: 'version',
created: new Date(), // TODO: set on server
...cloned,
version: version.version + 1,
}),
),
)
default:
throw new Error('Unknown decision type')
}
})
const onSubmit = (values, dispatch, { project, version, history }) => {
version.decision = {
...version.decision,
......@@ -82,7 +22,7 @@ const onSubmit = (values, dispatch, { project, version, history }) => {
submitted: new Date(),
}
return dispatch(handleDecision(project, version))
return dispatch(actions.makeDecision(project, version))
.then(() => {
// TODO: show "thanks for your review" message
history.push('/')
......
module.exports = {
frontend: {
components: [() => require('./components')],
actions: () => ({ makeDecision: require('./redux').makeDecision }),
reducers: {
makeDecision: () => require('./redux').default,
},
},
}
import * as api from 'pubsweet-client/src/helpers/api'
import {
GET_COLLECTION_SUCCESS,
GET_FRAGMENT_SUCCESS,
} from 'pubsweet-client/src/actions/types'
export const MAKE_DECISION_REQUEST = 'MAKE_DECISION_REQUEST'
export const MAKE_DECISION_SUCCESS = 'MAKE_DECISION_SUCCESS'
export const MAKE_DECISION_FAILURE = 'MAKE_DECISION_FAILURE'
function makeDecisionRequest(project, version) {
return {
type: MAKE_DECISION_REQUEST,
project,
version,
}
}
function makeDecisionSuccess(project, version, result) {
return {
type: MAKE_DECISION_SUCCESS,
project,
version,
result,
}
}
function makeDecisionFailure(project, version, error) {
return {
type: MAKE_DECISION_FAILURE,
project,
version,
error,
}
}
export function makeDecision(project, version) {
return dispatch => {
dispatch(makeDecisionRequest(project, version))
return api
.update('/make-decision', {
projectId: project.id,
versionId: version.id,
decision: version.decision,
})
.then(result => {
dispatch({
type: GET_COLLECTION_SUCCESS,
collection: result.project,
receivedAt: Date.now(),
})
dispatch({
type: GET_FRAGMENT_SUCCESS,
fragment: result.version,
receivedAt: Date.now(),
})
if (result.nextVersion) {
dispatch({
type: GET_FRAGMENT_SUCCESS,
fragment: result.nextVersion,
receivedAt: Date.now(),
})
}
dispatch(makeDecisionSuccess(project, version, result))
})
.catch(error => dispatch(makeDecisionFailure(project, version, error)))
}
}
const initialState = {}
export default (state = initialState, action) => {
switch (action.type) {
case MAKE_DECISION_REQUEST:
return {}
case MAKE_DECISION_SUCCESS:
return {}
case MAKE_DECISION_FAILURE:
return { error: action.error }
default:
return state
}
}
......@@ -3,6 +3,7 @@
"pubsweet-component-xpub-dashboard",
"pubsweet-component-xpub-manuscript",
"pubsweet-component-xpub-review",
"pubsweet-component-xpub-review-backend",
"pubsweet-component-xpub-submit",
"pubsweet-component-ink-backend",
"pubsweet-component-login",
......
......@@ -27,8 +27,11 @@ module.exports = {
'redux-log': false,
theme: process.env.PUBSWEET_THEME,
},
'mail-transport': {
sendmail: true,
mailer: {
from: 'dev@example.com',
transport: {
sendmail: true,
},
},
'password-reset': {
url:
......
......@@ -29,6 +29,7 @@
"pubsweet-component-xpub-find-reviewers": "^0.0.2",
"pubsweet-component-xpub-manuscript": "^0.0.2",
"pubsweet-component-xpub-review": "^0.0.2",
"pubsweet-component-xpub-review-backend": "^0.1.0",
"pubsweet-component-xpub-submit": "^0.0.2",
"pubsweet-server": "^1.0.1",
"react": "^16.2.0",
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment