diff --git a/.gitlab-ci.old.yml b/.gitlab-ci.old.yml new file mode 100644 index 0000000000000000000000000000000000000000..95eb8708f163e3986bff41a46aec700312bb3a45 --- /dev/null +++ b/.gitlab-ci.old.yml @@ -0,0 +1,149 @@ +image: docker:stable + +stages: + - build + - test + - deploy + - rollback + +build: + stage: build + script: + # Setup + - export AWS_REGION="eu-west-1" + - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID + - export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY + - export REPO=$CI_ECR_URL + - apk update + - apk --no-cache add --update curl python python-dev py-pip + - pip install awscli --upgrade --user + - export PATH=~/.local/bin:/usr/bin/:$PATH + # AUTH + - CERT=`aws ecr get-login --no-include-email --region ${AWS_REGION}` + - ${CERT} + # Build + - docker build -t ${CI_PROJECT_NAME}:$CI_COMMIT_SHA . + - docker tag $CI_PROJECT_NAME:$CI_COMMIT_SHA $REPO:latest + - docker push $REPO:latest + environment: + name: qa + url: $CI_ALB_URL + +lint: + image: $CI_ECR_URL:latest + stage: test + variables: + GIT_STRATEGY: none + script: + - cd ${HOME} + - npm run lint + +test: + image: $CI_ECR_URL:latest + stage: test + variables: + GIT_STRATEGY: none + script: + - cd ${HOME} + - npm run test + +create-rollback: + stage: test + only: + - master + script: + - export AWS_REGION="eu-west-1" + - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID + - export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY + - apk --no-cache add --update curl python python-dev py-pip jq + - pip install awscli --upgrade --user + - export PATH=~/.local/bin:/usr/bin/:$PATH + # Discover the ALB name + - ALB=`aws elbv2 describe-load-balancers --region ${AWS_REGION} --names ${CI_ALB_NAME} | jq .LoadBalancers[0].DNSName` + # Test Keepalive + - /usr/bin/curl --fail http://${ALB//'"'}/keepalive + # IF Keepalive return 200... + # Retrieve & Store this revision as 'last known successful revision' in S3 Bucket + - REV=`aws ecs describe-services --region ${AWS_REGION} --cluster ${CI_CLUSTER_NAME} --service ${CI_SERVICE_NAME} |jq -r '.services[0].deployments[0].taskDefinition'` + - echo successful revision is ${REV} Storing it in S3 Bucket + - echo ${REV} > /${CI_SERVICE_NAME} + # sync rev to S3 here + - aws s3 cp /${CI_SERVICE_NAME} s3://${CI_REV_BUCKET} + environment: + name: qa + url: $CI_ALB_URL + +deploy: + stage: deploy + only: + - master + script: + - export AWS_REGION="eu-west-1" + - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID + - export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY + - apk --no-cache add --update python python-dev py-pip + - pip install ecs-deploy + # Deploy + - ecs deploy ${CI_CLUSTER_NAME} ${CI_SERVICE_NAME} --region ${AWS_REGION} --timeout 600 -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST + environment: + name: qa + url: $CI_ALB_URL + +aws-qa: + stage: deploy + when: manual + only: + - develop + script: + - export AWS_REGION="eu-west-1" + - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID + - export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY + - apk --no-cache add --update python python-dev py-pip + - pip install ecs-deploy + # Deploy + - ecs deploy ${CI_CLUSTER_NAME} ${CI_SERVICE_NAME} --region ${AWS_REGION} --timeout 600 -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST + environment: + name: qa + url: $CI_ALB_URL + +rollback: + stage: rollback + when: on_failure + only: + - master + script: + - export AWS_REGION="us-east-1" + - export AWS_ACCESS_KEY_ID=$aws_access_key_id + - export AWS_SECRET_ACCESS_KEY=$aws_secret_access_key + - apk --no-cache add --update curl python python-dev py-pip + - pip install awscli --upgrade --user + - export PATH=~/.local/bin:/usr/bin/:$PATH + - pip install ecs-deploy + - aws s3 cp s3://${CI_REV_BUCKET}/${CI_SERVICE_NAME} ./ + - REV=`cat ./${CI_SERVICE_NAME}` + - echo rev is $REV + - ecs deploy --region ${AWS_REGION} ${CLUSTER_NAME} ${CI_SERVICE_NAME} --task ${REV} -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST + environment: + name: qa + url: $CI_ALB_URL + +rollback-qa: + stage: rollback + when: manual + only: + - develop + script: + - export AWS_REGION="us-east-1" + - export AWS_ACCESS_KEY_ID=$aws_access_key_id + - export AWS_SECRET_ACCESS_KEY=$aws_secret_access_key + - apk --no-cache add --update curl python python-dev py-pip + - pip install awscli --upgrade --user + - export PATH=~/.local/bin:/usr/bin/:$PATH + - pip install ecs-deploy + - aws s3 cp s3://${CI_REV_BUCKET}/${CI_SERVICE_NAME} ./ + - REV=`cat ./${CI_SERVICE_NAME}` + - echo rev is $REV + - ecs deploy --region ${AWS_REGION} ${CLUSTER_NAME} ${CI_SERVICE_NAME} --task ${REV} -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST + environment: + name: qa + url: $CI_ALB_URL \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 95eb8708f163e3986bff41a46aec700312bb3a45..9d76f567afded10a195ae13549b3ebb089ab9b9c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,19 +1,22 @@ -image: docker:stable +variables: + CONFIGURATION_REPOSITORY: https://gitlab.coko.foundation/bogdan/xpub-faraday-deployment-config.git + DOCKER_REPO: $CI_ECR_URL stages: - build - test + - push - deploy - rollback build: + image: docker:latest stage: build script: # Setup - export AWS_REGION="eu-west-1" - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID - export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY - - export REPO=$CI_ECR_URL - apk update - apk --no-cache add --update curl python python-dev py-pip - pip install awscli --upgrade --user @@ -23,14 +26,11 @@ build: - ${CERT} # Build - docker build -t ${CI_PROJECT_NAME}:$CI_COMMIT_SHA . - - docker tag $CI_PROJECT_NAME:$CI_COMMIT_SHA $REPO:latest - - docker push $REPO:latest - environment: - name: qa - url: $CI_ALB_URL + - docker tag $CI_PROJECT_NAME:$CI_COMMIT_SHA $DOCKER_REPO:$CI_COMMIT_SHA + - docker push $DOCKER_REPO:$CI_COMMIT_SHA lint: - image: $CI_ECR_URL:latest + image: $DOCKER_REPO:$CI_COMMIT_SHA stage: test variables: GIT_STRATEGY: none @@ -39,7 +39,7 @@ lint: - npm run lint test: - image: $CI_ECR_URL:latest + image: $DOCKER_REPO:$CI_COMMIT_SHA stage: test variables: GIT_STRATEGY: none @@ -47,49 +47,49 @@ test: - cd ${HOME} - npm run test -create-rollback: - stage: test +push:qa: + image: docker:latest + stage: push only: - - master + - develop script: + # Setup - export AWS_REGION="eu-west-1" - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID - export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY - - apk --no-cache add --update curl python python-dev py-pip jq + - apk update + - apk --no-cache add --update curl python python-dev py-pip - pip install awscli --upgrade --user - export PATH=~/.local/bin:/usr/bin/:$PATH - # Discover the ALB name - - ALB=`aws elbv2 describe-load-balancers --region ${AWS_REGION} --names ${CI_ALB_NAME} | jq .LoadBalancers[0].DNSName` - # Test Keepalive - - /usr/bin/curl --fail http://${ALB//'"'}/keepalive - # IF Keepalive return 200... - # Retrieve & Store this revision as 'last known successful revision' in S3 Bucket - - REV=`aws ecs describe-services --region ${AWS_REGION} --cluster ${CI_CLUSTER_NAME} --service ${CI_SERVICE_NAME} |jq -r '.services[0].deployments[0].taskDefinition'` - - echo successful revision is ${REV} Storing it in S3 Bucket - - echo ${REV} > /${CI_SERVICE_NAME} - # sync rev to S3 here - - aws s3 cp /${CI_SERVICE_NAME} s3://${CI_REV_BUCKET} - environment: - name: qa - url: $CI_ALB_URL + # AUTH + - CERT=`aws ecr get-login --no-include-email --region ${AWS_REGION}` + - ${CERT} + # Tag + - docker tag $DOCKER_REPO:$CI_COMMIT_SHA $DOCKER_REPO:latest + - docker push $DOCKER_REPO:latest -deploy: - stage: deploy +push:staging: + image: docker:latest + stage: push only: - master script: + # Setup - export AWS_REGION="eu-west-1" - export AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID - export AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY - - apk --no-cache add --update python python-dev py-pip - - pip install ecs-deploy - # Deploy - - ecs deploy ${CI_CLUSTER_NAME} ${CI_SERVICE_NAME} --region ${AWS_REGION} --timeout 600 -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST - environment: - name: qa - url: $CI_ALB_URL + - apk update + - apk --no-cache add --update curl python python-dev py-pip + - pip install awscli --upgrade --user + - export PATH=~/.local/bin:/usr/bin/:$PATH + # AUTH + - CERT=`aws ecr get-login --no-include-email --region ${AWS_REGION}` + - ${CERT} + # Tag + - docker tag $DOCKER_REPO:$CI_COMMIT_SHA $DOCKER_REPO:staging + - docker push $DOCKER_REPO:staging -aws-qa: +deploy:aws-qa: stage: deploy when: manual only: @@ -101,49 +101,39 @@ aws-qa: - apk --no-cache add --update python python-dev py-pip - pip install ecs-deploy # Deploy - - ecs deploy ${CI_CLUSTER_NAME} ${CI_SERVICE_NAME} --region ${AWS_REGION} --timeout 600 -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST + - ecs deploy ${CI_CLUSTER_NAME} ${CI_SERVICE_NAME} --region ${AWS_REGION} --timeout 1200 -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST environment: name: qa url: $CI_ALB_URL -rollback: - stage: rollback - when: on_failure +deploy:qa: + image: pubsweet/deployer:latest + stage: deploy + when: manual only: - - master - script: - - export AWS_REGION="us-east-1" - - export AWS_ACCESS_KEY_ID=$aws_access_key_id - - export AWS_SECRET_ACCESS_KEY=$aws_secret_access_key - - apk --no-cache add --update curl python python-dev py-pip - - pip install awscli --upgrade --user - - export PATH=~/.local/bin:/usr/bin/:$PATH - - pip install ecs-deploy - - aws s3 cp s3://${CI_REV_BUCKET}/${CI_SERVICE_NAME} ./ - - REV=`cat ./${CI_SERVICE_NAME}` - - echo rev is $REV - - ecs deploy --region ${AWS_REGION} ${CLUSTER_NAME} ${CI_SERVICE_NAME} --task ${REV} -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST + - develop + variables: + PACKAGE_NAME: xpub-faraday + IMAGE_TAG: latest environment: name: qa - url: $CI_ALB_URL + url: http://qa.faraday.hindawi.com + script: + - source deploy.sh + - create_deployment -rollback-qa: - stage: rollback +deploy:staging: + image: pubsweet/deployer:latest + stage: deploy when: manual only: - - develop - script: - - export AWS_REGION="us-east-1" - - export AWS_ACCESS_KEY_ID=$aws_access_key_id - - export AWS_SECRET_ACCESS_KEY=$aws_secret_access_key - - apk --no-cache add --update curl python python-dev py-pip - - pip install awscli --upgrade --user - - export PATH=~/.local/bin:/usr/bin/:$PATH - - pip install ecs-deploy - - aws s3 cp s3://${CI_REV_BUCKET}/${CI_SERVICE_NAME} ./ - - REV=`cat ./${CI_SERVICE_NAME}` - - echo rev is $REV - - ecs deploy --region ${AWS_REGION} ${CLUSTER_NAME} ${CI_SERVICE_NAME} --task ${REV} -e ${CI_CONTAINER_NAME} AWS_S3_ACCESS_KEY $AWS_S3_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_S3_SECRET_KEY $AWS_S3_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_S3_REGION $AWS_S3_REGION -e ${CI_CONTAINER_NAME} AWS_S3_BUCKET $AWS_S3_BUCKET -e ${CI_CONTAINER_NAME} AWS_SES_SECRET_KEY $AWS_SES_SECRET_KEY -e ${CI_CONTAINER_NAME} AWS_SES_ACCESS_KEY $AWS_SES_ACCESS_KEY -e ${CI_CONTAINER_NAME} AWS_SES_REGION $AWS_SES_REGION -e ${CI_CONTAINER_NAME} EMAIL_SENDER $EMAIL_SENDER -e ${CI_CONTAINER_NAME} secret $SECRET -e ${CI_CONTAINER_NAME} DATABASE $DATABASE -e ${CI_CONTAINER_NAME} DB_USER $DB_USER -e ${CI_CONTAINER_NAME} DB_PASS $DB_PASS -e ${CI_CONTAINER_NAME} DB_HOST $DB_HOST -e ${CI_CONTAINER_NAME} ORCID_CLIENT_ID $ORCID_CLIENT_ID -e ${CI_CONTAINER_NAME} ORCID_CLIENT_SECRET $ORCID_CLIENT_SECRET -e ${CI_CONTAINER_NAME} CLIENT_BASE_URL $CLIENT_BASE_URL -e ${CI_CONTAINER_NAME} FTP_USERNAME $FTP_USERNAME -e ${CI_CONTAINER_NAME} FTP_PASSWORD $FTP_PASSWORD -e ${CI_CONTAINER_NAME} FTP_HOST $FTP_HOST + - master + variables: + PACKAGE_NAME: xpub-faraday + IMAGE_TAG: staging environment: - name: qa - url: $CI_ALB_URL \ No newline at end of file + name: staging + url: http://faraday.hindawi.com + script: + - source deploy.sh + - create_deployment diff --git a/Dockerfile b/Dockerfile index af297f67bc3ab2c6bfea7267b9eecc736b0e93b9..d00693285f68ab5f0c4f5e4d97d59e0d9fd7ae09 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,22 +5,18 @@ WORKDIR ${HOME} COPY package.json yarn.lock ./ COPY lerna.json .babelrc .eslintignore .eslintrc .prettierrc .stylelintignore .stylelintrc ./ COPY packages packages -COPY now now RUN [ "yarn", "config", "set", "workspaces-experimental", "true" ] -# We do a development install because react-styleguidist is a dev dependency RUN [ "yarn", "install", "--frozen-lockfile" ] - -# Remove cache and offline mirror RUN [ "yarn", "cache", "clean"] RUN [ "rm", "-rf", "/npm-packages-offline-cache"] - -ENV NODE_ENV "development" +ENV NODE_ENV "production" WORKDIR ${HOME}/packages/xpub-faraday -# RUN [ "npm", "run", "server "] + +RUN [ "npx", "pubsweet", "build"] EXPOSE 3000 -CMD [ "npm", "run", "start-now" ] \ No newline at end of file +CMD [] \ No newline at end of file diff --git a/Dockerfile-development b/Dockerfile-development new file mode 100644 index 0000000000000000000000000000000000000000..abc8753aa68cd5dfdc765fdd66fa7f0bc28b31bf --- /dev/null +++ b/Dockerfile-development @@ -0,0 +1,20 @@ +FROM xpub/xpub:base + +WORKDIR ${HOME} + +COPY package.json yarn.lock ./ +COPY lerna.json .babelrc .eslintignore .eslintrc .prettierrc .stylelintignore .stylelintrc ./ +COPY packages packages + +RUN [ "yarn", "config", "set", "workspaces-experimental", "true" ] + +RUN [ "yarn", "install", "--frozen-lockfile" ] +RUN [ "yarn", "cache", "clean"] +RUN [ "rm", "-rf", "/npm-packages-offline-cache"] + +ENV NODE_ENV "development" +WORKDIR ${HOME}/packages/xpub-faraday + +EXPOSE 3000 + +CMD [ "npm", "run", "start-now" ] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 34e800990414df3e1fd12e239c51d7bdeec5c072..b4aa1dc1be2196cb8b1290417b8af9e915fac345 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: app: build: context: . - dockerfile: ./Dockerfile + dockerfile: ./Dockerfile-development command: sh -c "cd packages/xpub-faraday && yarn install --frozen-lockfile && ./scripts/wait-for-it.sh postgres:5432 -s -t 40 -- npx pubsweet server" ports: - ${PORT:-3000}:3000 diff --git a/packages/component-email/src/routes/emails/notifications.js b/packages/component-email/src/routes/emails/notifications.js index 23fea64c842350cd283d72b55f58c1b86a920d95..e5783fd5e02ea2586a854a7836fb2096495c2fb0 100644 --- a/packages/component-email/src/routes/emails/notifications.js +++ b/packages/component-email/src/routes/emails/notifications.js @@ -11,7 +11,7 @@ const { getEmailCopy } = require('./emailCopy') module.exports = { async sendNotifications({ user, baseUrl, role, UserModel }) { const userHelper = new User({ UserModel }) - const { firstName, lastName } = await userHelper.getEditorInChief() + const { firstName, lastName } = await userHelper.getEditorsInChief() const eicName = `${firstName} ${lastName}` const email = new Email({ diff --git a/packages/component-faraday-selectors/src/index.js b/packages/component-faraday-selectors/src/index.js index 65af2dd10ea67925098d318bc9f5d7ff13223d28..f1d9bcb1960a84d6656d68c25bf8616b2b4c02a8 100644 --- a/packages/component-faraday-selectors/src/index.js +++ b/packages/component-faraday-selectors/src/index.js @@ -7,10 +7,16 @@ export const isHEToManuscript = (state, collectionId) => { return get(collection, 'handlingEditor.id') === currentUserId } +const canMakeRecommendationStatuses = [ + 'reviewCompleted', + 'heAssigned', + 'underReview', +] export const canMakeRecommendation = (state, collection, fragment = {}) => { if (fragment.id !== last(collection.fragments)) return false const isHE = isHEToManuscript(state, collection.id) - return isHE && get(collection, 'status') === 'reviewCompleted' + const status = get(collection, 'status') + return isHE && canMakeRecommendationStatuses.includes(status) } export const currentUserIs = ({ currentUser: { user } }, role) => { @@ -22,6 +28,10 @@ export const currentUserIs = ({ currentUser: { user } }, role) => { return isHe case 'staff': return isAdmin || isEic || isHe + case 'isEiC': + return isEic + case 'isAdmin': + return isAdmin case 'adminEiC': return isAdmin || isEic default: @@ -60,15 +70,31 @@ export const getHERecommendation = (state, collectionId, fragmentId) => { ) } -const cantMakeDecisionStatuses = ['rejected', 'published', 'draft'] +const canMakeDecisionStatuses = ['submitted', 'pendingApproval'] export const canMakeDecision = (state, collection, fragment = {}) => { if (fragment.id !== last(collection.fragments)) return false const status = get(collection, 'status') - if (!status || cantMakeDecisionStatuses.includes(status)) return false - const isEIC = currentUserIs(state, 'adminEiC') - return isEIC && status + return isEIC && canMakeDecisionStatuses.includes(status) +} + +const canEditManuscriptStatuses = ['draft', 'technicalChecks', 'inQA'] +export const canEditManuscript = (state, collection, fragment = {}) => { + const isAdmin = currentUserIs(state, 'isAdmin') + if (!isAdmin || fragment.id !== last(collection.fragments)) return false + const status = get(collection, 'status') + + return canEditManuscriptStatuses.includes(status) +} + +const canOverrideTechnicalChecksStatuses = ['technicalChecks', 'inQA'] +export const canOverrideTechnicalChecks = (state, collection) => { + const isAdmin = currentUserIs(state, 'isAdmin') + if (!isAdmin) return false + const status = get(collection, 'status') + + return canOverrideTechnicalChecksStatuses.includes(status) } export const canSeeReviewersReports = (state, collectionId) => { diff --git a/packages/component-fixture-manager/src/fixtures/collections.js b/packages/component-fixture-manager/src/fixtures/collections.js index c6d401815491578a0262f7cb8cb06b2e3ed66c56..84583485feff68c50c532e4a5f4a396147177df2 100644 --- a/packages/component-fixture-manager/src/fixtures/collections.js +++ b/packages/component-fixture-manager/src/fixtures/collections.js @@ -11,7 +11,7 @@ const collections = { type: 'collection', fragments: [fragment.id], owners: [user.id], - save: jest.fn(), + save: jest.fn(() => collections.collection), invitations: [ { id: chance.guid(), @@ -44,6 +44,7 @@ const collections = { technicalChecks: { token: chance.guid(), }, + status: 'pendingApproval', }, } diff --git a/packages/component-helper-service/src/services/User.js b/packages/component-helper-service/src/services/User.js index 074414d59f43dcf979d912244249fce897b7d7b7..09c722d31cabe70492fb2c0fcdae381ee4c0c1ef 100644 --- a/packages/component-helper-service/src/services/User.js +++ b/packages/component-helper-service/src/services/User.js @@ -41,12 +41,16 @@ class User { return newUser } - async getEditorInChief() { + async getEditorsInChief() { const { UserModel } = this const users = await UserModel.all() - const eic = users.find(user => user.editorInChief || user.admin) - return eic + const eics = users.filter(user => user.editorInChief) + if (eics.length === 0) { + throw new Error('No Editor in Chief has been found') + } + + return eics } async updateUserTeams({ userId, teamId }) { @@ -65,6 +69,13 @@ class User { return authors.filter(author => activeUsers.includes(author.id)) } + + async getEiCName() { + const editorsInChief = await this.getEditorsInChief() + const firstName = get(editorsInChief, '0.firstName', 'Editor') + const lastName = get(editorsInChief, '0.lastName', 'in Chief') + return `${firstName} ${lastName}` + } } module.exports = User diff --git a/packages/component-helper-service/src/services/email/Email.js b/packages/component-helper-service/src/services/email/Email.js index 8d5b688b6993b3ebb9df7ab25630b926aa7762c6..53436b212dd89df765028f90fc779d4f2e0045bf 100644 --- a/packages/component-helper-service/src/services/email/Email.js +++ b/packages/component-helper-service/src/services/email/Email.js @@ -1,6 +1,7 @@ const config = require('config') const helpers = require('./helpers') const SendEmail = require('@pubsweet/component-send-email') +const logger = require('@pubsweet/logger') class Email { constructor({ @@ -64,14 +65,20 @@ class Email { } sendEmail({ text, html }) { + const fromEmail = config.get('mailer.from') const mailData = { - from: config.get('mailer.from'), + from: fromEmail, to: this.toUser.email, subject: this.content.subject, text, html, } + logger.info( + `EMAIL: Sent email from ${fromEmail} to ${ + this.toUser.email + } with subject '${this.content.subject}'`, + ) SendEmail.send(mailData) } } diff --git a/packages/component-invite/src/routes/collectionsInvitations/emails/notifications.js b/packages/component-invite/src/routes/collectionsInvitations/emails/notifications.js index 44c00f6856cbb40b92f735a8744df41ce73532b7..90df1a7ce364a2445dd79238dd9f4837392be5c8 100644 --- a/packages/component-invite/src/routes/collectionsInvitations/emails/notifications.js +++ b/packages/component-invite/src/routes/collectionsInvitations/emails/notifications.js @@ -36,7 +36,8 @@ module.exports = { } ${submittingAuthor.lastName}` const userHelper = new User({ UserModel }) - const eic = await userHelper.getEditorInChief() + const eics = await userHelper.getEditorsInChief() + const eic = eics[0] const eicName = `${eic.firstName} ${eic.lastName}` const subjectBaseText = `${collection.customId}: Manuscript ` diff --git a/packages/component-invite/src/routes/fragmentsInvitations/emails/invitations.js b/packages/component-invite/src/routes/fragmentsInvitations/emails/invitations.js index 9dc87ef106e786fc431f3be81acdd054d62313cf..b66445efe0a40c6470d9e04f3485710b5e8432ac 100644 --- a/packages/component-invite/src/routes/fragmentsInvitations/emails/invitations.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/emails/invitations.js @@ -91,7 +91,7 @@ module.exports = { unsubscribeLink: services.createUrl(baseUrl, unsubscribeSlug, { id: invitedUser.id, }), - authorsList, + authorsList: authorsList.join(', '), }, }) diff --git a/packages/component-invite/src/routes/fragmentsInvitations/emails/notifications.js b/packages/component-invite/src/routes/fragmentsInvitations/emails/notifications.js index 12cad6a9d5283e486784a5a08c037b1b4c23d669..9eeb709b629ee52e0ab63624c4a25019b1dc1a95 100644 --- a/packages/component-invite/src/routes/fragmentsInvitations/emails/notifications.js +++ b/packages/component-invite/src/routes/fragmentsInvitations/emails/notifications.js @@ -37,8 +37,7 @@ module.exports = { const handlingEditor = get(collection, 'handlingEditor') const userHelper = new User({ UserModel }) - const { firstName, lastName } = await userHelper.getEditorInChief() - const eicName = `${firstName} ${lastName}` + const eicName = await userHelper.getEiCName() const subjectBaseText = isCanceled ? `${collection.customId}: Reviewer ` : `${collection.customId}: Manuscript ` diff --git a/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js b/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js index 012113ffdbbd6df5542523b231860bdacaab7032..ee0f6f2918511fe268a35a1b9bca28c6e8d9e4c9 100644 --- a/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js +++ b/packages/component-manuscript-manager/src/routes/fragments/notifications/notifications.js @@ -49,9 +49,7 @@ module.exports = { }) const userHelper = new User({ UserModel }) - const eic = await userHelper.getEditorInChief() - const eicName = `${eic.firstName} ${eic.lastName}` - + const eicName = await userHelper.getEiCName() if (isNewVersion) { sendHandlingEditorEmail({ email, @@ -76,8 +74,8 @@ module.exports = { if (isTechnicalCheck) { sendEQSEmail({ - eic, email, + eicName, baseUrl, collection, subjectBaseText, @@ -157,7 +155,13 @@ const sendReviewersEmail = async ({ }) } -const sendEQSEmail = ({ eic, email, baseUrl, collection, subjectBaseText }) => { +const sendEQSEmail = ({ + email, + eicName, + baseUrl, + collection, + subjectBaseText, +}) => { const emailType = 'eqs-manuscript-submitted' email.toUser = { @@ -166,7 +170,7 @@ const sendEQSEmail = ({ eic, email, baseUrl, collection, subjectBaseText }) => { } email.content.unsubscribeLink = baseUrl - email.content.signatureName = `${eic.firstName} ${eic.lastName}` + email.content.signatureName = eicName email.content.subject = `${subjectBaseText} Submitted` email.content.ctaLink = services.createUrl( baseUrl, diff --git a/packages/component-manuscript-manager/src/routes/fragments/post.js b/packages/component-manuscript-manager/src/routes/fragments/post.js index 6150932b318d711b2eed23d7db81b6588e2a0799..2ec4ab94af0427c9014609b1927f0db01e40a498 100644 --- a/packages/component-manuscript-manager/src/routes/fragments/post.js +++ b/packages/component-manuscript-manager/src/routes/fragments/post.js @@ -52,7 +52,7 @@ module.exports = models => async (req, res) => { }, } - await MTS.sendPackage(packageFragment) + await MTS.sendPackage({ fragment: packageFragment }) notifications.sendNotifications({ fragment, diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/emailCopy.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/emailCopy.js index d4046f5a95f38e0c56bca849133118d98ccbd421..5b3840f79d5c78f1d6abedfb513c89b086f06737 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/emailCopy.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/emailCopy.js @@ -1,4 +1,5 @@ const getEmailCopy = ({ + customId, emailType, titleText, comments = '', @@ -19,7 +20,6 @@ const getEmailCopy = ({ break case 'author-manuscript-published': paragraph = `I am delighted to inform you that ${titleText} has passed through the review process and will be published in Hindawi.<br/><br/> - ${comments}<br/><br/> Thanks again for choosing to publish with us.` hasLink = false break @@ -77,6 +77,9 @@ const getEmailCopy = ({ paragraph = `In order for ${titleText} to proceed to publication, there needs to be a revision. <br/><br/> For more information about what is required, please visit the manuscript details page.` break + case 'eqa-manuscript-request-for-approval': + paragraph = `Manuscript ID ${customId} has passed peer-review and is now ready for EQA. Please click on the link below to either approve or return the manuscript to the Editor in Chief:` + break default: throw new Error(`The ${emailType} email type is not defined.`) } diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/notifications.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/notifications.js index f99bbd3b5c8ef78a1ed89ff6453df8945a1cc26f..c86bbd35a818229fbc82d1992ee27cbe9e91b00e 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/notifications.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/notifications/notifications.js @@ -1,5 +1,5 @@ const config = require('config') -const { chain, get } = require('lodash') +const { chain, get, isEmpty } = require('lodash') const { User, @@ -10,10 +10,12 @@ const { const { getEmailCopy } = require('./emailCopy') +const editorialAssistantEmail = config.get('mailer.editorialAssistant') const unsubscribeSlug = config.get('unsubscribe.url') module.exports = { async sendNotifications({ + hasEQA, baseUrl, fragment, UserModel, @@ -33,12 +35,15 @@ module.exports = { fragmentAuthors.submittingAuthor.firstName } ${fragmentAuthors.submittingAuthor.lastName}` + const userHelper = new User({ UserModel }) + const eicName = await userHelper.getEiCName() + const email = new Email({ type: 'user', content: { unsubscribeLink: baseUrl, ctaText: 'MANUSCRIPT DETAILS', - signatureName: get(collection, 'handlingEditor.name', 'N/A'), + signatureName: eicName, ctaLink: services.createUrl( baseUrl, `/projects/${collection.id}/versions/${fragment.id}/details`, @@ -46,77 +51,94 @@ module.exports = { }, }) - const userHelper = new User({ UserModel }) - const { - email: eicEmail, - firstName, - lastName, - } = await userHelper.getEditorInChief() - const eicName = `${firstName} ${lastName}` - - let comments - if (isEditorInChief) { - const eicComments = chain(newRecommendation) - .get('comments') - .find(comm => !comm.public) - .get('content') - .value() - - comments = eicComments - } - - if (isEditorInChief || newRecommendation.recommendationType === 'review') { - // the request came from either the Editor in Chief or a reviewer, so the HE needs to be notified - sendHandlingEditorEmail({ - email, - eicName, - baseUrl, - comments, - titleText, - targetUserName, - subjectBaseText, - handlingEditor: get(collection, 'handlingEditor', {}), - recommendation: newRecommendation.recommendation, - recommendationType: newRecommendation.recommendationType, - }) - } - if ( - newRecommendation.recommendationType !== 'review' && - newRecommendation.recommendation !== 'return-to-handling-editor' + !hasEQA && + isEditorInChief && + newRecommendation.recommendation === 'publish' ) { - sendAuthorsEmail({ + sendEQAEmail({ email, + eicName, baseUrl, - titleText, - parsedFragment, - fragmentAuthors, - isEditorInChief, + collection, subjectBaseText, - newRecommendation, }) + } else { + let comments + if (isEditorInChief) { + const eicComments = chain(newRecommendation) + .get('comments') + .find(comm => !comm.public) + .get('content') + .value() - sendReviewersEmail({ - email, - baseUrl, - UserModel, - titleText, - fragmentHelper, - isEditorInChief, - subjectBaseText, - recommendation: newRecommendation.recommendation, - handlingEditorName: get(collection, 'handlingEditor.name', 'N/A'), - }) + comments = eicComments + } - sendEiCEmail({ - email, - baseUrl, - eicName, - eicEmail, - titleText, - subjectBaseText, - recommendation: newRecommendation, - }) + const hasPeerReview = (collection = {}) => + !isEmpty(collection.handlingEditor) + + if ( + (isEditorInChief || + newRecommendation.recommendationType === 'review') && + hasPeerReview(collection) + ) { + // the request came from either the Editor in Chief or a reviewer, so the HE needs to be notified + sendHandlingEditorEmail({ + email, + eicName: await userHelper.getEiCName(), + baseUrl, + comments, + titleText, + targetUserName, + subjectBaseText, + handlingEditor: get(collection, 'handlingEditor', {}), + recommendation: newRecommendation.recommendation, + recommendationType: newRecommendation.recommendationType, + }) + } + + if ( + newRecommendation.recommendationType !== 'review' && + newRecommendation.recommendation !== 'return-to-handling-editor' + ) { + if (isEditorInChief || collection.status === 'revisionRequested') { + sendAuthorsEmail({ + email, + baseUrl, + titleText, + parsedFragment, + fragmentAuthors, + isEditorInChief, + subjectBaseText, + newRecommendation, + handlingEditorName: get(collection, 'handlingEditor.name', eicName), + }) + } + + if (hasPeerReview(collection)) { + sendReviewersEmail({ + email, + baseUrl, + UserModel, + titleText, + fragmentHelper, + isEditorInChief, + subjectBaseText, + recommendation: newRecommendation.recommendation, + handlingEditorName: get(collection, 'handlingEditor.name', eicName), + }) + + sendEiCsEmail({ + email, + baseUrl, + userHelper, + titleText, + subjectBaseText, + recommendation: newRecommendation, + }) + } + } } }, } @@ -161,7 +183,9 @@ const sendHandlingEditorEmail = ({ email.content.unsubscribeLink = services.createUrl(baseUrl, unsubscribeSlug, { id: handlingEditor.id, }) + email.content.signatureName = eicName + const { html, text } = email.getBody({ body: getEmailCopy({ emailType, @@ -181,6 +205,7 @@ const sendAuthorsEmail = async ({ subjectBaseText, fragmentAuthors, newRecommendation, + handlingEditorName, parsedFragment: { heRecommendation }, }) => { let emailType, authors, comments @@ -201,7 +226,7 @@ const sendAuthorsEmail = async ({ email.content.subject = `${subjectBaseText} Published` } else { emailType = 'author-manuscript-rejected' - email.content.subject = `${subjectBaseText} Rejected` + email.content.subject = `${subjectBaseText} Decision` } authors = fragmentAuthors.activeAuthors.map(author => ({ @@ -214,7 +239,10 @@ const sendAuthorsEmail = async ({ })) } else { emailType = 'author-request-to-revision' + email.content.subject = `${subjectBaseText} Recommendation` + email.content.signatureName = handlingEditorName + const authorNote = newRecommendation.comments.find(comm => comm.public) const content = get(authorNote, 'content') const authorNoteText = content ? `Reason & Details: "${content}"` : '' @@ -328,20 +356,18 @@ const sendReviewersEmail = async ({ }, ) const { html, text } = email.getBody({ - body: { paragraph: reviewer.paragraph }, - hasLink: reviewer.hasLink, + body: { paragraph: reviewer.paragraph, hasLink: reviewer.hasLink }, }) email.sendEmail({ html, text }) }) } -const sendEiCEmail = ({ +const sendEiCsEmail = async ({ email, - eicName, - eicEmail, titleText, - recommendation: { recommendation, comments: recComments = [] }, + userHelper, subjectBaseText, + recommendation: { recommendation, comments: recComments = [] }, }) => { let emailType @@ -361,19 +387,63 @@ const sendEiCEmail = ({ throw new Error(`undefined recommendation: ${recommendation} `) } + const privateNote = recComments.find(comm => !comm.public) + const content = get(privateNote, 'content') + const comments = content ? `Note to Editor: "${content}"` : '' + + const editors = (await userHelper.getEditorsInChief()).map(eic => ({ + ...eic, + ...getEmailCopy({ + emailType, + titleText, + comments, + }), + })) + + editors.forEach(eic => { + email.toUser = { + email: eic.email, + name: `${eic.firstName} ${eic.lastName}`, + } + const { html, text } = email.getBody({ + body: { paragraph: eic.paragraph, hasLink: eic.hasLink }, + }) + email.sendEmail({ html, text }) + }) +} + +const sendEQAEmail = ({ + email, + eicName, + baseUrl, + collection, + subjectBaseText, +}) => { + const emailType = 'eqa-manuscript-request-for-approval' + email.toUser = { - email: eicEmail, - name: eicName, + email: editorialAssistantEmail, + name: 'Editorial Assistant', } - const privateNote = recComments.find(comm => comm.private) - const content = get(privateNote, 'content') - const comments = content ? `Note to Editor: "${content}"` : '' + email.content.unsubscribeLink = baseUrl + email.content.signatureName = eicName + email.content.subject = `${subjectBaseText} Request for EQA Approval` + email.content.ctaLink = services.createUrl( + baseUrl, + config.get('eqa-decision.url'), + { + collectionId: collection.id, + customId: collection.customId, + token: collection.technicalChecks.token, + }, + ) + email.content.ctaText = 'MAKE DECISION' + const { html, text } = email.getBody({ body: getEmailCopy({ emailType, - titleText, - comments, + customId: collection.customId, }), }) email.sendEmail({ html, text }) diff --git a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js index 37677984272615861c98b4d2b2b7f341078933f6..f67754387a6deef1b7dd8f2d93f31221c7bd52d5 100644 --- a/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js +++ b/packages/component-manuscript-manager/src/routes/fragmentsRecommendations/post.js @@ -1,5 +1,7 @@ const uuid = require('uuid') -const { pick } = require('lodash') +const { pick, get, set, has } = require('lodash') +const config = require('config') +const { v4 } = require('uuid') const { services, @@ -7,6 +9,10 @@ const { Collection, } = require('pubsweet-component-helper-service') +const s3Config = get(config, 'pubsweet-component-aws-s3', {}) +const mtsConfig = get(config, 'mts-service', {}) +const MTSService = require('pubsweet-component-mts-package') + const notifications = require('./notifications/notifications') module.exports = models => async (req, res) => { @@ -70,7 +76,43 @@ module.exports = models => async (req, res) => { fragment.revision = pick(fragment, ['authors', 'files', 'metadata']) } + const technicalChecks = get(collection, 'technicalChecks', {}) + const { hasEQA } = technicalChecks + + if (isEditorInChief && recommendation === 'publish' && !hasEQA) { + const { journal, xmlParser, ftp } = mtsConfig + const MTS = new MTSService(journal, xmlParser, s3Config, ftp) + const packageFragment = { + ...fragment, + metadata: { + ...fragment.metadata, + customId: collection.customId, + }, + } + + await MTS.sendPackage({ fragment: packageFragment, isEQA: true }) + + collection.status = 'inQA' + set(collection, 'technicalChecks.token', v4()) + set(collection, 'technicalChecks.hasEQA', false) + await collection.save() + } + + /* if the EiC returns the manuscript to the HE after the EQA has been performed + then remove all properties from the technicalChecks property so that the manuscript + can go through the EQA process again + */ + if ( + isEditorInChief && + recommendation === 'return-to-handling-editor' && + has(collection.technicalChecks, 'hasEQA') + ) { + collection.technicalChecks = {} + await collection.save() + } + notifications.sendNotifications({ + hasEQA, fragment, collection, isEditorInChief, diff --git a/packages/component-manuscript-manager/src/routes/technicalChecks/notifications/emailCopy.js b/packages/component-manuscript-manager/src/routes/technicalChecks/notifications/emailCopy.js index c562e8be47b57e98d965e88681150adead6057b0..0c36acca52a116cc5f9584d0679a52944e635b02 100644 --- a/packages/component-manuscript-manager/src/routes/technicalChecks/notifications/emailCopy.js +++ b/packages/component-manuscript-manager/src/routes/technicalChecks/notifications/emailCopy.js @@ -1,14 +1,34 @@ -const getEmailCopy = ({ emailType, titleText }) => { +const getEmailCopy = ({ emailType, titleText, comments }) => { let paragraph + let hasLink = true switch (emailType) { case 'eqs-manuscript-accepted': paragraph = `We are please to inform you that ${titleText} has passed the Hindawi technical check process and is now submitted. Please click the link below to access the manuscript.` break + case 'he-manuscript-published': + hasLink = false + paragraph = `Thank you for your recommendation to publish ${titleText} based on the reviews you received.<br/><br/> + I can confirm this article will now go through to publication.` + break + case 'author-manuscript-published': + paragraph = `I am delighted to inform you that ${titleText} has passed through the review process and will be published in Hindawi.<br/><br/> + Thanks again for choosing to publish with us.` + hasLink = false + break + case 'submitted-reviewers-after-publish': + hasLink = false + paragraph = `Thank you for your review on ${titleText}. After taking into account the reviews and the recommendation of the Handling Editor, I can confirm this article will now be published.<br/><br/> + If you have any queries about this decision, then please email them to Hindawi as soon as possible.` + break + case 'eqa-manuscript-returned-to-eic': + paragraph = `We regret to inform you that ${titleText} has been returned with comments. Please click the link below to access the manuscript.<br/><br/> + Comments: ${comments}<br/><br/>` + break default: throw new Error(`The ${emailType} email type is not defined.`) } - return { paragraph, hasLink: true } + return { paragraph, hasLink } } module.exports = { diff --git a/packages/component-manuscript-manager/src/routes/technicalChecks/notifications/notifications.js b/packages/component-manuscript-manager/src/routes/technicalChecks/notifications/notifications.js index 0da35cdfd11321ba4fbced245cd01a8e8a9ba22f..89cc1d71180c06f2fed6c78f2650a974ec8501ea 100644 --- a/packages/component-manuscript-manager/src/routes/technicalChecks/notifications/notifications.js +++ b/packages/component-manuscript-manager/src/routes/technicalChecks/notifications/notifications.js @@ -1,3 +1,6 @@ +const config = require('config') +const { get } = require('lodash') + const { User, Email, @@ -6,10 +9,15 @@ const { } = require('pubsweet-component-helper-service') const { getEmailCopy } = require('./emailCopy') +const unsubscribeSlug = config.get('unsubscribe.url') + module.exports = { async sendNotifications({ + isEQA, + agree, baseUrl, collection, + comments = '', User: UserModel, Fragment: FragmentModel, }) { @@ -18,30 +26,29 @@ module.exports = { const parsedFragment = await fragmentHelper.getFragmentData({ handlingEditor: collection.handlingEditor, }) - const { submittingAuthor } = await fragmentHelper.getAuthorData({ + + const { + activeAuthors: authors, + submittingAuthor, + } = await fragmentHelper.getAuthorData({ UserModel, }) const titleText = `the manuscript titled "${parsedFragment.title}" by ${ submittingAuthor.firstName } ${submittingAuthor.lastName}` + const subjectBaseText = `${collection.customId}: Manuscript` const userHelper = new User({ UserModel }) - const { - lastName, - firstName, - email: eicEmail, - } = await userHelper.getEditorInChief() + const subject = `${subjectBaseText} ${ + agree ? '' : 'Not ' + }Passed Technical Checks` const email = new Email({ type: 'user', - toUser: { - email: eicEmail, - name: `${firstName} ${lastName}`, - }, content: { - subject: `${collection.customId}: Manuscript Passed Technical Checks`, - signatureName: 'EQS Team', + subject, + signatureName: 'EQA Team', ctaLink: services.createUrl( baseUrl, `/projects/${collection.id}/versions/${fragment.id}/details`, @@ -51,12 +58,173 @@ module.exports = { }, }) - const { html, text } = email.getBody({ - body: getEmailCopy({ + if (isEQA && agree) { + const eicName = await userHelper.getEiCName() + email.content.signatureName = eicName + + sendAuthorsEmail({ + email, + baseUrl, + titleText, + subjectBaseText, + fragmentAuthors: authors, + }) + sendHandlingEditorEmail({ + email, + baseUrl, + titleText, + subjectBaseText, + handlingEditor: get(collection, 'handlingEditor', {}), + }) + sendSubmittedReviewersEmail({ + email, + baseUrl, titleText, - emailType: 'eqs-manuscript-accepted', - }), + UserModel, + fragmentHelper, + subjectBaseText, + }) + } else { + sendEditorsEmail({ email, agree, comments, userHelper, titleText }) + } + }, +} + +const sendEditorsEmail = async ({ + email, + agree, + comments = '', + userHelper, + titleText, +}) => { + const emailType = agree + ? 'eqs-manuscript-accepted' + : 'eqa-manuscript-returned-to-eic' + + const editors = (await userHelper.getEditorsInChief()).map(eic => ({ + ...eic, + ...getEmailCopy({ + emailType, + titleText, + comments, + }), + })) + + editors.forEach(eic => { + email.toUser = { + email: eic.email, + name: `${eic.firstName} ${eic.lastName}`, + } + const { html, text } = email.getBody({ + body: { paragraph: eic.paragraph, hasLink: eic.hasLink }, }) email.sendEmail({ html, text }) - }, + }) +} + +const sendHandlingEditorEmail = ({ + email, + baseUrl, + titleText, + handlingEditor, + subjectBaseText, +}) => { + email.content.subject = `${subjectBaseText} Decision` + const emailType = 'he-manuscript-published' + + email.toUser = { + email: handlingEditor.email, + name: handlingEditor.name, + } + email.content.unsubscribeLink = services.createUrl(baseUrl, unsubscribeSlug, { + id: handlingEditor.id, + }) + + const { html, text } = email.getBody({ + body: getEmailCopy({ + emailType, + titleText, + }), + }) + email.sendEmail({ html, text }) +} + +const sendSubmittedReviewersEmail = async ({ + email, + baseUrl, + titleText, + UserModel, + fragmentHelper, + subjectBaseText, +}) => { + email.content.subject = `${subjectBaseText} Decision` + + const reviewers = (await fragmentHelper.getReviewers({ + UserModel, + type: 'submitted', + })).map(rev => ({ + ...rev, + ...getEmailCopy({ + emailType: 'submitted-reviewers-after-publish', + titleText, + }), + })) + + reviewers.forEach(reviewer => { + email.toUser = { + email: reviewer.email, + name: `${reviewer.firstName} ${reviewer.lastName}`, + } + email.content.unsubscribeLink = services.createUrl( + baseUrl, + unsubscribeSlug, + { + id: reviewer.id, + }, + ) + const { html, text } = email.getBody({ + body: { paragraph: reviewer.paragraph, hasLink: reviewer.hasLink }, + }) + email.sendEmail({ html, text }) + }) +} + +const sendAuthorsEmail = ({ + email, + baseUrl, + titleText, + subjectBaseText, + fragmentAuthors, +}) => { + const emailType = 'author-manuscript-published' + email.content.subject = `${subjectBaseText} Published` + + const authors = fragmentAuthors.map(author => ({ + ...author, + ...getEmailCopy({ + emailType, + titleText, + }), + })) + + authors.forEach(author => { + email.toUser = { + email: author.email, + name: `${author.firstName} ${author.lastName}`, + } + email.content.unsubscribeLink = services.createUrl( + baseUrl, + unsubscribeSlug, + { + id: author.id, + }, + ) + const { html, text } = email.getBody({ + body: { + paragraph: author.paragraph, + hasLink: author.hasLink, + }, + }) + email.sendEmail({ html, text }) + }) } diff --git a/packages/component-manuscript-manager/src/routes/technicalChecks/patch.js b/packages/component-manuscript-manager/src/routes/technicalChecks/patch.js index 934709a746946699d07062c71084b3013f4fff13..14e68a7cc4b745752a5ddd2d23a733876f0f9a85 100644 --- a/packages/component-manuscript-manager/src/routes/technicalChecks/patch.js +++ b/packages/component-manuscript-manager/src/routes/technicalChecks/patch.js @@ -12,13 +12,13 @@ const setNewStatus = (step, agree) => { if (step === TECHNICAL_STEPS.EQS) { return agree ? 'submitted' : 'rejected' } else if (step === TECHNICAL_STEPS.EQA) { - return agree ? 'accepted' : 'rejected' + return agree ? 'accepted' : 'pendingApproval' } } module.exports = ({ Collection, Fragment, User }) => async (req, res) => { const { collectionId } = req.params - const { token, agree, step } = req.body + const { token, agree, step, comments } = req.body try { const collection = await Collection.find(collectionId) @@ -37,17 +37,21 @@ module.exports = ({ Collection, Fragment, User }) => async (req, res) => { } delete collection.technicalChecks.token + if (step === TECHNICAL_STEPS.EQA) { + collection.technicalChecks.hasEQA = true + } collection.status = setNewStatus(step, agree) await collection.save() - if (agree) { - sendNotifications({ - User, - Fragment, - collection, - baseUrl: services.getBaseUrl(req), - }) - } + sendNotifications({ + User, + agree, + comments, + Fragment, + collection, + baseUrl: services.getBaseUrl(req), + isEQA: step === TECHNICAL_STEPS.EQA, + }) return res.status(200).json(collection) } catch (e) { 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 0f0f048e62835ecbd5d6c9df1f36ad3de740b183..686124cb9518df27b9c722fae0f645c764a4d415 100644 --- a/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js +++ b/packages/component-manuscript-manager/src/tests/fragmentsRecommendations/post.test.js @@ -11,6 +11,7 @@ const chance = new Chance() jest.mock('@pubsweet/component-send-email', () => ({ send: jest.fn(), })) +jest.mock('pubsweet-component-mts-package') const reqBody = { recommendation: 'accept', @@ -206,4 +207,129 @@ describe('Post fragments recommendations route handler', () => { const data = JSON.parse(res._getData()) expect(data.error).toEqual('Unauthorized.') }) + it('should return success when the EiC recommends to reject without peer review', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body.recommendation = 'reject' + body.recommendationType = 'editorRecommendation' + + delete fragment.recommendations + delete fragment.revision + delete fragment.invitations + delete collection.invitations + delete collection.handlingEditor + + const res = await requests.sendRequest({ + body, + userId: editorInChief.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(editorInChief.id) + expect(data.recommendation).toBe('reject') + }) + it('should return success when the EiC recommends to publish without EQA', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body.recommendation = 'publish' + body.recommendationType = 'editorRecommendation' + delete collection.technicalChecks + + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + + expect(collection.status).toBe('inQA') + expect(collection.technicalChecks).toHaveProperty('hasEQA') + expect(collection.technicalChecks.hasEQA).toBeFalsy() + expect(data.userId).toEqual(editorInChief.id) + expect(data.recommendation).toBe('publish') + }) + it('should return success when the EiC recommends to publish with EQA accepted', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body.recommendation = 'publish' + body.recommendationType = 'editorRecommendation' + + collection.technicalChecks.hasEQA = true + + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + + expect(collection.status).toBe('accepted') + expect(data.userId).toEqual(editorInChief.id) + expect(data.recommendation).toBe('publish') + }) + it('should return success when the EiC returns the manuscript to HE with comments after EQA returned to EiC', async () => { + const { editorInChief } = testFixtures.users + const { collection } = testFixtures.collections + const { fragment } = testFixtures.fragments + body.recommendation = 'return-to-handling-editor' + body.recommendationType = 'editorRecommendation' + body.comments = 'This needs more work' + + delete fragment.recommendations + delete fragment.revision + delete fragment.invitations + delete collection.invitations + delete collection.handlingEditor + collection.technicalChecks.hasEQA = false + + const res = await requests.sendRequest({ + body, + userId: editorInChief.id, + models, + route, + path, + params: { + collectionId: collection.id, + fragmentId: fragment.id, + }, + }) + + expect(res.statusCode).toBe(200) + const data = JSON.parse(res._getData()) + + expect(collection.status).toBe('reviewCompleted') + expect(collection.technicalChecks).not.toHaveProperty('token') + expect(collection.technicalChecks).not.toHaveProperty('hasEQA') + + expect(data.userId).toEqual(editorInChief.id) + expect(data.recommendation).toBe('return-to-handling-editor') + }) }) diff --git a/packages/component-manuscript-manager/src/tests/technicalChecks/patch.test.js b/packages/component-manuscript-manager/src/tests/technicalChecks/patch.test.js index 37c2ca388c56320be9a1527db7aa22c6f310a002..f8b6ccd6bc92c2817aeb60b454c6347a7d6eb47b 100644 --- a/packages/component-manuscript-manager/src/tests/technicalChecks/patch.test.js +++ b/packages/component-manuscript-manager/src/tests/technicalChecks/patch.test.js @@ -29,7 +29,7 @@ describe('Patch technical checks route handler', () => { models = Model.build(testFixtures) }) - it('should return success when the parameters are correct', async () => { + it('should return success when the EQS is accepted', async () => { const { collection } = testFixtures.collections body.token = collection.technicalChecks.token @@ -46,6 +46,62 @@ describe('Patch technical checks route handler', () => { expect(res.statusCode).toBe(200) }) + it('should return success when the EQS is rejected', async () => { + const { collection } = testFixtures.collections + body.token = collection.technicalChecks.token + body.agree = false + + const res = await requests.sendRequest({ + body, + models, + route, + path, + params: { + collectionId: collection.id, + }, + }) + + expect(res.statusCode).toBe(200) + }) + + it('should return success when the EQA is accepted', async () => { + const { collection } = testFixtures.collections + body.token = collection.technicalChecks.token + body.step = 'eqa' + + const res = await requests.sendRequest({ + body, + models, + route, + path, + params: { + collectionId: collection.id, + }, + }) + + expect(res.statusCode).toBe(200) + }) + + it('should return success when the EQA is returned with comments', async () => { + const { collection } = testFixtures.collections + body.token = collection.technicalChecks.token + body.agree = false + body.step = 'eqa' + body.comments = 'suspicion of plagiarism' + + const res = await requests.sendRequest({ + body, + models, + route, + path, + params: { + collectionId: collection.id, + }, + }) + + expect(res.statusCode).toBe(200) + }) + it('should return an error when the collection does not exist', async () => { const res = await requests.sendRequest({ body, diff --git a/packages/component-manuscript/src/components/ManuscriptLayout.js b/packages/component-manuscript/src/components/ManuscriptLayout.js index 7c68d8ef1d2db6d3f13592a8afaa2a4a6e0408a7..c0ed8a1f1635315f2e1f5aa3ea048fec535ab306 100644 --- a/packages/component-manuscript/src/components/ManuscriptLayout.js +++ b/packages/component-manuscript/src/components/ManuscriptLayout.js @@ -30,7 +30,6 @@ const ManuscriptLayout = ({ editorInChief, canMakeRevision, editorialRecommendations, - responseToReviewers, hasResponseToReviewers, project = {}, version = {}, diff --git a/packages/component-manuscript/src/components/SideBarActions.js b/packages/component-manuscript/src/components/SideBarActions.js index d5e8b16b033cf934842da48a3126b4fd686545d0..ea233bbbc75e697bdfb16c76f7fbdb8c50ed3fcd 100644 --- a/packages/component-manuscript/src/components/SideBarActions.js +++ b/packages/component-manuscript/src/components/SideBarActions.js @@ -1,5 +1,5 @@ import React from 'react' -import { compose } from 'recompose' +import { compose, withHandlers } from 'recompose' import { Icon } from '@pubsweet/ui' import { connect } from 'react-redux' import styled from 'styled-components' @@ -11,18 +11,21 @@ import { Recommendation, } from 'pubsweet-components-faraday/src/components' -import { createRevision } from 'pubsweet-component-wizard/src/redux/conversion' - import { canMakeDecision, canMakeRecommendation, + canEditManuscript, + canOverrideTechnicalChecks, } from 'pubsweet-component-faraday-selectors/src' const SideBarActions = ({ project, version, - createRevision, + goToEdit, canMakeDecision, + canEditManuscript, + goToTechnicalCheck, + canOverrideTechChecks, canMakeRecommendation, }) => ( <Root> @@ -31,9 +34,9 @@ const SideBarActions = ({ collectionId={project.id} fragmentId={version.id} modalKey={`decide-${version.id}`} + status={project.status} /> )} - {canMakeRecommendation && ( <Recommendation collectionId={project.id} @@ -41,6 +44,24 @@ const SideBarActions = ({ modalKey={`recommend-${version.id}`} /> )} + {canOverrideTechChecks && ( + <ClickableIcon + data-test="button-technical-checks" + onClick={goToTechnicalCheck(project)} + title="Technical Checks" + > + <Icon>check-square</Icon> + </ClickableIcon> + )} + {canEditManuscript && ( + <ClickableIcon + data-test="button-edit-manuscript" + onClick={goToEdit(project, version)} + title="Edit Manuscript" + > + <Icon>edit</Icon> + </ClickableIcon> + )} <ZipFiles archiveName={`ID-${project.customId}`} collectionId={project.id} @@ -55,15 +76,27 @@ const SideBarActions = ({ export default compose( withRouter, - connect( - (state, { project, version }) => ({ - canMakeDecision: canMakeDecision(state, project, version), - canMakeRecommendation: canMakeRecommendation(state, project, version), - }), - (dispatch, { project, version, history }) => ({ - createRevision: () => dispatch(createRevision(project, version, history)), - }), - ), + connect((state, { project, version }) => ({ + canMakeDecision: canMakeDecision(state, project, version), + canMakeRecommendation: canMakeRecommendation(state, project, version), + canEditManuscript: canEditManuscript(state, project, version), + canOverrideTechChecks: canOverrideTechnicalChecks(state, project), + })), + withHandlers({ + goToEdit: ({ history }) => (project, version) => () => { + history.push(`/projects/${project.id}/versions/${version.id}/submit`, { + editMode: true, + }) + }, + goToTechnicalCheck: ({ history }) => project => () => { + const { status, id, customId, technicalChecks: { token = '' } } = project + const stage = status === 'technicalChecks' ? 'eqs' : 'eqa' + history.push({ + pathname: `/${stage}-decision`, + search: `?collectionId=${id}&customId=${customId}&token=${token}`, + }) + }, + }), )(SideBarActions) // #region styled-components diff --git a/packages/component-manuscript/src/components/SubmitRevision.js b/packages/component-manuscript/src/components/SubmitRevision.js index a962668fb2bbb1537795e8057505a23526e109ad..afaf7697ebebc111d39a773e01ccf69e594c5e48 100644 --- a/packages/component-manuscript/src/components/SubmitRevision.js +++ b/packages/component-manuscript/src/components/SubmitRevision.js @@ -24,6 +24,7 @@ import { import { AuthorList, Files } from 'pubsweet-components-faraday/src/components' import { submitRevision } from 'pubsweet-component-wizard/src/redux/conversion' import AutosaveIndicator from 'pubsweet-component-wizard/src/components/AutosaveIndicator' +import { selectReviewRecommendations } from 'pubsweet-components-faraday/src/redux/recommendations' import { toClass, compose, @@ -60,6 +61,7 @@ const SubmitRevision = ({ removeFile, handleSubmit, responseFiles, + reviews = [], submitFailed, }) => ( <Root> @@ -108,39 +110,41 @@ const SubmitRevision = ({ /> </CustomValidatedField> </Expandable> - <Expandable label="RESPONSE TO REVIEWER COMMENTS" startExpanded> - <Title>Reply text*</Title> - <Row> - <FullWidth className="full-width"> - <ValidatedField - component={TextAreaField} - name="commentsToReviewers" - validate={ - isEmpty(get(formValues, 'files.responseToReviewers')) - ? [required] - : [] - } - /> - </FullWidth> - </Row> - <Row left> - {responseFiles.map(file => ( - <FileItem - compact - id={file.id} - key={file.id} - {...file} - removeFile={removeFile} - /> - ))} - </Row> - <FilePicker - allowedFileExtensions={['pdf', 'doc', 'docx']} - onUpload={addFile} - > - <ActionText left={12}>Upload file</ActionText> - </FilePicker> - </Expandable> + {!isEmpty(reviews) && ( + <Expandable label="RESPONSE TO REVIEWER COMMENTS" startExpanded> + <Title>Reply text*</Title> + <Row> + <FullWidth className="full-width"> + <ValidatedField + component={TextAreaField} + name="commentsToReviewers" + validate={ + isEmpty(get(formValues, 'files.responseToReviewers')) + ? [required] + : [] + } + /> + </FullWidth> + </Row> + <Row left> + {responseFiles.map(file => ( + <FileItem + compact + id={file.id} + key={file.id} + {...file} + removeFile={removeFile} + /> + ))} + </Row> + <FilePicker + allowedFileExtensions={['pdf', 'doc', 'docx']} + onUpload={addFile} + > + <ActionText left={12}>Upload file</ActionText> + </FilePicker> + </Expandable> + )} <SubmitContainer> {submitFailed && formError && <Error>There are some errors above.</Error>} @@ -169,8 +173,9 @@ export default compose( modalComponent: ConfirmationModal, })), connect( - state => ({ + (state, { version }) => ({ fileFetching: getRequestStatus(state), + reviews: selectReviewRecommendations(state, version.id), formValues: getFormValues('revision')(state), formError: getFormSyncErrors('revision')(state), }), diff --git a/packages/component-mts-package/readme.md b/packages/component-mts-package/readme.md new file mode 100644 index 0000000000000000000000000000000000000000..45347c011043112bece9ecd1a88f0fe9bf233775 --- /dev/null +++ b/packages/component-mts-package/readme.md @@ -0,0 +1,45 @@ +## MTS service integration + +This component is running as a service to integrate current Hindawi Manuscript Tracking System for Editorial Quality Screening and for Editorial Quality Assurance. + +### Use-case + +As an Editor in Chief, I want the manuscript to go through Editorial Quality Screening before I assign a Handling Editor to it and start the review process. + +### Workflow + +Stage 1: When an article is submitted, Faraday needs to convert the submission to XML JATS format, and upload as a .zip file to a FTP server. + +Stage 2: When new .zip file has been detected on FTP server, then third party system MTS (Manuscript Tracking System - Hindawi) needs to consume package and populate DB records. + +Stage 3: When check is completed, then Faraday system needs to be updated to move status from Technical Checks to Submitted - which allows the Editor in Chief to assign a Handling Editor. + + + +### Configuration +| Params | Required | Description | +| ------------- |:-------------:| -----| +| journalConfig | Yes | Journal configuration in .xml file as: doctype, dtdVersion, journalTitle, articleType, journal email, journalIdPublisher, issn | +| xmlParserOptions | No | parsing config options used by xml-js library | +| s3Config | Yes | Access to AWS S3 Bucket where fragment files are saved | +| FTPConfig | Yes | FTP server connection credentials | + + +### Usage + +MTS service gathers all the fragment files, creates an .xml file in a specific JATS format out of fragment JSON, creates a .zip archive and sends it to a specific FTP server with a configurable package name (also a backup it's uploaded to AWS S3). + +.xml structure of Hindawi use-case can be checked `/tests/sample.xml` from generated json `/tests/sample.json`. + +#### Example + +```javascript +const fragment = {} //fragment json here +const { journalConfig, xmlParser, s3Config, ftpConfig } = config //import your config +const MTS = new MTSService(journalConfig, xmlParser, s3Config, ftpConfig) +MTS.sendPackage({ fragment }) +``` + +[GIFs Demo](https://gitlab.coko.foundation/xpub/xpub-faraday/wikis/mts-integration) + + diff --git a/packages/component-mts-package/src/MTS.js b/packages/component-mts-package/src/MTS.js index 0d52a235a9d63630cadd9b88cf5e036aced1e11c..a8a9421139c0beafbf844b974658b8e602822b17 100644 --- a/packages/component-mts-package/src/MTS.js +++ b/packages/component-mts-package/src/MTS.js @@ -216,15 +216,19 @@ class MTS { return this.convertToXML(this.composeJson(fragment)) } - sendPackage(fragment = {}) { + sendPackage({ fragment = {}, isEQA = false }) { const xmlFile = this.convertFragmentToXML(fragment) return PackageManager.createFilesPackage(this.s3Config)({ fragment, xmlFile, + isEQA, }).then(() => { - const manuscriptName = get(xmlFile, 'name', '').replace('.xml', '') - const filename = `${manuscriptName}.zip` + const packageName = get(xmlFile, 'name', '').replace('.xml', '') + const filename = isEQA + ? `ACCEPTED_${packageName}.zip` + : `${packageName}.zip` + return PackageManager.uploadFiles({ filename, s3Config: this.s3Config, diff --git a/packages/component-mts-package/src/PackageManager.js b/packages/component-mts-package/src/PackageManager.js index 43d9386c5037e78176ee3a41116fdaf071dfe366..b98dbf10308c84b927b10d3f670b285a1b6ca8b7 100644 --- a/packages/component-mts-package/src/PackageManager.js +++ b/packages/component-mts-package/src/PackageManager.js @@ -17,29 +17,29 @@ const createFilesPackage = (s3Config, archiver = nodeArchiver) => { }) const s3 = new AWS.S3() const asyncGetObject = promisify(s3.getObject.bind(s3)) - const asyncListObjects = promisify(s3.listObjects.bind(s3)) - return async ({ fragment, fileTypes, xmlFile }) => { - const { id } = fragment - const manuscriptName = get(xmlFile, 'name', '').replace('.xml', '') + return async ({ fragment, fileTypes, xmlFile, isEQA = false }) => { + const { files = {} } = fragment + let packageName = get(xmlFile, 'name', '').replace('.xml', '') + if (isEQA) { + packageName = `ACCEPTED_${packageName}` + } try { - const params = { - Bucket: s3Config.bucket, - Prefix: `${id}`, - } - const s3Items = await asyncListObjects(params) - if (s3Items) { + const s3FileIDs = Object.values(files) + .reduce((acc, f) => [...acc, ...f], []) + .map(f => f.id) + + if (s3FileIDs) { const s3Files = await Promise.all( - s3Items.Contents.map(content => + s3FileIDs.map(fileID => asyncGetObject({ Bucket: s3Config.bucket, - Key: content.Key, + Key: fileID, }), ), ) - if (s3Files) { - const packageOutput = fs.createWriteStream(`${manuscriptName}.zip`) + const packageOutput = fs.createWriteStream(`${packageName}.zip`) const archive = archiver('zip') archive.pipe(packageOutput) @@ -53,6 +53,7 @@ const createFilesPackage = (s3Config, archiver = nodeArchiver) => { }) archive.on('error', err => { + logger.error(err) throw err }) archive.on('end', err => { diff --git a/packages/component-mts-package/tests/MTS.test.js b/packages/component-mts-package/tests/MTS.test.js index 6655fbe07f3602b353cb89be4371bde679d4ddff..42c67eafd272402699e9be402e3f9d3f1860e0a6 100644 --- a/packages/component-mts-package/tests/MTS.test.js +++ b/packages/component-mts-package/tests/MTS.test.js @@ -27,7 +27,7 @@ describe('MTS integration', () => { }) it('should contain configured journal name ', () => { - const result = MTS.composeJson(mocks.fragment) + const result = MTS.composeJson({ fragment: mocks.fragment }) expect(result).toHaveProperty( 'article.front.journal-meta.journal-title-group.journal-title._text', 'Bioinorganic Chemistry and Applications', diff --git a/packages/component-mts-package/tests/sample.json b/packages/component-mts-package/tests/sample.json index 21ccc5f10edeed4f9c776ff30bc1bb0fd58e6053..17f672cb1edf947a633dac1beb4818bb08662dbc 100644 --- a/packages/component-mts-package/tests/sample.json +++ b/packages/component-mts-package/tests/sample.json @@ -24,12 +24,12 @@ "_attributes": { "journal-id-type": "email" }, - "_text": "trashjo@ariessys.com" + "_text": "faraday@hindawi.com" } ], "journal-title-group": { "journal-title": { - "_text": "Research" + "_text": "Bioinorganic Chemistry and Applications" } }, "issn": [ @@ -37,7 +37,7 @@ "_attributes": { "pub-type": "ppub" }, - "_text": "0000-000Y" + "_text": "2474-7394" }, { "_attributes": { @@ -52,60 +52,28 @@ "_attributes": { "pub-id-type": "publisher-id" }, - "_text": "RESEARCH-D-18-00005" + "_text": "RESEARCH-F-3326913" }, { "_attributes": { "pub-id-type": "manuscript" }, - "_text": "RESEARCH-D-18-00005" + "_text": "RESEARCH-F-3326913" } ], "article-categories": { - "subj-group": [ - { - "_attributes": { - "subj-group-type": "Article Type" - }, - "subject": { - "_text": "Research Article" - } - }, - { - "_attributes": { - "subj-group-type": "Category" - }, - "subject": { - "_text": "Information science" - } - }, - { - "_attributes": { - "subj-group-type": "Classification" - }, - "subject": { - "_text": "Applied sciences and engineering" - } + "subj-group": { + "_attributes": { + "subj-group-type": "Article Type" }, - { - "_attributes": { - "subj-group-type": "Classification" - }, - "subject": { - "_text": "Scientific community" - } + "subject": { + "_text": "clinical-study" } - ] + } }, "title-group": { "article-title": { - "_text": "January 24 sample article with new email trigger in place" - }, - "alt-title": { - "_attributes": { - "alt-title-type": "running-head" - }, - "_text": "let's hope this works" + "_text": "Demo sprint 16 no fun" } }, "contrib-group": { @@ -121,17 +89,17 @@ }, "name": { "surname": { - "_text": "Heckner" + "_text": "Raluca" }, "given-names": { - "_text": "Hannah" + "_text": "Authorescu" }, "prefix": { - "_text": "Ms." + "_text": "miss" } }, "email": { - "_text": "hheckner@aaas.org" + "_text": "raluca.gramschi+auth@thinslices.com" }, "xref": { "_attributes": { @@ -144,8 +112,9 @@ "_attributes": { "id": "aff1" }, - "country": { - "_text": "UNITED STATES" + "country": {}, + "addr-line": { + "_text": "Technical University Gheorghe Asachi Iasi" } } }, @@ -155,10 +124,10 @@ "date-type": "received" }, "day": { - "_text": "24" + "_text": "3" }, "month": { - "_text": "01" + "_text": "8" }, "year": { "_text": "2018" @@ -166,78 +135,58 @@ } }, "abstract": { - "p": { - "_text": "Abstract\nThis article explains and illustrates the use of LATEX in preparing manuscripts for submission to the American Journal of Physics (AJP). While it is not a comprehensive reference, we hope it will suffice for the needs of most AJP authors." - } + "_text": "<p>some abstract here</p>" }, - "kwd-group": { - "kwd": [ - { - "_text": "manuscript" + "funding-group": {} + }, + "files": { + "file": [ + { + "item_type": { + "_text": "coverLetter" }, - { - "_text": "submissions" + "item_description": { + "_text": "sample cover letter_ms 1.doc" }, - { - "_text": "ftp site" - } - ] - }, - "funding-group": {}, - "counts": { - "fig-count": { - "_attributes": { - "count": "0" + "item_name": { + "_text": "sample cover letter_ms 1.doc" } - } - }, - "custom-meta-group": { - "custom-meta": [ - { - "meta-name": { - "_text": "Black and White Image Count" - }, - "meta-value": { - "_text": "0" - } + }, + { + "item_type": { + "_text": "manuscripts" }, - { - "meta-name": { - "_text": "Color Image Count" - }, - "meta-value": { - "_text": "0" - } + "item_description": { + "_text": "manuscript.pdf" + }, + "item_name": { + "_text": "manuscript.pdf" } - ] - } - } - }, - "body": { - "fig": [ - { - "label": { - "_text": "Figure 1" }, - "graphic": { - "_attributes": { - "xlink:href": "GasBulbData.eps", - "xmlns:xlink": "http://www.w3.org/1999/xlink" + { + "item_type": { + "_text": "supplementary" + }, + "item_description": { + "_text": "important-emails.md" + }, + "item_name": { + "_text": "important-emails.md" } - } - }, - { - "label": { - "_text": "Figure 2" }, - "graphic": { - "_attributes": { - "xlink:href": "ThreeSunsets.jpg", - "xmlns:xlink": "http://www.w3.org/1999/xlink" + { + "item_type": { + "_text": "supplementary" + }, + "item_description": { + "_text": "important-emails.md" + }, + "item_name": { + "_text": "important-emails (1).md" } } - } - ] + ] + } } } } \ No newline at end of file diff --git a/packages/component-mts-package/tests/sample.xml b/packages/component-mts-package/tests/sample.xml index 697e0e64c760dc9f1acdda45ff087c984303906e..d91397fb44989adaffbcc7d597b457138b71b714 100644 --- a/packages/component-mts-package/tests/sample.xml +++ b/packages/component-mts-package/tests/sample.xml @@ -4,89 +4,71 @@ <front> <journal-meta> <journal-id journal-id-type="publisher">research</journal-id> - <journal-id journal-id-type="email">trashjo@ariessys.com</journal-id> + <journal-id journal-id-type="email">faraday@hindawi.com</journal-id> <journal-title-group> - <journal-title>Research</journal-title> + <journal-title>Bioinorganic Chemistry and Applications</journal-title> </journal-title-group> - <issn pub-type="ppub">0000-000Y</issn> + <issn pub-type="ppub">2474-7394</issn> <issn pub-type="epub"></issn> </journal-meta> <article-meta> - <article-id pub-id-type="publisher-id">RESEARCH-D-18-00005</article-id> - <article-id pub-id-type="manuscript">RESEARCH-D-18-00005</article-id> + <article-id pub-id-type="publisher-id">RESEARCH-F-3326913</article-id> + <article-id pub-id-type="manuscript">RESEARCH-F-3326913</article-id> <article-categories> <subj-group subj-group-type="Article Type"> - <subject>Research Article</subject> - </subj-group> - <subj-group subj-group-type="Category"> - <subject>Information science</subject> - </subj-group> - <subj-group subj-group-type="Classification"> - <subject>Applied sciences and engineering</subject> - </subj-group> - <subj-group subj-group-type="Classification"> - <subject>Scientific community</subject> + <subject>clinical-study</subject> </subj-group> </article-categories> <title-group> - <article-title>January 24 sample article with new email trigger in place</article-title> - <alt-title alt-title-type="running-head">let's hope this works</alt-title> + <article-title>Demo sprint 16 no fun</article-title> </title-group> <contrib-group> <contrib contrib-type="author" corresp="yes"> - <role content-type="1" /> + <role content-type="1"></role> <name> - <surname>Heckner</surname> - <given-names>Hannah</given-names> - <prefix>Ms.</prefix> + <surname>Raluca</surname> + <given-names>Authorescu</given-names> + <prefix>miss</prefix> </name> - <email>hheckner@aaas.org</email> - <xref ref-type="aff" rid="aff1" /> + <email>raluca.gramschi+auth@thinslices.com</email> + <xref ref-type="aff" rid="aff1"></xref> </contrib> <aff id="aff1"> - <country>UNITED STATES</country> + <country></country> + <addr-line>Technical University Gheorghe Asachi Iasi</addr-line> </aff> </contrib-group> <history> <date date-type="received"> - <day>24</day> - <month>01</month> + <day>3</day> + <month>8</month> <year>2018</year> </date> </history> - <abstract> - <p>Abstract -This article explains and illustrates the use of LATEX in preparing manuscripts for submission to the American Journal of Physics (AJP). While it is not a comprehensive reference, we hope it will suffice for the needs of most AJP authors.</p> - </abstract> - <kwd-group> - <kwd>manuscript</kwd> - <kwd>submissions</kwd> - <kwd>ftp site</kwd> - </kwd-group> - <funding-group /> - <counts> - <fig-count count="0" /> - </counts> - <custom-meta-group> - <custom-meta> - <meta-name>Black and White Image Count</meta-name> - <meta-value>0</meta-value> - </custom-meta> - <custom-meta> - <meta-name>Color Image Count</meta-name> - <meta-value>0</meta-value> - </custom-meta> - </custom-meta-group> + <abstract><p>some abstract here</p></abstract> + <funding-group></funding-group> </article-meta> + <files> + <file> + <item_type>coverLetter</item_type> + <item_description>sample cover letter_ms 1.doc</item_description> + <item_name>sample cover letter_ms 1.doc</item_name> + </file> + <file> + <item_type>manuscripts</item_type> + <item_description>manuscript.pdf</item_description> + <item_name>manuscript.pdf</item_name> + </file> + <file> + <item_type>supplementary</item_type> + <item_description>important-emails.md</item_description> + <item_name>important-emails.md</item_name> + </file> + <file> + <item_type>supplementary</item_type> + <item_description>important-emails.md</item_description> + <item_name>important-emails (1).md</item_name> + </file> + </files> </front> - <body> - <fig> - <label>Figure 1</label> - <graphic xlink:href="GasBulbData.eps" xmlns:xlink="http://www.w3.org/1999/xlink" /> - </fig> - <fig> - <label>Figure 2</label> - <graphic xlink:href="ThreeSunsets.jpg" xmlns:xlink="http://www.w3.org/1999/xlink" /> - </fig> - </body> </article> \ No newline at end of file diff --git a/packages/component-user-manager/src/routes/fragmentsUsers/emails/emailCopy.js b/packages/component-user-manager/src/routes/fragmentsUsers/emails/emailCopy.js index d6e87cc86947dee0dd26537455c9ed458a929500..68132122b47e8b66bed920a01a9afcacc16ba8c6 100644 --- a/packages/component-user-manager/src/routes/fragmentsUsers/emails/emailCopy.js +++ b/packages/component-user-manager/src/routes/fragmentsUsers/emails/emailCopy.js @@ -1,8 +1,8 @@ const getEmailCopy = ({ emailType, titleText }) => { let paragraph switch (emailType) { - case 'co-author-added-to-manuscript': - paragraph = `You have been added as co-author to ${titleText}. The manuscript will become visible on your dashboard once it's submitted. Please click on the link below to access your dashboard.` + case 'author-added-to-manuscript': + paragraph = `You have been added as an author to ${titleText}. The manuscript will become visible on your dashboard once it's submitted. Please click on the link below to access your dashboard.` break case 'new-author-added-to-manuscript': paragraph = `You have been added as an author to ${titleText}. In order to gain access to the manuscript, please confirm your account and set your account details by clicking on the link below.` diff --git a/packages/component-user-manager/src/routes/fragmentsUsers/emails/notifications.js b/packages/component-user-manager/src/routes/fragmentsUsers/emails/notifications.js index 8ee396d5206c25e20e52c5145938a8242cbde576..9a9281fa22b59b1e76db7dcb8cc8ee00b6170641 100644 --- a/packages/component-user-manager/src/routes/fragmentsUsers/emails/notifications.js +++ b/packages/component-user-manager/src/routes/fragmentsUsers/emails/notifications.js @@ -9,16 +9,17 @@ const { services, Fragment, } = require('pubsweet-component-helper-service') + const { getEmailCopy } = require('./emailCopy') module.exports = { async sendNotifications({ user, baseUrl, - isSubmitting, fragment, UserModel, collection, + reqUser, }) { const fragmentHelper = new Fragment({ fragment }) const { title } = await fragmentHelper.getFragmentData({ @@ -33,8 +34,6 @@ module.exports = { } ${submittingAuthor.lastName}` const userHelper = new User({ UserModel }) - const { firstName, lastName } = await userHelper.getEditorInChief() - const eicName = `${firstName} ${lastName}` const subjectBaseText = `${collection.customId}: Manuscript` const email = new Email({ @@ -42,25 +41,34 @@ module.exports = { content: { ctaLink: baseUrl, ctaText: 'VIEW DASHBOARD', - signatureName: eicName, + signatureName: await userHelper.getEiCName(), }, }) - if (!isSubmitting) { - sendCoAuthorEmail({ email, baseUrl, user, titleText, subjectBaseText }) + if (!user.isConfirmed) { + sendNewAuthorEmail({ + email, + user, + baseUrl, + titleText, + subjectBaseText, + }) } - sendNewAuthorEmail({ - email, - user, - baseUrl, - titleText, - subjectBaseText, - }) + const requestUser = await UserModel.find(reqUser) + if (requestUser.id !== user.id) { + sendAddedToManuscriptEmail({ + email, + baseUrl, + user, + titleText, + subjectBaseText, + }) + } }, } -const sendCoAuthorEmail = ({ +const sendAddedToManuscriptEmail = ({ email, baseUrl, user, @@ -79,7 +87,7 @@ const sendCoAuthorEmail = ({ const { html, text } = email.getBody({ body: getEmailCopy({ - emailType: 'co-author-added-to-manuscript', + emailType: 'author-added-to-manuscript', titleText, }), }) @@ -87,13 +95,7 @@ const sendCoAuthorEmail = ({ email.sendEmail({ html, text }) } -const sendNewAuthorEmail = ({ - email, - baseUrl, - user, - titleText, - subjectBaseText, -}) => { +const sendNewAuthorEmail = ({ email, baseUrl, user, titleText }) => { email.toUser = { email: user.email, name: `${user.firstName} ${user.lastName}`, diff --git a/packages/component-user-manager/src/routes/fragmentsUsers/post.js b/packages/component-user-manager/src/routes/fragmentsUsers/post.js index 0d88c22046537c5d3c63bf08c2ac69d7d855e595..fa0b2b2f352cd5a03988d3b537dd86b37cd87370 100644 --- a/packages/component-user-manager/src/routes/fragmentsUsers/post.js +++ b/packages/component-user-manager/src/routes/fragmentsUsers/post.js @@ -92,6 +92,15 @@ module.exports = models => async (req, res) => { collection.save() } + notifications.sendNotifications({ + user, + baseUrl, + fragment, + collection, + reqUser: req.user, + UserModel: models.User, + }) + return res.status(200).json({ ...pick(user, authorKeys), isSubmitting, @@ -128,7 +137,6 @@ module.exports = models => async (req, res) => { collection, user: newUser, UserModel: models.User, - isSubmitting, }) if (!collection.owners.includes(newUser.id)) { diff --git a/packages/component-wizard/src/components/SubmissionWizard.js b/packages/component-wizard/src/components/SubmissionWizard.js index 162de41426cb1e4d6ab014aeac2c8e9f6c632031..76ea5eadbacef57a645c6a36255b53b9c7f1ea55 100644 --- a/packages/component-wizard/src/components/SubmissionWizard.js +++ b/packages/component-wizard/src/components/SubmissionWizard.js @@ -10,7 +10,13 @@ import { DragDropContext } from 'react-dnd' import { Icon, Button } from '@pubsweet/ui' import HTML5Backend from 'react-dnd-html5-backend' import { selectCollection, selectFragment } from 'xpub-selectors' -import { withStateHandlers, compose, toClass, withProps } from 'recompose' +import { + withStateHandlers, + compose, + toClass, + withProps, + withHandlers, +} from 'recompose' import { withModal, @@ -44,9 +50,11 @@ const NewWizard = ({ step, history, prevStep, + isEditMode, isLastStep, isFirstStep, handleSubmit, + getButtonText, journal: { manuscriptTypes = [] }, ...rest }) => ( @@ -76,7 +84,7 @@ const NewWizard = ({ >{`< BACK`}</Button> )} <Button data-test="submission-next" onClick={handleSubmit} primary> - {isLastStep ? `SUBMIT MANUSCRIPT` : `NEXT STEP >`} + {getButtonText()} </Button> </ButtonContainer> </Row> @@ -132,13 +140,22 @@ export default compose( }, ), withProps(setInitialValues), - withProps(({ formValues, formSyncErrors, submitFailed, step }) => ({ + withProps(({ formValues, formSyncErrors, submitFailed, step, location }) => ({ isFirstStep: step === 0, isLastStep: step === wizardSteps.length - 1, filesError: submitFailed && get(formSyncErrors, 'files', ''), authorsError: submitFailed && get(formSyncErrors, 'authors', ''), hasConflicts: get(formValues, 'conflicts.hasConflicts', 'no') === 'yes', + isEditMode: get(location, 'state.editMode', false), })), + withHandlers({ + getButtonText: ({ isLastStep, isEditMode }) => () => { + if (isEditMode && isLastStep) { + return 'SAVE CHANGES' + } + return isLastStep ? `SUBMIT MANUSCRIPT` : `NEXT STEP >` + }, + }), withModal(() => ({ modalComponent: ModalWrapper, })), diff --git a/packages/component-wizard/src/components/utils.js b/packages/component-wizard/src/components/utils.js index 27f14a882079cbb52d613d6f26e4385bc87c0315..eaef4c5aa092b554fae967b24d7755f0f5665ebc 100644 --- a/packages/component-wizard/src/components/utils.js +++ b/packages/component-wizard/src/components/utils.js @@ -82,6 +82,7 @@ export const onSubmit = ( nextStep, showModal, hideModal, + isEditMode, setModalError, autosaveRequest, autosaveSuccess, @@ -93,7 +94,7 @@ export const onSubmit = ( ) => { if (step !== 2) { nextStep() - } else { + } else if (!isEditMode) { showModal({ title: 'By submitting the manuscript you agree to the following statements:', @@ -119,5 +120,7 @@ export const onSubmit = ( }, onCancel: hideModal, }) + } else { + history.goBack() } } diff --git a/packages/components-faraday/src/components/Dashboard/DashboardCard.js b/packages/components-faraday/src/components/Dashboard/DashboardCard.js index 2d6ec271f17846f5956c043627d6a5302b8235cf..d0b8f03d47f45aa1f8a155754152933b970757a5 100644 --- a/packages/components-faraday/src/components/Dashboard/DashboardCard.js +++ b/packages/components-faraday/src/components/Dashboard/DashboardCard.js @@ -66,6 +66,7 @@ const DashboardCard = ({ collectionId={project.id} fragmentId={version.id} modalKey={`decide-${version.id}`} + status={project.status} /> )} {canMakeRecommendation && ( diff --git a/packages/components-faraday/src/components/MakeDecision/Decision.js b/packages/components-faraday/src/components/MakeDecision/Decision.js index cf4279e1615dc66ec08c8deb6cd8fab98ca16207..be2b86e66f15032614dea4d9d832b43823d13bc0 100644 --- a/packages/components-faraday/src/components/MakeDecision/Decision.js +++ b/packages/components-faraday/src/components/MakeDecision/Decision.js @@ -1,17 +1,21 @@ import React from 'react' import { th } from '@pubsweet/ui' +import { connect } from 'react-redux' import styled from 'styled-components' -import { compose, withHandlers, setDisplayName } from 'recompose' +import { actions } from 'pubsweet-client' +import { compose, withHandlers, setDisplayName, withProps } from 'recompose' import { ConfirmationModal, withModal, } from 'pubsweet-component-modal/src/components' +import { handleError } from '../utils' +import { createRecommendation } from '../../redux/recommendations' import { DecisionForm } from './' -const Decision = ({ showDecisionModal }) => ( - <Root onClick={showDecisionModal}>Make decision</Root> +const Decision = ({ showDecisionModal, buttonText }) => ( + <Root onClick={showDecisionModal}>{buttonText}</Root> ) const ModalComponent = ({ type, ...rest }) => { @@ -28,19 +32,55 @@ export default compose( withModal(() => ({ modalComponent: ModalComponent, })), + connect(null, { + createRecommendation, + getFragments: actions.getFragments, + getCollections: actions.getCollections, + }), + withProps(({ status }) => ({ + buttonText: status === 'submitted' ? 'Reject' : 'Make Decision', + })), withHandlers({ showDecisionModal: ({ + status, showModal, hideModal, fragmentId, collectionId, + getFragments, + setModalError, + getCollections, + createRecommendation, }) => () => { - showModal({ - type: 'decision', - hideModal, - fragmentId, - collectionId, - }) + status !== 'submitted' + ? showModal({ + type: 'decision', + hideModal, + fragmentId, + collectionId, + }) + : showModal({ + hideModal, + fragmentId, + collectionId, + title: 'Reject Manuscript?', + confirmText: 'Reject', + onConfirm: () => { + const recommendation = { + recommendation: 'reject', + recommendationType: 'editorRecommendation', + } + createRecommendation( + collectionId, + fragmentId, + recommendation, + ).then(() => { + getCollections() + getFragments() + hideModal() + }, handleError(setModalError)) + }, + }) }, }), )(Decision) diff --git a/packages/components-faraday/src/components/MakeDecision/DecisionForm.js b/packages/components-faraday/src/components/MakeDecision/DecisionForm.js index 0a53c9423317ac06571ebf6470b6ee16b957fb0f..3fab58f21c15bcdd312cc0fa0f8df64ef928350e 100644 --- a/packages/components-faraday/src/components/MakeDecision/DecisionForm.js +++ b/packages/components-faraday/src/components/MakeDecision/DecisionForm.js @@ -27,7 +27,6 @@ const { const Form = RootContainer.withComponent(FormContainer) const DecisionForm = ({ - aHERec, decision, hideModal, handleSubmit, diff --git a/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js b/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js index fb7452baf0ba87d8e6d3edb530511df05a9a4bfa..6ad740fde0799055cbc4a8684935b0e37e8b2511 100644 --- a/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js +++ b/packages/components-faraday/src/components/MakeRecommendation/RecommendWizard.js @@ -9,7 +9,10 @@ import { getFormValues, reset as resetForm } from 'redux-form' import { FormItems } from '../UIComponents' import { StepOne, StepTwo, utils } from './' -import { createRecommendation } from '../../redux/recommendations' +import { + createRecommendation, + selectReviewRecommendations, +} from '../../redux/recommendations' const RecommendWizard = ({ step, @@ -40,8 +43,9 @@ const RecommendWizard = ({ export default compose( connect( - state => ({ + (state, { fragmentId }) => ({ decision: get(getFormValues('recommendation')(state), 'decision'), + reviews: selectReviewRecommendations(state, fragmentId), }), { resetForm, diff --git a/packages/components-faraday/src/components/MakeRecommendation/Recommendation.js b/packages/components-faraday/src/components/MakeRecommendation/Recommendation.js index 10d7fc6d98a6a1f1ce932aa5b6622015eee59530..dff35cbd4f9388814ac47cb005a418462845e9a5 100644 --- a/packages/components-faraday/src/components/MakeRecommendation/Recommendation.js +++ b/packages/components-faraday/src/components/MakeRecommendation/Recommendation.js @@ -30,12 +30,14 @@ export default compose( })), withHandlers({ showFirstStep: ({ + status, showModal, hideModal, fragmentId, collectionId, }) => () => { showModal({ + status, hideModal, fragmentId, collectionId, diff --git a/packages/components-faraday/src/components/MakeRecommendation/StepOne.js b/packages/components-faraday/src/components/MakeRecommendation/StepOne.js index 6185a09a1677c060032875ce80ebf83c4e1139be..1cb644c274d3bf7732c58e245090397fa33f2da6 100644 --- a/packages/components-faraday/src/components/MakeRecommendation/StepOne.js +++ b/packages/components-faraday/src/components/MakeRecommendation/StepOne.js @@ -1,5 +1,6 @@ import React from 'react' import { reduxForm } from 'redux-form' +import { isEmpty } from 'lodash' import { RadioGroup, ValidatedField, Button } from '@pubsweet/ui' import { utils } from './' @@ -7,7 +8,7 @@ import { FormItems } from '../UIComponents' const { Row, Title, RowItem, RootContainer, CustomRadioGroup } = FormItems -const StepOne = ({ hideModal, disabled, onSubmit }) => ( +const StepOne = ({ hideModal, disabled, onSubmit, reviews }) => ( <RootContainer> <Title>Recommendation for Next Phase</Title> <Row> @@ -20,7 +21,11 @@ const StepOne = ({ hideModal, disabled, onSubmit }) => ( > <RadioGroup name="decision" - options={utils.recommendationOptions} + options={ + !isEmpty(reviews) + ? utils.recommendationOptions + : utils.recommendationOptions.slice(1) + } {...input} /> </CustomRadioGroup> diff --git a/packages/components-faraday/src/components/MakeRecommendation/utils.js b/packages/components-faraday/src/components/MakeRecommendation/utils.js index 2884cdb904717cfdd4958cc99accf9f852adc489..69e5b63f12e4b979dba34332a98fa8bec20eed17 100644 --- a/packages/components-faraday/src/components/MakeRecommendation/utils.js +++ b/packages/components-faraday/src/components/MakeRecommendation/utils.js @@ -1,8 +1,8 @@ import { omit } from 'lodash' export const recommendationOptions = [ - { value: 'reject', label: 'Reject' }, { value: 'publish', label: 'Publish' }, + { value: 'reject', label: 'Reject' }, { value: 'revise', label: 'Request revision' }, ] diff --git a/packages/components-faraday/src/components/SignUp/utils.js b/packages/components-faraday/src/components/SignUp/utils.js index ae98c93c7df0b2d169affe222f81c2c79f412311..c5564bfe044faa2d7903253efb4ac688082567d5 100644 --- a/packages/components-faraday/src/components/SignUp/utils.js +++ b/packages/components-faraday/src/components/SignUp/utils.js @@ -15,6 +15,7 @@ const generatePasswordHash = () => export const parseSignupAuthor = ({ token, confirmPassword, ...values }) => ({ ...values, admin: false, + isActive: true, isConfirmed: false, editorInChief: false, handlingEditor: false, diff --git a/packages/components-faraday/src/components/UIComponents/EQADecisionPage.js b/packages/components-faraday/src/components/UIComponents/EQADecisionPage.js new file mode 100644 index 0000000000000000000000000000000000000000..c1fb89f349ec68b04e977988e1e0bfea9a5fda79 --- /dev/null +++ b/packages/components-faraday/src/components/UIComponents/EQADecisionPage.js @@ -0,0 +1,231 @@ +import React from 'react' +import { isEmpty } from 'lodash' +import { connect } from 'react-redux' +import { Button } from '@pubsweet/ui' +import styled from 'styled-components' +import { th } from '@pubsweet/ui-toolkit' +import { + compose, + withState, + lifecycle, + withHandlers, + setDisplayName, +} from 'recompose' + +import { + withModal, + ConfirmationModal, +} from 'pubsweet-component-modal/src/components' + +import { Err, Subtitle } from './FormItems' +import { parseSearchParams } from '../utils' +import { + technicalDecision, + technicalCheckFetching, +} from '../../redux/technicalCheck' + +const EQADecisionPage = ({ + params, + showEQAModal, + errorMessage, + successMessage, +}) => ( + <Root> + <Title> + Take a decision for manuscript <b>{params.customId}</b>. + </Title> + {errorMessage && <Err>{errorMessage}</Err>} + {successMessage && <Subtitle>{successMessage}</Subtitle>} + {isEmpty(errorMessage) && + isEmpty(successMessage) && ( + <ButtonContainer> + <Button onClick={showEQAModal(false)}>RETURN TO EiC</Button> + <Button onClick={showEQAModal(true)} primary> + ACCEPT + </Button> + </ButtonContainer> + )} + </Root> +) + +const DeclineModal = compose( + withState('reason', 'setReason', ''), + withHandlers({ + changeReason: ({ setReason }) => e => { + setReason(e.target.value) + }, + }), +)(({ reason, changeReason, hideModal, onConfirm, modalError }) => ( + <DeclineRoot> + <span>Return Manuscript to Editor in Chief</span> + <textarea + onChange={changeReason} + placeholder="Return reason*" + value={reason} + /> + {modalError && <ErrorMessage>{modalError}</ErrorMessage>} + <ButtonContainer data-test="eqa-buttons"> + <Button onClick={hideModal}>Cancel</Button> + <Button disabled={!reason} onClick={onConfirm(reason)} primary> + Send + </Button> + </ButtonContainer> + </DeclineRoot> +)) + +const ModalComponent = ({ type, ...rest }) => + type === 'decline' ? ( + <DeclineModal {...rest} /> + ) : ( + <ConfirmationModal {...rest} /> + ) + +export default compose( + setDisplayName('EQA Decision page'), + connect( + state => ({ + isFetching: technicalCheckFetching(state), + }), + { technicalDecision }, + ), + withModal(({ isFetching }) => ({ + isFetching, + modalComponent: ModalComponent, + })), + withState('params', 'setParams', { + token: null, + customId: null, + collectionId: null, + }), + withState('successMessage', 'setSuccess', ''), + lifecycle({ + componentDidMount() { + const { location, setParams } = this.props + const { customId, collectionId, token } = parseSearchParams( + location.search, + ) + setParams({ customId, collectionId, token }) + }, + }), + withHandlers({ + showEQAModal: ({ + showModal, + hideModal, + setSuccess, + setModalError, + technicalDecision, + params: { collectionId, token }, + }) => decision => () => { + const acceptConfig = { + title: `Are you sure you want to accept this EQA package?`, + onConfirm: () => { + technicalDecision({ + step: 'eqa', + agree: decision, + collectionId, + token, + }).then(() => { + setSuccess( + `Manuscript accepted. Thank you for your technical check!`, + ) + hideModal() + }, setModalError) + }, + onCancel: hideModal, + } + const declineConfig = { + type: 'decline', + title: 'Return Manuscript to Editor in Chief', + onConfirm: reason => () => { + technicalDecision({ + step: 'eqa', + agree: decision, + comments: reason, + collectionId, + token, + }).then(() => { + setSuccess( + `Manuscript returned with comments. An email has been sent to Editor In Chief. Thank you for your technical check!`, + ) + hideModal() + }, setModalError) + }, + } + + const cfg = decision ? acceptConfig : declineConfig + showModal(cfg) + }, + }), +)(EQADecisionPage) + +// #region styles +const Root = styled.div` + align-items: center; + color: ${th('colorText')}; + display: flex; + flex-direction: column; + justify-content: flex-start; + 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; +` + +const ButtonContainer = styled.div` + align-items: center; + display: flex; + justify-content: space-around; + padding: calc(${th('gridUnit')} / 2); + width: calc(${th('gridUnit')} * 15); +` +const ErrorMessage = styled.div` + color: ${th('colorError')}; + margin: ${th('subGridUnit')}; + text-align: center; +` +const DeclineRoot = styled.div` + align-items: center; + background-color: ${th('backgroundColor')}; + display: flex; + flex-direction: column; + height: calc(${th('gridUnit')} * 13); + justify-content: space-between; + padding: calc(${th('subGridUnit')} * 7); + width: calc(${th('gridUnit')} * 24); + + & span { + color: ${th('colorPrimary')}; + font-size: ${th('fontSizeHeading5')}; + font-family: ${th('fontHeading')}; + margin-bottom: ${th('gridUnit')}; + } + + & textarea { + height: 100%; + padding: calc(${th('subGridUnit')} * 2); + width: 100%; + } + + & textarea:focus, + & textarea:active { + outline: none; + } + + & div { + display: flex; + justify-content: space-evenly; + margin: ${th('gridUnit')} auto 0; + width: 100%; + } +` +// #endregion diff --git a/packages/components-faraday/src/components/UIComponents/EQSDecisionPage.js b/packages/components-faraday/src/components/UIComponents/EQSDecisionPage.js index 6aaa02bb30d2245e08ac88c503cbc111651d7c63..165361b6ff2853f62f96dbb9082870a66ed87781 100644 --- a/packages/components-faraday/src/components/UIComponents/EQSDecisionPage.js +++ b/packages/components-faraday/src/components/UIComponents/EQSDecisionPage.js @@ -21,7 +21,7 @@ import { Err, Subtitle } from './FormItems' import { parseSearchParams } from '../utils' import { technicalDecision, - technicalCheckFetcing, + technicalCheckFetching, } from '../../redux/technicalCheck' const EQSDecisionPage = ({ @@ -52,7 +52,7 @@ export default compose( setDisplayName('EQS Decision page'), connect( state => ({ - isFetching: technicalCheckFetcing(state), + isFetching: technicalCheckFetching(state), }), { technicalDecision }, ), diff --git a/packages/components-faraday/src/components/UIComponents/index.js b/packages/components-faraday/src/components/UIComponents/index.js index a619f97e10cfcf1546e5428b5773ed6e6530e336..ff001b8fb3a18548136a1a88e64816e3cde7a4b8 100644 --- a/packages/components-faraday/src/components/UIComponents/index.js +++ b/packages/components-faraday/src/components/UIComponents/index.js @@ -8,5 +8,6 @@ export { default as InfoPage } from './InfoPage' export { default as ErrorPage } from './ErrorPage' export { default as DateParser } from './DateParser' export { default as EQSDecisionPage } from './EQSDecisionPage' +export { default as EQADecisionPage } from './EQADecisionPage' export { default as ConfirmationPage } from './ConfirmationPage' export { default as BreadcrumbsHeader } from './BreadcrumbsHeader' diff --git a/packages/components-faraday/src/redux/technicalCheck.js b/packages/components-faraday/src/redux/technicalCheck.js index 8cb3a074bf9eed5220f56f7f476a7510cda8dc57..5c8d7b9c2e5125ecfa487aab80cab12234a3c361 100644 --- a/packages/components-faraday/src/redux/technicalCheck.js +++ b/packages/components-faraday/src/redux/technicalCheck.js @@ -22,6 +22,7 @@ export const technicalDecision = ({ step, agree, token, + comments, collectionId, }) => dispatch => { dispatch(decisionRequest()) @@ -29,19 +30,25 @@ export const technicalDecision = ({ step, token, agree, + comments, }).then( r => { dispatch(decisionSuccess()) return r }, err => { - dispatch(decisionError(err)) - throw err + const errorMessage = get( + JSON.parse(err.response), + 'error', + 'Oops! Something went wrong!', + ) + dispatch(decisionError(errorMessage)) + throw errorMessage }, ) } -export const technicalCheckFetcing = state => +export const technicalCheckFetching = state => get(state, 'technicalCheck.fetching', false) export default (state = {}, action = {}) => { diff --git a/packages/xpub-faraday/app/index.html b/packages/xpub-faraday/app/index.html index fe975b733e9074fe352d024bee816dc961bf195d..9dfcd619060052a1bd0709f36fcd48e52c1e0e1a 100644 --- a/packages/xpub-faraday/app/index.html +++ b/packages/xpub-faraday/app/index.html @@ -6,6 +6,6 @@ </head> <body style="margin: 0;"> <div id="root"></div> - <script src="/assets/app.js"></script> + <script type="text/javascript" src="/assets/app.js"></script> </body> </html> diff --git a/packages/xpub-faraday/app/routes.js b/packages/xpub-faraday/app/routes.js index 05e3106ebac869d37c6b1401cf8764def29ab516..e3cb4ba8b9a232317471c34f98ac6f73b3ce92cd 100644 --- a/packages/xpub-faraday/app/routes.js +++ b/packages/xpub-faraday/app/routes.js @@ -16,6 +16,7 @@ import { InfoPage, ErrorPage, EQSDecisionPage, + EQADecisionPage, ConfirmationPage, } from 'pubsweet-components-faraday/src/components/UIComponents/' import { @@ -120,6 +121,7 @@ const Routes = () => ( path="/projects/:project/versions/:version/details" /> <Route component={EQSDecisionPage} exact path="/eqs-decision" /> + <Route component={EQADecisionPage} exact path="/eqa-decision" /> <Route component={ErrorPage} exact path="/error-page" /> <Route component={InfoPage} exact path="/info-page" /> <Route component={NotFound} /> diff --git a/packages/xpub-faraday/config/default.js b/packages/xpub-faraday/config/default.js index 0e8568e0dc11ee83148a032d079c88617345c49a..819278c83b59ebec49ad8628363421503c649948 100644 --- a/packages/xpub-faraday/config/default.js +++ b/packages/xpub-faraday/config/default.js @@ -46,7 +46,7 @@ module.exports = { API_ENDPOINT: '/api', baseUrl: process.env.CLIENT_BASE_URL || 'http://localhost:3000', 'login-redirect': '/', - 'redux-log': process.env.NODE_ENV === 'development', + 'redux-log': process.env.NODE_ENV !== 'production', theme: process.env.PUBSWEET_THEME, }, orcid: { @@ -97,6 +97,9 @@ module.exports = { 'eqs-decision': { url: process.env.PUBSWEET_EQS_DECISION || '/eqs-decision', }, + 'eqa-decision': { + url: process.env.PUBSWEET_EQA_DECISION || '/eqa-decision', + }, unsubscribe: { url: process.env.PUBSWEET_UNSUBSCRIBE_URL || '/unsubscribe', }, @@ -110,9 +113,14 @@ module.exports = { }, }, mailer: { - from: 'faraday@hindawi.com', + from: 'hindawi@thinslices.com', path: `${__dirname}/mailer`, - editorialAssistant: 'hindawi@thinslices.com', + editorialAssistant: 'hindawi+editorial@thinslices.com', + }, + SES: { + accessKey: process.env.AWS_SES_ACCESS_KEY, + secretKey: process.env.AWS_SES_SECRET_KEY, + region: process.env.AWS_SES_REGION, }, publicKeys: ['pubsweet-client', 'authsome', 'validations'], statuses: { diff --git a/packages/xpub-faraday/config/production.js b/packages/xpub-faraday/config/production.js new file mode 100644 index 0000000000000000000000000000000000000000..9950c9b354fa8710a4f043a07ac2b86a3cf6bd2f --- /dev/null +++ b/packages/xpub-faraday/config/production.js @@ -0,0 +1,3 @@ +const defaultConfig = require('xpub-faraday/config/default') + +module.exports = defaultConfig diff --git a/packages/xpub-faraday/config/validations.js b/packages/xpub-faraday/config/validations.js index 4a96afcddc925807a78fa9495b8be0aa53aedec8..25f2501d38bb6ffaa7619b683bcb3825e28e0883 100644 --- a/packages/xpub-faraday/config/validations.js +++ b/packages/xpub-faraday/config/validations.js @@ -13,6 +13,7 @@ module.exports = { handlingEditor: Joi.object(), technicalChecks: Joi.object({ token: Joi.string(), + hasEQA: Joi.boolean(), }), }, fragment: [