diff --git a/.fly/kotahi-server-test.toml b/.fly/kotahi-server-test.toml index d7d389319..eabbcf460 100644 --- a/.fly/kotahi-server-test.toml +++ b/.fly/kotahi-server-test.toml @@ -11,7 +11,7 @@ app = "bash -c 'sh /custom/start.sh && exec yarn coko-server start'" auto_start_machines = true auto_stop_machines = true force_https = true -internal_port = 8_080 +internal_port = 8080 min_machines_running = 0 processes = ["app"] diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml deleted file mode 100644 index 6198cebb0..000000000 --- a/.gitlab-ci.yml +++ /dev/null @@ -1,201 +0,0 @@ -# include: -# - project: 'cokoapps/ci' -# ref: main -# file: 'ci-templates.yml' - -variables: - IMAGE_NAME_DEVELOPMENT: kotahi/kotahi/root-development - CLIENT_PREPRODUCTION: $CI_REGISTRY/kotahi/kotahi/client-preproduction:$CI_COMMIT_REF_NAME - SERVER_PREPRODUCTION: $CI_REGISTRY/kotahi/kotahi/server-preproduction:$CI_COMMIT_REF_NAME - CLIENT_PRODUCTION: cokoapps/kotahi-client - SERVER_PRODUCTION: cokoapps/kotahi-server - DEV_DOCS: $CI_REGISTRY/kotahi/kotahi/devdocs:$CI_COMMIT_REF_NAME - DEPLOY_COMPOSE_FILE: docker-compose.ci.yml - -stages: - - Build development - - Lint & Unit test - - Build production - - Deploy test - - End to end testing - - Tear down test - - Deploy staging - - Release - - Publish - - Deploy production - -build development: - stage: Build development - interruptible: true - image: docker:27-dind - services: - - docker:27-dind - before_script: - - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin - script: - - docker build - --tag $CI_REGISTRY/$IMAGE_NAME_DEVELOPMENT:$CI_COMMIT_REF_NAME.$CI_COMMIT_SHA - -f Dockerfile . - - docker push $CI_REGISTRY/$IMAGE_NAME_DEVELOPMENT:$CI_COMMIT_REF_NAME.$CI_COMMIT_SHA - -lint: - stage: Lint & Unit test - interruptible: true - image: $CI_REGISTRY/$IMAGE_NAME_DEVELOPMENT:$CI_COMMIT_REF_NAME.$CI_COMMIT_SHA - script: - - cd /home/node/app - - yarn coko-lint run - -build preproduction client: - stage: Build production - interruptible: true - image: docker:27 - services: - - docker:27-dind - only: - - staging - - main - before_script: - - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin - script: - - cd packages/client - - docker build - --tag $CLIENT_PREPRODUCTION.$CI_COMMIT_SHA - --tag $CLIENT_PREPRODUCTION.latest - -f Dockerfile-production . - - docker push $CLIENT_PREPRODUCTION.$CI_COMMIT_SHA - - docker push $CLIENT_PREPRODUCTION.latest - -build preproduction server: - stage: Build production - interruptible: true - image: docker:27 - services: - - docker:27-dind - only: - - staging - - main - before_script: - - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin - script: - - cd packages/server - - docker build - --tag $SERVER_PREPRODUCTION.$CI_COMMIT_SHA - --tag $SERVER_PREPRODUCTION.latest - -f Dockerfile-production . - - docker push $SERVER_PREPRODUCTION.$CI_COMMIT_SHA - - docker push $SERVER_PREPRODUCTION.latest - -deploy fly staging: - stage: Deploy staging - interruptible: false - image: cokoapps/fly - only: - - staging - script: - - FLY_API_TOKEN=$FLY_SERVER_TEST_TOKEN flyctl deploy --config .fly/kotahi-server-test.toml --image $SERVER_PREPRODUCTION.$CI_COMMIT_SHA - - FLY_API_TOKEN=$FLY_CLIENT_TEST_TOKEN flyctl deploy --config .fly/kotahi-client-test.toml --image $CLIENT_PREPRODUCTION.$CI_COMMIT_SHA - -deploy fly main: - stage: Deploy staging - interruptible: false - image: cokoapps/fly - only: - - main - script: - - FLY_API_TOKEN=$FLY_SERVER_MAIN_TOKEN flyctl deploy --config .fly/kotahi-server-main.toml --image $SERVER_PREPRODUCTION.$CI_COMMIT_SHA - - FLY_API_TOKEN=$FLY_CLIENT_MAIN_TOKEN flyctl deploy --config .fly/kotahi-client-main.toml --image $CLIENT_PREPRODUCTION.$CI_COMMIT_SHA - -release: - stage: Release - interruptible: false - image: node:20 - when: manual - only: - - main - before_script: - - git remote set-url --push origin "https://gitlab-ci-token:$CI_RELEASE_TOKEN@gitlab.coko.foundation/kotahi/kotahi.git" - - git config user.email "$CI_RELEASE_BOT_EMAIL" - - git config user.name "kotahi release bot" - script: - - node scripts/updatePackageJson.js - - VERSION=$(node -e "const packageJson = require('./package.json'); console.log(packageJson.version);") - - echo VERSION set to $VERSION - - git add -A - - git commit -m "release version $VERSION [skip ci]" - - git show-ref --tags "$VERSION" > /dev/null 2>&1 && git tag -d "$VERSION" || echo "Tag '$VERSION' does not exist." - - git tag $VERSION - - git push origin HEAD:$CI_COMMIT_REF_NAME --verbose - - git push origin $VERSION - - echo RELEASE_VERSION=$VERSION >> version.env - artifacts: - reports: - dotenv: version.env - -production client: - stage: Publish - interruptible: false - image: docker:27 - services: - - docker:27-dind - needs: - - release - only: - - main - before_script: - - '[ -z "$RELEASE_VERSION" ] && exit 1' - - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD - script: - - git fetch origin $CI_COMMIT_REF_NAME - - git reset --hard origin/$CI_COMMIT_REF_NAME - - cd packages/client - - docker build --tag $CLIENT_PRODUCTION:$RELEASE_VERSION -f Dockerfile-production . - - docker push $CLIENT_PRODUCTION:$RELEASE_VERSION - -production server: - stage: Publish - interruptible: false - image: docker:27 - services: - - docker:27-dind - needs: - - release - only: - - main - before_script: - - '[ -z "$RELEASE_VERSION" ] && exit 1' - - docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD - script: - - git fetch origin $CI_COMMIT_REF_NAME - - git reset --hard origin/$CI_COMMIT_REF_NAME - - cd packages/server - - docker build --tag $SERVER_PRODUCTION:$RELEASE_VERSION -f Dockerfile-production . - - docker push $SERVER_PRODUCTION:$RELEASE_VERSION - -build docs: - stage: Publish - interruptible: false - image: docker:27 - services: - - docker:27-dind - needs: - - release - only: - - main - before_script: - - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin - script: - - cd packages/devdocs - - docker build -f Dockerfile-production -t $DEV_DOCS.$CI_COMMIT_SHA . - - docker push $DEV_DOCS.$CI_COMMIT_SHA - -deploy docs: - stage: Deploy production - interruptible: false - image: cokoapps/fly - needs: - - build docs - only: - - main - script: - - FLY_API_TOKEN=$FLY_DEV_DOCS_TOKEN flyctl deploy --config .fly/kotahi-dev-docs.toml --image $DEV_DOCS.$CI_COMMIT_SHA diff --git a/.gitlab/issue_templates/bug-report.md b/.gitlab/issue_templates/bug-report.md index eda0f5447..46d81203e 100644 --- a/.gitlab/issue_templates/bug-report.md +++ b/.gitlab/issue_templates/bug-report.md @@ -25,4 +25,4 @@ - + diff --git a/CHANGES.md b/CHANGES.md index 8cc85d0ea..63ca64b51 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -41,7 +41,7 @@ There's a new (optional) `S3_REGION` environment variable that let's you control If you were previously adding the `pgcrypto` extension to your databases manually, that is no longer necessary. The extension is still necessary, but its addition will happen automatically. -Note that there's a new major version (`2.0.0`) of the pagedjs microservice. If you switch to that, there are a couple of environment variables that you need to change. Check that repo's [changelog](https://gitlab.coko.foundation/cokoapps/pagedjs/-/blob/main/CHANGELOG.md?ref_type=heads) for details. Both versions 1 and 2 will work with kotahi. +Note that there's a new major version (`2.0.0`) of the pagedjs microservice. If you switch to that, there are a couple of environment variables that you need to change. Check that repo's [changelog](https://github.com/Coko-Foundation/pagedjs-microservice/blob/main/CHANGELOG.md) for details. Both versions 1 and 2 will work with kotahi. ### Version 3.8.0 @@ -49,7 +49,7 @@ We've changed how translation overrides work a little. Instead of mounting files ### Version 3.7.0 -Client and server are now two separate containers in production. This means that your main url (eg. myapp.com) should now point to the client deployment, not the server. You can see the [production compose file](https://gitlab.coko.foundation/kotahi/kotahi/-/blob/3.7.0/docker-compose.production.yml?ref_type=tags) for reference. Both client and server need to have separate, publicly accessible urls. +Client and server are now two separate containers in production. This means that your main url (eg. myapp.com) should now point to the client deployment, not the server. You can see the [production compose file](https://github.com/eLifePathways/Kotahi/blob/3.7.0/docker-compose.production.yml?ref_type=tags) for reference. Both client and server need to have separate, publicly accessible urls. eg. If on your deployment, you are running the client on `localhost:4000` and the server on `localhost:3000`, in your nginx or equivalent configuration you will need to point eg. myapp.com to `localhost:4000` and server.myapp.com (or whatever new url you want) to `localhost:3000`. @@ -106,7 +106,7 @@ If you have a `translationOverrides.js` file, it must be moved from `config/` fo **Development instances only:** Any dev instances hosted on `localhost` will need to their 'Redirect URIs' changed in ORCID, to be able to log in. Alternately, if your port is 4000, you can just change the `ORCID_CLIENT_ID` in your `.env` file to the one supplied in the `.env.example` file, and no further changes should be required. -To change 'Redirect URIs' in ORCID, follow the [instructions in the FAQ](https://gitlab.coko.foundation/kotahi/kotahi/-/blob/main/FAQ.md#how-do-i-setup-orcid-for-development), and replace any Redirect URIs mentioning "localhost" with equivalent URIs containing `127.0.0.1`. +To change 'Redirect URIs' in ORCID, follow the [instructions in the FAQ](https://github.com/eLifePathways/Kotahi/blob/3.2.0/FAQ.md#how-do-i-setup-orcid-for-development), and replace any Redirect URIs mentioning "localhost" with equivalent URIs containing `127.0.0.1`. #### Renaming a Group Name @@ -133,7 +133,7 @@ Update the group name in the `.env` file. - Replace the 'kotahi' group name with the desired group name. - After updating the `.env` file, redeploy the application to apply the changes. -Note: Ensure that you follow the [instructions in the FAQ](https://gitlab.coko.foundation/kotahi/kotahi/-/blob/main/FAQ.md#instance_groups-and-multitenancy) regarding `INSTANCE_GROUPS` and group specifications. +Note: Ensure that you follow the [instructions in the FAQ](https://github.com/eLifePathways/Kotahi/blob/3.2.0/FAQ.md#instance_groups-and-multitenancy) regarding `INSTANCE_GROUPS` and group specifications. ### Version 3.1.0 @@ -188,7 +188,7 @@ Set `USE_COLAB_BIOPHYSICS_IMPORT=true` in the `.env` file. Without this, imports Previously, all groups using the `colab` archetype performed these imports, despite them being intended for one organization only. -In future we intend to move all imports into plugins, using the import [plugin architecture](https://docs.coko.foundation/s/f961fad5-f903-4561-9d22-b723129edf15). +In future we intend to move all imports into plugins, using the import [plugin architecture](https://github.com/eLifePathways/Kotahi/wiki/Plugins). ### 2023-08-18: Version 2.0.0 @@ -198,9 +198,9 @@ Instances affected: **all**.
`INSTANCE_NAME` environment variable is no longer used (but may be retained in case rollback is required). Instead, the variable `INSTANCE_GROUPS` must be supplied when upgrading Kotahi to version 2.0.0 or later. -`INSTANCE_GROUPS` is a required setting, which determines what multitenanted "groups" should run within a single instance of Kotahi. Typically, when upgrading an existing instance to 2.0.0, `INSTANCE_GROUPS` should specify a single group, though you may subsequently add further groups separated by commas to create new mulitenanted groups, each with their own data, workflow, branding and other settings (see the [FAQ](https://gitlab.coko.foundation/kotahi/kotahi/-/blob/main/FAQ.md#instance_groups-and-multitenancy) for details). +`INSTANCE_GROUPS` is a required setting, which determines what multitenanted "groups" should run within a single instance of Kotahi. Typically, when upgrading an existing instance to 2.0.0, `INSTANCE_GROUPS` should specify a single group, though you may subsequently add further groups separated by commas to create new mulitenanted groups, each with their own data, workflow, branding and other settings (see the [FAQ](https://github.com/eLifePathways/Kotahi/blob/3.2.0/FAQ.md#instance_groups-and-multitenancy) for details). -`INSTANCE_GROUPS` must contain one or more _group specifications_ separated by commas. Each _group specification_ consists of a _group name_ followed by a colon followed by a _group type_, e.g. `ourjournal:aperture`. The _group name_ (before the colon) may only contain lowercase `a`-`z`, `0`-`9` and the `_` character. The _group type_ (after the colon) must be either 'aperture', 'colab', 'elife' or 'ncrc'. (These [_group types_](https://docs.coko.foundation/doc/instance-archetypes-LFnzu7leM7) will be given more descriptive and generic names in the near future.) +`INSTANCE_GROUPS` must contain one or more _group specifications_ separated by commas. Each _group specification_ consists of a _group name_ followed by a colon followed by a _group type_, e.g. `ourjournal:aperture`. The _group name_ (before the colon) may only contain lowercase `a`-`z`, `0`-`9` and the `_` character. The _group type_ (after the colon) must be either 'aperture', 'colab', 'elife' or 'ncrc'. (These _group types_ will be given more descriptive and generic names in the near future.) Typically, to keep URLs to pages unchanged it is recommended that the _group name_ "kotahi" be used: thus, if you had `INSTANCE_NAME=aperture`, you would set `INSTANCE_GROUPS=kotahi:aperture`. @@ -254,7 +254,7 @@ eLife only: To publish fields to Hypothesis in reverse order, so that they appea Fields for publishing are now specified in the form-builder, rather than via `HYPOTHESIS_PUBLISH_FIELDS` in the `.env` file. If `HYPOTHESIS_PUBLISH_FIELDS` is not specified, no action is necessary. Otherwise, to retain your existing publishing options, you should: -1. Determine what fields are selected in `HYPOTHESIS_PUBLISH_FIELDS`. Fields are separated by commas, and may consist of either a field name, or a field name and a tag separated by a colon (e.g. `fieldName:hypothesis tag`). You may also have a special `decision` or `reviews` pseudo-field specified. For full details of these old settings, [see here](https://gitlab.coko.foundation/kotahi/kotahi/-/blob/c51b72ec81e74aab915c46f0bdadc3975cc61bd9/FAQ.md#hypothesis). +1. Determine what fields are selected in `HYPOTHESIS_PUBLISH_FIELDS`. Fields are separated by commas, and may consist of either a field name, or a field name and a tag separated by a colon (e.g. `fieldName:hypothesis tag`). You may also have a special `decision` or `reviews` pseudo-field specified. For full details of these old settings, [see here](https://github.com/eLifePathways/Kotahi/blob/c51b72ec81e74aab915c46f0bdadc3975cc61bd9/FAQ.md#hypothesis). 2. For each ordinary field that was specified in `HYPOTHESIS_PUBLISH_FIELDS`, locate that field in the submission form in the form-builder, and enable "Include when sharing or publishing". 3. If a tag was specified for that field, enter it in the "Hypothes.is tag" text box. 4. If the `decision` pseudo-field was specified, locate the field in your decision form with the internal name "comment": enable "Include when sharing or publishing", and set a hypothes.is tag if one was specified. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9d991e7b1..73e92e031 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,33 +1,33 @@ # CONTRIBUTING -Kotahi is a manuscript submission system, based on the discontinued [xpub-collabra](https://gitlab.coko.foundation/xpub/xpub) project. -It is currently under development by the [Coko Foundation](https://coko.foundation/) and is being built with [PubSweet](https://gitlab.coko.foundation/pubsweet/pubsweet). We welcome people of all kinds to join the community and contribute with knowledge, skills, expertise. Everyone is welcome in our chat room (https://mattermost.coko.foundation/coko/channels/town-square). +Kotahi is a manuscript submission system, based on the discontinued xpub-collabra project. +It is currently under development by [eLife Pathways](https://elifepathways.org/) and is being built with [Coko Server](https://github.com/Coko-Foundation/cokoserver). We welcome people of all kinds to join the community and contribute with knowledge, skills, expertise. Everyone is welcome in our chat room (https://mattermost.coko.foundation/coko/channels/town-square). In order to contribute to Kotahi, you're expected to follow a few sensible guidelines. ## Discuss your contribution before you build -Please let us know about the contribution you plan to make before you start it. Either comment on a relevant existing issue, or open a new [issue](https://gitlab.coko.foundation/kotahi/kotahi/issues) if you can't find an existing one. This helps us avoid duplicating effort and to ensure contributions are likely to be accepted. You can also ask in the chat room (https://mattermost.coko.foundation/coko/channels/kotahi) if you are unsure. +Please let us know about the contribution you plan to make before you start it. Either comment on a relevant existing issue, or open a new [issue](https://github.com/eLifePathways/Kotahi/issues) if you can't find an existing one. This helps us avoid duplicating effort and to ensure contributions are likely to be accepted. You can also ask in the chat room (https://mattermost.coko.foundation/coko/channels/kotahi) if you are unsure. For contributions made as discussions and suggestions, you can at any time open an RFC (request for comments) in our issue tracker. ## Branches -We maintain master as the production branch and tag it with release names. If you wish to contribute to Kotahi then you need to make a branch and then issue a pull request following this procedure: +We maintain main as the production branch and tag it with release names. If you wish to contribute to Kotahi then you need to make a branch and then issue a pull request following this procedure: -- Create a user account on Coko's GitLab: http://gitlab.coko.foundation -- Clone master with `git clone git@gitlab.coko.foundation:kotahi/kotahi.git` -- Create a new branch and work off of that. Please name the branch to sensibly identify which feature you are working on. You can push the branch to GitLab at anytime. +- Create a user account on GitHub: https://github.com +- Clone master with `git clone git@github.com:eLifePathways/Kotahi.git` +- Create a new branch and work off of that. Please name the branch to sensibly identify which feature you are working on (preferably starting with the issue number). You can push the branch to GitHub at anytime. ## Getting your contributions merged This is a two part process, first ask for comments, then ask for the changes to be merged. -To ask for comments, generate a Merge Request (Pull Request) from the GitLab interface but do not assign this request to anyone. You do this from the Gitlab UI on your branch. +To ask for comments, generate a Pull Request from the GitHub interface but do not assign this request to anyone. You do this from the GitHub UI on your branch. Look at the feedback and alter your branch as necessary. -To merge with master - generate a merge request (Pull Request). You do this from the Gitlab UI on your branch. -We encourage feedback and discussion from as many people as possible on Merge Requests! +To merge with master - generate a pull request. You do this from the GitHub UI on your branch. +We encourage feedback and discussion from as many people as possible on Pull Requests! Before merging all PRs must fulfill these three simple rules: @@ -41,6 +41,6 @@ We use conventional commits and verify that commit messages match the pattern, y ## Bug reports, feature requests, support questions -This is all done through GitLab using their native issue tracker -Visit the master issue tracker for Kotahi (https://gitlab.coko.foundation/kotahi/kotahi/issues) +This is all done through GitHub using their native issue tracker +Visit the master issue tracker for Kotahi (https://github.com/eLifePathways/Kotahi/issues) Tag the issue with 'support', 'bug', or 'feature' to identify the nature of your issue diff --git a/FAQ.md b/FAQ.md index fd284e5ac..32f804dfd 100644 --- a/FAQ.md +++ b/FAQ.md @@ -34,7 +34,7 @@ Click **Dashboard** in the upper right. You'll be taken to the login flow. ### How do I publish from Kotahi? -While Kotahi deals with importing, reviewing, editing and preproduction, the final step of publishing to the web (or to print) is relegated to other tools. A wide variety of tools exist for building a static website from structured data; you may wish to use Coko Flax which is built expressly for this task. +While Kotahi deals with importing, reviewing, editing and preproduction, the final step of publishing to the web (or to print) is relegated to other tools. A wide variety of tools exist for building a static website from structured data; you may wish to use Flax which is built expressly for this task. Kotahi provides a GraphQL API for obtaining published article data; see [API](#api) below. @@ -211,7 +211,7 @@ Note that publishing of review fields to hypothes.is is not yet supported. #### Publishing Hypothesis annotations with DocMaps -Alternatively (or as well), you can specify one or more Hypothesis annotations to create for each published manuscript, each containing whatever field or combination of fields you choose, by providing a file `config/journal/docmaps_scheme.json` on your server. This mechanism allows more complex selections of data to be published; furthermore, a [DocMap](https://docmaps.knowledgefutures.org/) will also be created at time of publishing, which can be retrieved using kotahi's public API (see below). An example file, [`config/journal/example_docmaps_scheme.json`](https://gitlab.coko.foundation/kotahi/kotahi/-/blob/main/config/journal/example_docmaps_scheme.json), is supplied. +Alternatively (or as well), you can specify one or more Hypothesis annotations to create for each published manuscript, each containing whatever field or combination of fields you choose, by providing a file `config/journal/docmaps_scheme.json` on your server. This mechanism allows more complex selections of data to be published; furthermore, a [DocMap](https://docmaps.knowledgefutures.org/) will also be created at time of publishing, which can be retrieved using kotahi's public API (see below). An example file, [`config/journal/example_docmaps_scheme.json`](https://github.com/eLifePathways/Kotahi/blob/main/packages/server/config/journal/example_docmaps_scheme.json), is supplied. The `docmaps_scheme.json` file specifies the _actions_ to perform when a manuscript is published, complete with participant information and directives to determine how the outputs are generated. Essentially, its structure is copied into the `actions` node of a full DocMap, expanding any templated values and replacing special directives with generated data. @@ -264,7 +264,7 @@ Kotahi exposes a graphql API for external access. The available queries are: - `unreviewedPreprints(token: String!, groupName: String): [Preprint!]!` returns a list of manuscripts with the `readyToEvaluate` label, for the specified group. `groupName` can be omitted if there is only one active group. - `docmap(externalId: String!, groupName: String): String!` returns a [DocMap](https://docmaps.knowledgefutures.org/) from the specified group, representing the relationship between a given preprint (`externalId` is the preprint's URL) and related artifacts such as evaluations that have been published from Kotahi. See above for how to enable this. `groupName` can be omitted if there is only one active group. -Consult the code [here](https://gitlab.coko.foundation/kotahi/kotahi/blob/main/server/model-manuscript/src/graphql.js) and [here](https://gitlab.coko.foundation/kotahi/kotahi/-/blob/a3f6620a553ec3f8a6c869a75021b211019280fd/server/model-docmap/src/graphql.js) for details, or the graphql playground (typically at http://localhost:4000/graphql, when your dev environment is running). +Consult the code [here](https://github.com/eLifePathways/Kotahi/blob/main/packages/server/api/graphql/manuscript/manuscript.graphql) and [here](https://github.com/eLifePathways/Kotahi/blob/main/packages/server/api/graphql/docmap/docmap.graphql) for details, or the [graphql sandbox](https://studio.apollographql.com/sandbox/explorer), when your dev environment is running. While these queries are publicly exposed and don't need a JWT token, the `unreviewedPreprints` query expects a `token` parameter for authentication; this must match a secret token set in the `KOTAHI_API_TOKENS` environment variable in the `.env` file. Tokens may contain any characters other than commas, and may not start or end with whitespace. Multiple tokens may be stored, separated by commas. We recommend that each token contain a human-readable identifier and a strong random string, e.g.: @@ -332,7 +332,7 @@ To archive a group you no longer wish to have active, stop all containers, remov ### Upgrading from old versions -When upgrading from an earlier version of Kotahi prior to 2.0, it is recommended to specify only a single group, with the _group name_ "kotahi" and the same _group type_ as previously specified for the `INSTANCE_NAME` setting. This will keep existing settings and page URLs unchanged. See [CHANGES.md](https://gitlab.coko.foundation/kotahi/kotahi/-/blob/main/CHANGES.md#2023-07-07) for instructions. You can later change or add groups as you wish. +When upgrading from an earlier version of Kotahi prior to 2.0, it is recommended to specify only a single group, with the _group name_ "kotahi" and the same _group type_ as previously specified for the `INSTANCE_NAME` setting. This will keep existing settings and page URLs unchanged. See [CHANGES.md](https://github.com/eLifePathways/Kotahi/blob/main/CHANGES.md#2023-08-18-version-200) for instructions. You can later change or add groups as you wish. ### Configuring the Manuscripts table for your group diff --git a/LICENSE.md b/LICENSE.md index 9d366b781..0fa78e756 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -8,7 +8,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --- -The UI for chat ([code](https://gitlab.coko.foundation/kotahi/kotahi/tree/main/app/components/component-chat/src)) is based on parts of the [Spectrum application](https://github.com/withspectrum/spectrum). While heavily adapted and customized that part of the code also retains the original BSD-3 license below: +The UI for chat ([code](https://github.com/eLifePathways/Kotahi/tree/main/packages/client/app/components/component-chat/src)) is based on parts of the [Spectrum application](https://github.com/withspectrum/spectrum). While heavily adapted and customized that part of the code also retains the original BSD-3 license below: Copyright 2018 Space Program Inc. diff --git a/README.md b/README.md index 785a722f6..6752eab8a 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Kotahi is currently under development by the [Kotahi Foundation](https://kotahi. ## Bug reporting -To report a bug, [open a GitLab issue](https://gitlab.coko.foundation/kotahi/kotahi/-/issues/new) and use the bug-report template contained in the issue. +To report a bug, [open a GitHub issue](https://github.com/eLifePathways/Kotahi/issues/new) and use the bug type contained in the issue. ## Deploying kotahi @@ -36,8 +36,7 @@ You can find our deployment documentation [here](https://kotahi-dev-docs.fly.dev ## Running kotahi for development -You can find development documentation [here](https://kotahi-dev-docs.fly.dev/docs/development/Getting%20started). -Documentation for underlying coko libraries can be found [here](https://coko-dev-docs.fly.dev/). +You can find development documentation [here](https://kotahi-dev-docs.fly.dev/docs/development/Getting%20started). ## Further info diff --git a/package.json b/package.json index a66e1db42..9ab36cdca 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "Kotahi - open journals", "repository": { "type": "git", - "url": "https://gitlab.coko.foundation/kotahi/kotahi" + "url": "https://github.com/eLifePathways/Kotahi" }, "license": "MIT", "scripts": { diff --git a/packages/client/app/components/component-cms-manager/src/queries.js b/packages/client/app/components/component-cms-manager/src/queries.js index b096432b7..30d9f33cb 100644 --- a/packages/client/app/components/component-cms-manager/src/queries.js +++ b/packages/client/app/components/component-cms-manager/src/queries.js @@ -304,6 +304,12 @@ export const deleteFileMutation = gql` } ` +export const generateNewCoarAuthTokenMutation = gql` + mutation ($name: String!, $groupId: ID!) { + generateNewToken(name: $name, groupId: $groupId) + } +` + export const updateCollectionMutation = gql` mutation ($id: ID!, $input: PublishCollectionInput!) { updateCollection(id: $id, input: $input) { diff --git a/packages/client/app/components/component-config-manager/src/ConfigManagerForm.js b/packages/client/app/components/component-config-manager/src/ConfigManagerForm.js index e3741fe0a..4e7d14560 100644 --- a/packages/client/app/components/component-config-manager/src/ConfigManagerForm.js +++ b/packages/client/app/components/component-config-manager/src/ConfigManagerForm.js @@ -4,7 +4,7 @@ import React, { useMemo, useRef, useState } from 'react' import Form from '@rjsf/core' import { useTranslation } from 'react-i18next' -import styled from 'styled-components' +import styled, { css } from 'styled-components' import { isEqual } from 'lodash' import { grid } from '@coko/client' import { @@ -20,6 +20,7 @@ import { Heading, SectionContent, HiddenTabs, + Alert, } from '../../shared' import { color, space } from '../../../theme' import EmailTemplatesPage from '../../component-email-templates/src/EmailTemplatesPage' @@ -136,6 +137,13 @@ const EmailsTabWrapper = styled(StyledSectionContent)` // TODO Improve on this hardcoded hack to hide the "Publishing" heading. const StyledWrapper = styled.div` + ${p => + p.$showGap && + css` + display: grid; + gap: ${grid(2)}; + `} + /* stylelint-disable-next-line selector-id-pattern */ #form-integrations_publishing > legend:nth-of-type(1) { display: ${p => (p.$hideFirstLegend ? 'none' : 'block')}; @@ -167,14 +175,30 @@ const FieldTemplate = props => { 'form-emailNotifications_emailNotification_advancedSettings_requireTLS', ] + const alertFields = [ + 'form-integrationsAndPublishing_integrations_coarNotify_repoIpAddress', + ] + const hideLabel = suppressedLabels.includes(id) const hideDescription = supressedDescriptions.includes(id) + const showWarning = alertFields.includes(id) + const getFieldName = key => description._owner.key === key // eslint-disable-next-line no-nested-ternary return !showInstanceType ? ( !getFieldName('instanceName') ? ( - + + {showWarning && ( + + )}
{label && showLabel && !hideLabel && !hideDescription && ( @@ -208,6 +232,7 @@ const ConfigManagerForm = ({ config, liveValidate = true, omitExtraData = true, + onRefreshCoarAuthToken, updateConfig, updateConfigStatus, emailTemplates, @@ -250,6 +275,7 @@ const ConfigManagerForm = ({ config, t, logoAndFavicon, + onRefreshCoarAuthToken, submissionOptions, ...emailOptions, }) diff --git a/packages/client/app/components/component-config-manager/src/ConfigManagerPage.js b/packages/client/app/components/component-config-manager/src/ConfigManagerPage.js index 782c1ad1e..6d3ff5192 100644 --- a/packages/client/app/components/component-config-manager/src/ConfigManagerPage.js +++ b/packages/client/app/components/component-config-manager/src/ConfigManagerPage.js @@ -6,6 +6,7 @@ import { UPDATE_CONFIG } from '../../../queries' import { createFileMutation, deleteFileMutation, + generateNewCoarAuthTokenMutation, } from '../../component-cms-manager/src/queries' import getSubmissionForm from './ConfigManager.queries' @@ -59,6 +60,11 @@ const ConfigManagerPage = ({ match, ...props }) => { const [update] = useMutation(UPDATE_CONFIG) const [createFile] = useMutation(createFileMutation) const [deleteFile] = useMutation(deleteFileMutation) + + const [generateNewCoarAuthToken] = useMutation( + generateNewCoarAuthTokenMutation, + ) + const [updateConfigStatus, setUpdateConfigStatus] = useState(null) const { data: metadata, loading: loadingMetadata } = useQuery( @@ -75,6 +81,28 @@ const ConfigManagerPage = ({ match, ...props }) => { fetchPolicy: 'network-only', }) + const handleRefreshCoarAuthToken = async () => { + const { data: coarRefreshData, errors: coarRefreshError } = + await generateNewCoarAuthToken({ + variables: { name: 'coar', groupId: config.groupId }, + errorPolicy: 'all', + }) + + if (coarRefreshError) { + console.error( + 'Error refreshing COAR Notify auth token:', + coarRefreshError, + ) + } + + return { + authToken: coarRefreshData?.generateNewToken, + error: coarRefreshError + ? JSON.stringify(coarRefreshError[0], null, 2) + : undefined, + } + } + if ((loading && !data) || (!metadata && loadingMetadata)) return if (error) return @@ -115,6 +143,7 @@ const ConfigManagerPage = ({ match, ...props }) => { disabled={!data.config.active} emailTemplates={data.emailTemplates} formData={JSON.parse(data.config.formData)} + onRefreshCoarAuthToken={handleRefreshCoarAuthToken} submissionForm={form} updateConfig={updateConfig} updateConfigStatus={updateConfigStatus} diff --git a/packages/client/app/components/component-config-manager/src/ui/CoarAuthToken.js b/packages/client/app/components/component-config-manager/src/ui/CoarAuthToken.js new file mode 100644 index 000000000..13898ce07 --- /dev/null +++ b/packages/client/app/components/component-config-manager/src/ui/CoarAuthToken.js @@ -0,0 +1,77 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import styled from 'styled-components' +import { useTranslation } from 'react-i18next' +import { grid } from '@coko/client' +import { ActionButton, Alert } from '../../../shared' +import { TextField } from '../../../pubsweet' + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${grid(2)}; + width: 100%; +` + +const CoarAuthFieldWrapper = styled.div` + display: flex; + gap: ${grid(2)}; + width: 100%; +` + +const StyledTextField = styled(TextField)` + flex-grow: 1; +` + +const StyledActionButton = styled(ActionButton)` + padding: 0 ${grid(2)}; +` + +const CoarAuthToken = ({ onRefreshCoarAuthToken }) => { + const { t } = useTranslation() + const [token, setToken] = useState('*********') + const [status, setStatus] = useState(null) + const [errorMessage, setErrorMessage] = useState('') + + const handleRefresh = async () => { + setStatus('pending') + + const { error, authToken } = await onRefreshCoarAuthToken() + + if (error) { + setErrorMessage(error) + setStatus('failure') + } else { + setToken(authToken) + setStatus('success') + } + } + + return ( + + {status && !['pending', 'failure'].includes(status) && ( + + )} + {status && status === 'failure' && ( + + )} + + + + {t('configPage.coar.refreshAuthToken')} + + + + ) +} + +CoarAuthToken.propTypes = { + onRefreshCoarAuthToken: PropTypes.func.isRequired, +} + +export default CoarAuthToken diff --git a/packages/client/app/components/component-config-manager/src/ui/schema.js b/packages/client/app/components/component-config-manager/src/ui/schema.js index 6094672f7..3228e8b52 100644 --- a/packages/client/app/components/component-config-manager/src/ui/schema.js +++ b/packages/client/app/components/component-config-manager/src/ui/schema.js @@ -4,9 +4,10 @@ import React from 'react' import BrandIcon from './BrandIcon' import ListSubmissionFields from './ListSubmissionFields' -import { Select } from '../../../pubsweet' -import { ColorPicker } from '../../../shared' +import { Button, Select, TextField } from '../../../pubsweet' +import { ActionButton, ColorPicker } from '../../../shared' import SimpleWaxEditor from '../../../wax-collab/src/SimpleWaxEditor' +import CoarAuthToken from './CoarAuthToken' export const configTabLabels = { general: 'General', @@ -47,6 +48,7 @@ export const generateSchemas = ({ defaultCollaborativeReviewerInvitationTemplate, defaultAuthorProofingInvitationTemplate, defaultAuthorProofingSubmittedTemplate, + onRefreshCoarAuthToken, t, logoAndFavicon, submissionOptions, @@ -388,6 +390,10 @@ export const generateSchemas = ({ const: 'Tasks & Notifications', title: t('configPage.showTabs.Tasks & Notifications'), }, + { + const: 'COAR Notify Metadata', + title: t('configPage.showTabs.COAR Notify Metadata'), + }, ], // enum: [ // 'Team', @@ -901,6 +907,10 @@ export const generateSchemas = ({ type: ['string', 'null'], description: t('configPage.allowedIPs'), }, + authToken: { + type: ['string', 'null'], + description: t('configPage.coar.authToken'), + }, scietyInboxUrl: { type: ['string', 'null'], description: t('configPage.scietyInboxUrl'), @@ -1596,6 +1606,10 @@ export const generateSchemas = ({ const: 'Tasks & Notifications', title: t('configPage.showTabs.Tasks & Notifications'), }, + { + const: 'COAR Notify Metadata', + title: t('configPage.showTabs.COAR Notify Metadata'), + }, ], // enum: [ // 'Team', @@ -2110,6 +2124,10 @@ export const generateSchemas = ({ type: ['string', 'null'], description: t('configPage.allowedIPs'), }, + authToken: { + type: ['string', 'null'], + description: t('configPage.coar.authToken'), + }, scietyInboxUrl: { type: ['string', 'null'], description: t('configPage.scietyInboxUrl'), @@ -2830,6 +2848,7 @@ export const generateSchemas = ({ 'Manuscript text', 'Metadata', 'Tasks & Notifications', + 'COAR Notify Metadata', ], items: { type: 'string', @@ -2858,6 +2877,10 @@ export const generateSchemas = ({ const: 'Tasks & Notifications', title: t('configPage.showTabs.Tasks & Notifications'), }, + { + const: 'COAR Notify Metadata', + title: t('configPage.showTabs.COAR Notify Metadata'), + }, ], // enum: [ // 'Team', @@ -3362,6 +3385,10 @@ export const generateSchemas = ({ type: ['string', 'null'], description: t('configPage.allowedIPs'), }, + authToken: { + type: ['string', 'null'], + description: t('configPage.coar.authToken'), + }, scietyInboxUrl: { type: ['string', 'null'], description: t('configPage.scietyInboxUrl'), @@ -4087,6 +4114,7 @@ export const generateSchemas = ({ 'Manuscript text', 'Metadata', 'Tasks & Notifications', + 'COAR Notify Metadata', ], items: { type: 'string', @@ -4115,6 +4143,10 @@ export const generateSchemas = ({ const: 'Tasks & Notifications', title: t('configPage.showTabs.Tasks & Notifications'), }, + { + const: 'COAR Notify Metadata', + title: t('configPage.showTabs.COAR Notify Metadata'), + }, ], // enum: [ // 'Team', @@ -4619,6 +4651,10 @@ export const generateSchemas = ({ type: ['string', 'null'], description: t('configPage.allowedIPs'), }, + authToken: { + type: ['string', 'null'], + description: t('configPage.coar.authToken'), + }, scietyInboxUrl: { type: ['string', 'null'], description: t('configPage.scietyInboxUrl'), @@ -5188,6 +5224,11 @@ export const generateSchemas = ({ }, coarNotify: { classNames: 'col-md-12 col-md-offset-0', + authToken: { + 'ui:widget': props => ( + + ), + }, }, aiDesignStudio: { classNames: 'col-md-12 col-md-offset-0', diff --git a/packages/client/app/components/component-formbuilder/src/components/FormBuilderPage.js b/packages/client/app/components/component-formbuilder/src/components/FormBuilderPage.js index 0963b4ebc..106cab274 100644 --- a/packages/client/app/components/component-formbuilder/src/components/FormBuilderPage.js +++ b/packages/client/app/components/component-formbuilder/src/components/FormBuilderPage.js @@ -63,6 +63,7 @@ structure { permitPublishing publishingTag aiPrompt + metadataMapping } } ` diff --git a/packages/client/app/components/component-formbuilder/src/components/config/Elements.js b/packages/client/app/components/component-formbuilder/src/components/config/Elements.js index 811f84fe6..f9352cf28 100644 --- a/packages/client/app/components/component-formbuilder/src/components/config/Elements.js +++ b/packages/client/app/components/component-formbuilder/src/components/config/Elements.js @@ -432,6 +432,14 @@ const aiPromptField = { }, } +const metadataMappingField = { + component: 'TextField', + props: { + label: i18next.t('fields.metadataMapping.title'), + description: i18next.t('fields.metadataMapping.description'), + }, +} + /** Most fields have at least these properties. * Components and fields can override these */ const prototypeComponent = category => ({ @@ -447,6 +455,7 @@ const prototypeComponent = category => ({ permitPublishing: permitPublishingField, publishingTag: publishingTagField, aiPrompt: aiPromptField, + metadataMapping: category === 'submission' ? metadataMappingField : null, }) /** All properties from all components must appear in this list, @@ -484,6 +493,7 @@ const propertiesOrder = [ 'permitPublishing', 'publishingTag', 'aiPrompt', + 'metadataMapping', ] /** Component types refer to the React component used to represent the field @@ -551,6 +561,7 @@ const getBaseComponentProperties = category => ({ shortDescription: hiddenfield, publishingTag: hiddenfield, aiPrompt: hiddenfield, + metadataMapping: hiddenfield, }, CheckboxGroup: { label: 'Checkboxes', diff --git a/packages/client/app/components/component-review/src/components/DecisionPage.js b/packages/client/app/components/component-review/src/components/DecisionPage.js index 1d5246fcc..be5956dd4 100644 --- a/packages/client/app/components/component-review/src/components/DecisionPage.js +++ b/packages/client/app/components/component-review/src/components/DecisionPage.js @@ -36,6 +36,7 @@ import { CREATE_TASK_EMAIL_NOTIFICATION_LOGS, DELETE_TASK_NOTIFICATION, GET_BLACKLIST_INFORMATION, + GET_COAR_NOTIFICATIONS_FOR_MANUSCRIPT, REFRESH_ADA_STATUS, UPDATE_SHARED_STATUS_FOR_INVITED_REVIEWER_MUTATION, UPDATE_TASK, @@ -133,6 +134,8 @@ const useChatGpt = gql` let debouncers = {} const DecisionPage = ({ currentUser, match }) => { + const manuscriptId = match.params.version + const { t } = useTranslation() // start of code from submit page to handle possible form changes const client = useApolloClient() @@ -167,7 +170,7 @@ const DecisionPage = ({ currentUser, match }) => { refetch: refetchManuscript, } = useQuery(query, { variables: { - id: match.params.version, + id: manuscriptId, groupId: config.groupId, }, }) @@ -231,6 +234,14 @@ const DecisionPage = ({ currentUser, match }) => { const selectedEmailIsBlacklisted = !!blacklistInfoQuery.data?.getBlacklistInformation?.length + const { + data: coarData, + loading: coarLoading, + refetch: refetchCoar, + } = useQuery(GET_COAR_NOTIFICATIONS_FOR_MANUSCRIPT, { + variables: { manuscriptId }, + }) + const [sendEmailMutation] = useMutation(sendEmail) const [doUpdateManuscript] = useMutation(updateManuscriptMutation) @@ -470,7 +481,7 @@ const DecisionPage = ({ currentUser, match }) => { } = await client.query({ query, variables: { - id: match.params.version, + id: manuscriptId, groupId: config.groupId, }, partialRefetch: true, @@ -527,6 +538,11 @@ const DecisionPage = ({ currentUser, match }) => { return } + const refetchData = async () => { + await refetchManuscript() + await refetchCoar() + } + const updateManuscript = (versionId, manuscriptDelta) => doUpdateManuscript({ variables: { @@ -547,7 +563,7 @@ const DecisionPage = ({ currentUser, match }) => { }, }) - const updateReview = async (reviewId, reviewData, manuscriptId) => { + const updateReview = async (reviewId, reviewData, id) => { doUpdateReview({ variables: { id: reviewId || undefined, @@ -557,7 +573,7 @@ const DecisionPage = ({ currentUser, match }) => { cache.modify({ id: cache.identify({ __typename: 'Manuscript', - id: manuscriptId, + id, }), fields: { /* eslint-disable-next-line default-param-last */ @@ -598,6 +614,8 @@ const DecisionPage = ({ currentUser, match }) => { emailTemplates, } = data + const { coarNotificationsForManuscript } = coarData ?? {} + const currentUserRoles = getRoles(manuscript, currentUser.id) if ( @@ -640,14 +658,14 @@ const DecisionPage = ({ currentUser, match }) => { }, }) - await refetchManuscript() + await refetchData() return response } const handleCreateTeam = async createTeamVariables => { const createTeamResponse = await createTeam(createTeamVariables) - await refetchManuscript() + await refetchData() return createTeamResponse } @@ -684,12 +702,12 @@ const DecisionPage = ({ currentUser, match }) => { const handleCompleteComment = async options => { await completeComment(options) - await refetchManuscript() + await refetchData() } const handlePublishManuscript = async options => { const res = await publishManuscript(options) - await refetchManuscript() + await refetchData() return res } @@ -711,6 +729,7 @@ const DecisionPage = ({ currentUser, match }) => { canHideReviews={config?.controlPanel?.hideReview} channels={channels} chatProps={chatProps} + coarMessages={coarNotificationsForManuscript} createFile={createFile} createTaskEmailNotificationLog={createTaskEmailNotificationLog} createTeam={handleCreateTeam} @@ -727,6 +746,7 @@ const DecisionPage = ({ currentUser, match }) => { form={form} handleChange={handleChange} hideChat={hideAuthorChat && hideDiscussionFromEditorsReviewersAuthors} + isCoarLoading={coarLoading} lockUnlockReview={lockUnlockReview} makeDecision={makeDecision} manuscript={manuscript} @@ -734,7 +754,7 @@ const DecisionPage = ({ currentUser, match }) => { publishManuscript={handlePublishManuscript} queryAI={queryAI} refetch={() => { - refetchManuscript() + refetchData() }} removeAuthor={removeAuthor} removeInvitation={removeInvitation} diff --git a/packages/client/app/components/component-review/src/components/DecisionVersion.js b/packages/client/app/components/component-review/src/components/DecisionVersion.js index 52063ddef..efc5402ce 100644 --- a/packages/client/app/components/component-review/src/components/DecisionVersion.js +++ b/packages/client/app/components/component-review/src/components/DecisionVersion.js @@ -28,6 +28,7 @@ import InviteReviewer from './reviewers/InviteReviewer' import { ConfigContext } from '../../../config/src' import { getActiveTab } from '../../../../shared/manuscriptUtils' import { transformTeamsToLegacy } from '../../../utils' +import CoarMessages from './coar/CoarMessages' const TaskSectionRow = styled(SectionRow)` padding: 12px 0 18px; @@ -47,10 +48,12 @@ const DecisionVersion = ({ roles, decisionForm, form, + coarMessages, currentDecisionData, currentUser, version, versionNumber, + isCoarLoading, isCurrentVersion, parent, updateManuscript, // To handle manuscript editing @@ -639,6 +642,12 @@ const DecisionVersion = ({ } } + const coarMessagesSection = () => ({ + content: , + key: 'coarMessages', + label: t('decisionPage.COAR Notify Metadata'), + }) + let defaultActiveKey switch (config?.controlPanel?.showTabs[0]) { @@ -660,6 +669,9 @@ const DecisionVersion = ({ case 'Tasks & Notifications': defaultActiveKey = `tasks` break + case 'COAR Notify Metadata': + defaultActiveKey = `coarMessages` + break default: defaultActiveKey = `team` break @@ -687,6 +699,8 @@ const DecisionVersion = ({ sections.push(metadataSection()) if (config?.controlPanel?.showTabs.includes('Tasks & Notifications')) sections.push(tasksAndNotificationsSection()) + if (config?.controlPanel?.showTabs.includes('COAR Notify Metadata')) + sections.push(coarMessagesSection()) } return ( diff --git a/packages/client/app/components/component-review/src/components/DecisionVersions.js b/packages/client/app/components/component-review/src/components/DecisionVersions.js index b3e950b6a..cfa201a3c 100644 --- a/packages/client/app/components/component-review/src/components/DecisionVersions.js +++ b/packages/client/app/components/component-review/src/components/DecisionVersions.js @@ -23,9 +23,11 @@ const DecisionVersions = ({ decisionForm, chatProps, channels, + coarMessages, form, handleChange, hideChat, + isCoarLoading, onRefreshAdaStatus, updateManuscript, manuscript, @@ -126,6 +128,7 @@ const DecisionVersions = ({ allUsers={allUsers} assignAuthorForProofing={assignAuthorForProofing} canHideReviews={canHideReviews} + coarMessages={coarMessages} createFile={createFile} createTaskEmailNotificationLog={createTaskEmailNotificationLog} createTeam={createTeam} @@ -140,6 +143,7 @@ const DecisionVersions = ({ externalEmail={externalEmail} form={form} invitations={version.manuscript.invitations || []} + isCoarLoading={isCoarLoading} isCurrentVersion={index === 0} key={version.manuscript.id} lockUnlockReview={lockUnlockReview} diff --git a/packages/client/app/components/component-review/src/components/coar/CoarMessages.js b/packages/client/app/components/component-review/src/components/coar/CoarMessages.js new file mode 100644 index 000000000..33cdaf141 --- /dev/null +++ b/packages/client/app/components/component-review/src/components/coar/CoarMessages.js @@ -0,0 +1,173 @@ +import React from 'react' +import styled from 'styled-components' +import { useTranslation } from 'react-i18next' +import Moment from 'react-moment' +import { grid, th } from '@coko/client' +import { Collapse, Spinner, Tag } from '../../../../shared' + +const Container = styled.div` + align-items: center; + display: flex; + flex-direction: column; + gap: ${grid(2)}; + padding: ${grid(4)} 0; +` + +const MessageWrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${grid(1)}; +` + +const CodeWrapper = styled.div` + background-color: #ddd; + border: 1px solid black; + border-radius: ${th('borderRadius')}; + padding: ${grid(2)}; +` + +const patterns = [ + 'Offer', + 'Accept', + 'Reject', + 'TentativeAccept', + 'TentativeReject', + 'Announce', + 'Flag', + 'Undo', +] + +const getPatternType = rawType => { + return Array.isArray(rawType) + ? rawType.find(t => patterns.includes(t)) + : rawType +} + +const getActivityType = rawType => + Array.isArray(rawType) + ? rawType + .find(t => t.startsWith('coar-notify:')) + ?.replace('coar-notify:', '') + : undefined + +const CoarLabel = ({ message }) => { + const { t } = useTranslation() + + const { created, payload } = message + + const { type: rawType } = payload + + const activityType = getActivityType(rawType) + + const type = getPatternType(rawType) + + let color + + if (['Accept'].includes(type)) { + color = 'success' + } else if (['TentativeAccept', 'TentativeReject'].includes(type)) { + color = 'warning' + } else if (['Reject', 'Undo'].includes(type)) { + color = 'error' + } + + return ( +
+ + {t(`decisionPage.coarTab.${type}`)} + {activityType && `: ${t(`decisionPage.coarTab.${activityType}`)}`} + + {created} +
+ ) +} + +const CoarMessage = ({ payload }) => { + const { t } = useTranslation() + + const { actor, object, origin } = payload + + const actorName = actor?.name + + const actorOrcid = actor.id?.match(/\d{4}-\d{4}-\d{4}-\d{3}[0-9X]\b/) ?? '' + + const doi = object['ietf:cite-as']?.includes('doi.org/') + ? object['ietf:cite-as'].split('org/')[1] + : object['ietf:cite-as'] + + const objectUrl = object.id + + const originId = origin.id + + return ( + +
+ {t('decisionPage.coarTab.actor')}: {actorName}{' '} + {actorOrcid && ( + + ORCID ID icon + {/* ORCID ID icon */} + + )} +
+ {doi && ( +
+ DOI: {doi} +
+ )} +
+ {t('decisionPage.coarTab.resourceUrl')}: {objectUrl} +
+
+ {t('decisionPage.coarTab.from')}: {originId} +
+
+ {t('decisionPage.coarTab.rawPayload')}: +
+ +
{JSON.stringify(payload, null, 2)}
+
+
+ ) +} + +const CoarMessages = ({ loading, messages }) => { + const { t } = useTranslation() + + if (loading) { + return + } + + if (!messages.length) { + return {t('decisionPage.coarTab.noMessages')} + } + + const parsedMessages = messages.map(m => ({ + ...m, + payload: JSON.parse(m.payload), + })) + + return ( + + ({ + key: m.id, + label: , + children: , + }))} + /> + + ) +} + +export default CoarMessages diff --git a/packages/client/app/components/shared/Alert.js b/packages/client/app/components/shared/Alert.js new file mode 100644 index 000000000..837d8a94b --- /dev/null +++ b/packages/client/app/components/shared/Alert.js @@ -0,0 +1,33 @@ +import React from 'react' +import { Alert as AntAlert } from 'antd' +import PropTypes from 'prop-types' +import { useTranslation } from 'react-i18next' + +const Alert = ({ description, message, showIcon, type }) => { + const { t } = useTranslation() + + return ( + + ) +} + +Alert.propTypes = { + description: PropTypes.node, + message: PropTypes.node, + showIcon: PropTypes.bool, + type: PropTypes.oneOf(['success', 'info', 'warning', 'error']), +} + +Alert.defaultProps = { + description: null, + message: null, + showIcon: false, + type: 'info', +} + +export default Alert diff --git a/packages/client/app/components/shared/ChatCollapse.js b/packages/client/app/components/shared/ChatCollapse.js new file mode 100644 index 000000000..eb3207bfb --- /dev/null +++ b/packages/client/app/components/shared/ChatCollapse.js @@ -0,0 +1,65 @@ +/* stylelint-disable color-function-notation, hue-degree-notation */ + +import React, { useState } from 'react' +import styled from 'styled-components' + +const CollapseWrapper = styled.div` + border-left: 1px solid #e5e5e5; + display: flex; + height: 100vh; + left: ${props => + props.showCollapse ? `calc(100% - ${props.defaultWidth}px)` : '99%'}; + max-height: 100vh; + position: fixed; + transition: all 300ms; + width: ${props => (props.showCollapse ? `${props.defaultWidth}px` : '0px')}; +` + +const DragIconWrap = styled.div` + align-items: center; + display: flex; + height: 100%; + justify-content: center; + width: 0; +` + +const DragIcon = styled.div` + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 10px; + color: linear-gradient(134deg, #3aae2a, hsl(112.7, 61.1%, 59.6%)); + cursor: pointer; + left: 0; + padding: 10px 20px; + position: relative; + width: max-content; + z-index: 100000000000; +` + +const ChatCollapse = ({ + children, + defaultWidth = 400, + showCollapseData = false, +}) => { + const [showCollapse, setShowCollapse] = useState(showCollapseData) + return children ? ( + + + {showCollapse ? ( + setShowCollapse(!showCollapse)}> + {'>'} + + ) : ( + setShowCollapse(!showCollapse)}> + {'<'} + + )} + + {children} + + ) : ( + '' + ) +} + +export default ChatCollapse diff --git a/packages/client/app/components/shared/Collapse.js b/packages/client/app/components/shared/Collapse.js index 98414c433..f68eb5dd5 100644 --- a/packages/client/app/components/shared/Collapse.js +++ b/packages/client/app/components/shared/Collapse.js @@ -1,65 +1,41 @@ -/* stylelint-disable color-function-notation, hue-degree-notation */ - -import React, { useState } from 'react' +import React from 'react' +import { grid } from '@coko/client' +import { Collapse as AntCollapse } from 'antd' import styled from 'styled-components' +import PropTypes from 'prop-types' -const CollapseWrapper = styled.div` - border-left: 1px solid #e5e5e5; - display: flex; - height: 100vh; - left: ${props => - props.showCollapse ? `calc(100% - ${props.defaultWidth}px)` : '99%'}; - max-height: 100vh; - position: fixed; - transition: all 300ms; - width: ${props => (props.showCollapse ? `${props.defaultWidth}px` : '0px')}; +const StyledCollapse = styled(AntCollapse)` + padding: 0 ${grid(2)}; + width: 100%; ` -const DragIconWrap = styled.div` - align-items: center; - display: flex; - height: 100%; - justify-content: center; - width: 0; -` +const Collapse = ({ accordion, expandIconPosition, items, ...otherProps }) => { + return ( + + ) +} -const DragIcon = styled.div` - background: #fff; - border: 1px solid #e5e5e5; - border-radius: 10px; - color: linear-gradient(134deg, #3aae2a, hsl(112.7, 61.1%, 59.6%)); - cursor: pointer; - left: 0; - padding: 10px 20px; - position: relative; - width: max-content; - z-index: 100000000000; -` +Collapse.propTypes = { + accordion: PropTypes.bool, + expandIconPosition: PropTypes.oneOf(['start', 'end']), + items: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + label: PropTypes.node, + children: PropTypes.node, + }), + ), +} -const Collapse = ({ - children, - defaultWidth = 400, - showCollapseData = false, -}) => { - const [showCollapse, setShowCollapse] = useState(showCollapseData) - return children ? ( - - - {showCollapse ? ( - setShowCollapse(!showCollapse)}> - {'>'} - - ) : ( - setShowCollapse(!showCollapse)}> - {'<'} - - )} - - {children} - - ) : ( - '' - ) +Collapse.defaultProps = { + accordion: false, + expandIconPosition: 'start', + items: [], } export default Collapse diff --git a/packages/client/app/components/shared/Tag.js b/packages/client/app/components/shared/Tag.js new file mode 100644 index 000000000..5de326c8b --- /dev/null +++ b/packages/client/app/components/shared/Tag.js @@ -0,0 +1,44 @@ +import React from 'react' +import { Tag as AntTag } from 'antd' +import PropTypes from 'prop-types' +import { capitalize } from 'lodash' +import { grid, theme } from '@coko/client' + +const Tag = ({ children, color, fontSize }) => { + const colorLabel = color ? `color${capitalize(color)}` : undefined + const fontSizeLabel = `fontSize${capitalize(fontSize)}` + + return ( + + {children} + + ) +} + +Tag.propTypes = { + color: PropTypes.oneOf(['success', 'warning', 'error', undefined]), + fontSize: PropTypes.oneOf([ + 'base', + 'baseSmall', + 'baseSmaller', + 'Heading1', + 'Heading2', + 'Heading3', + 'Heading4', + 'Heading5', + 'Heading6', + ]), +} + +Tag.defaultProps = { + color: undefined, + fontSize: 'baseSmall', +} + +export default Tag diff --git a/packages/client/app/components/shared/VersionSwitcher.js b/packages/client/app/components/shared/VersionSwitcher.js index 4329b3978..91cde4191 100644 --- a/packages/client/app/components/shared/VersionSwitcher.js +++ b/packages/client/app/components/shared/VersionSwitcher.js @@ -7,6 +7,7 @@ import PlainOrRichText from './PlainOrRichText' import { VersionIndicator, VersionLabelWrapper, VersionTitle } from './General' const Container = styled.div` + height: 100%; margin-top: ${grid(2)}; ` diff --git a/packages/client/app/components/shared/index.js b/packages/client/app/components/shared/index.js index 8057b5eea..3fc3ce6f6 100644 --- a/packages/client/app/components/shared/index.js +++ b/packages/client/app/components/shared/index.js @@ -43,3 +43,6 @@ export { default as PlainOrRichText } from './PlainOrRichText' export { default as ColorPicker } from './ColorPicker' export { default as AccessErrorPage } from './AccessErrorPage' export { default as Divider } from './Divider' +export { default as Alert } from './Alert' +export { default as Collapse } from './Collapse' +export { default as Tag } from './Tag' diff --git a/packages/client/app/components/wax-collab/src/CustomWaxToolGroups/JatsSideMenuToolGroupService/Icon.js b/packages/client/app/components/wax-collab/src/CustomWaxToolGroups/JatsSideMenuToolGroupService/Icon.js index 512064e40..644bf937d 100644 --- a/packages/client/app/components/wax-collab/src/CustomWaxToolGroups/JatsSideMenuToolGroupService/Icon.js +++ b/packages/client/app/components/wax-collab/src/CustomWaxToolGroups/JatsSideMenuToolGroupService/Icon.js @@ -4,8 +4,8 @@ import styled from 'styled-components' import { icons } from 'wax-prosemirror-core' import { color } from '../../../../../theme' -// Source: https://gitlab.coko.foundation/wax/wax-prosemirror/-/blob/master/wax-prosemirror-components/src/helpers/Icon.js -// modified to allow more icons, which can be defined like this: https://gitlab.coko.foundation/wax/wax-prosemirror/-/blob/master/wax-prosemirror-components/src/icons/icons.js +// Source: https://github.com/Coko-Foundation/wax-prosemirror/blob/master/wax-prosemirror-core/src/components/icons/Icon.js +// modified to allow more icons, which can be defined like this: https://github.com/Coko-Foundation/wax-prosemirror/blob/master/wax-prosemirror-core/src/components/icons/icons.js const Svg = styled.svg.attrs(() => ({ version: '1.1', diff --git a/packages/client/app/components/wax-collab/src/JatsTags/index.js b/packages/client/app/components/wax-collab/src/JatsTags/index.js index a3643dd44..1476ca255 100644 --- a/packages/client/app/components/wax-collab/src/JatsTags/index.js +++ b/packages/client/app/components/wax-collab/src/JatsTags/index.js @@ -512,7 +512,7 @@ class JatsTagsService extends Service { excludes: 'citationMarks', inclusive: false, // see: https://prosemirror.net/examples/schema/ parseDOM: [ - // but see https://gitlab.coko.foundation/wax/wax-prosemirror/-/blob/master/wax-prosemirror-schema/src/marks/linkMark.js + // but see https://github.com/Coko-Foundation/wax-prosemirror/blob/master/wax-prosemirror-services/src/LinkService/schema/linkMark.js { tag: 'a.doi', getAttrs(dom) { diff --git a/packages/client/app/components/wax-collab/src/JatsTags/removeOrToggleMark.js b/packages/client/app/components/wax-collab/src/JatsTags/removeOrToggleMark.js index 5ea801773..d9e0b1e1c 100644 --- a/packages/client/app/components/wax-collab/src/JatsTags/removeOrToggleMark.js +++ b/packages/client/app/components/wax-collab/src/JatsTags/removeOrToggleMark.js @@ -3,10 +3,10 @@ import { Commands, DocumentHelpers } from 'wax-prosemirror-core' // Sources: // This function: -// https://gitlab.coko.foundation/wax/wax-prosemirror/-/blob/master/wax-prosemirror-core/src/utilities/commands/Commands.js#L29 +// https://github.com/Coko-Foundation/wax-prosemirror/blob/master/wax-prosemirror-core/src/utilities/commands/Commands.js#L30 // (discussed and based on what's here: hhttps://discuss.prosemirror.net/t/expanding-the-selection-to-the-active-mark/478) // tells you if a given mark is active. If that is the case, we can use this function: -// https://gitlab.coko.foundation/wax/wax-prosemirror/-/blob/master/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js#L161 +// https://github.com/Coko-Foundation/wax-prosemirror/blob/master/wax-prosemirror-core/src/utilities/document/DocumentHelpers.js#L163 // to expand the current text selection to the outside mark boundary. const removeOrToggleMark = (state, dispatch, markName) => { diff --git a/packages/client/app/i18n/en/translation.js b/packages/client/app/i18n/en/translation.js index a50f6a4b1..34d482996 100644 --- a/packages/client/app/i18n/en/translation.js +++ b/packages/client/app/i18n/en/translation.js @@ -96,6 +96,12 @@ const en = { day: 'day', day_plural: 'days', }, + statuses: { + success: 'Success', + info: 'Information', + warning: 'Warning', + error: 'Error', + }, }, leftMenu: { 'Summary Info': 'Summary Info', @@ -248,6 +254,7 @@ const en = { 'read-only': ' (read-only)', Metadata: 'Metadata', 'Tasks & Notifications': 'Tasks & Notifications', + 'COAR Notify Metadata': 'COAR Notify Metadata', 'Assign Editors': 'Assign Editors', 'Reviewer Status': 'Reviewer Status', authorStatus: 'Author Status', @@ -334,6 +341,26 @@ const en = { 'Delete this author': 'Delete this author', invalidDoi: 'DOI is invalid', unauthorized: 'This resource is not accessible.', + coarTab: { + noMessages: 'There are no COAR Notify messages', + Offer: 'Offer', + Accept: 'Accept', + Reject: 'Reject', + TentativeAccept: 'Tentatively Accept', + TentativeReject: 'Tentatively Reject', + Announce: 'Announce', + Flag: 'Flag', + Undo: 'Undo', + EndorsementAction: 'Endorsement', + ReviewAction: 'Review', + RelationshipAction: 'Relationship', + UnprocessableNotification: 'Unprocessable Notification', + IngestAction: 'Ingest', + actor: 'Actor', + resourceUrl: 'Resource URL', + from: 'From', + rawPayload: 'Raw Payload', + }, }, editorSection: { noFileLoaded: 'No manuscript file loaded', @@ -682,6 +709,7 @@ const en = { 'Manuscript text': 'Manuscript text', Metadata: 'Metadata', 'Tasks & Notifications': 'Tasks & Notifications', + 'COAR Notify Metadata': 'COAR Notify Metadata', }, crossrefRetrievalEmail: 'Email to use for citation search', crossrefSearchResultCount: @@ -755,6 +783,17 @@ const en = { Wiley: 'Wiley', 'Wolters Kluwer': 'Wolters Kluwer', }, + coar: { + authToken: 'COAR Notify auth token', + refreshAuthToken: 'Refresh auth token', + copyMessage: 'Auth token is only shown once!', + copyDescription: + 'Please copy the auth token now, and store it in your third-party COAR Notify system. You can always refresh the token here, but previous tokens will be invalidated.', + }, + warnings: { + 'form-integrationsAndPublishing_integrations_coarNotify_repoIpAddress': + 'List of IPs has been deprecated, and will be removed soon. Please use the "COAR Notify auth token" option below to authenitcate.', + }, }, notificationPage: { title: 'Notification Events', @@ -1313,7 +1352,6 @@ const en = { 'Field hideFromAuthors': 'Hide from authors?', 'Field permitPublishing': 'Include when sharing or publishing?', 'Field publishingTag': 'Hypothesis tag', - 'Field aiPrompt': 'AI prompt', 'FieldDescription publishingTag': 'You may specify a tag to use when sharing this field as a Hypothesis annotation.', 'Label to display': 'Label to display', @@ -1340,6 +1378,8 @@ const en = { 'Field inline': 'Field inline', 'Field sectioncss': 'Field sectioncss', 'Field isReadOnly': 'Field is read only?', + 'Field aiPrompt': 'AI prompt', + 'Field metadataMapping': 'Metadata Mapping', 'Please give the form a name.': 'Please give the form a name.', 'Give the form a title': 'Give the form a title', 'Edit form settings': 'Edit form settings', @@ -1486,6 +1526,11 @@ const en = { true: 'Yes', false: 'No', }, + metadataMapping: { + description: + 'Automatically map this field to an imported value from a 3rd party source', + title: 'Metadata Mapping', + }, }, }, } diff --git a/packages/client/app/i18n/es-la/translation.js b/packages/client/app/i18n/es-la/translation.js index 4cf755b38..71b07344d 100644 --- a/packages/client/app/i18n/es-la/translation.js +++ b/packages/client/app/i18n/es-la/translation.js @@ -97,6 +97,12 @@ const esLa = { day: 'día', day_plural: 'días', }, + statuses: { + success: 'Éxito', + info: 'Información', + warning: 'Advertencia', + error: 'Error', + }, }, leftMenu: { 'Summary Info': 'Información Resumen', @@ -249,6 +255,7 @@ const esLa = { 'Manuscript text': 'Texto del Manuscrito', Metadata: 'Metadatos', 'Tasks & Notifications': 'Tareas y Notificaciones', + 'COAR Notify Metadata': 'Metadatos de COAR Notify', 'Assign Editors': 'Asignar Editores', 'Reviewer Status': 'Estado del Revisor', authorStatus: 'Estado del autor', @@ -338,6 +345,26 @@ const esLa = { 'Delete this author': 'Eliminar este autor', invalidDoi: 'El DOI es inválido', unauthorized: 'Este recurso no es accesible.', + coarTab: { + noMessages: 'No hay mensajes de COAR Notify', + Offer: 'Ofrecer', + Accept: 'Aceptar', + Reject: 'Rechazar', + TentativeAccept: 'Aceptar provisionalmente', + TentativeReject: 'Rechazar provisionalmente', + Announce: 'Anunciar', + Flag: 'Marcar', + Undo: 'Deshacer', + EndorsementAction: 'Respaldo', + ReviewAction: 'Revisión', + RelationshipAction: 'Relación', + UnprocessableNotification: 'Notificación no procesable', + IngestAction: 'Incorporar', + actor: 'Actor', + resourceUrl: 'URL del recurso', + from: 'De', + rawPayload: 'Carga útil sin procesar', + }, }, editorSection: { noFileLoaded: 'No se ha cargado ningún archivo de manuscrito', @@ -698,6 +725,7 @@ const esLa = { 'Manuscript text': 'Texto del Manuscrito', Metadata: 'Metadatos', 'Tasks & Notifications': 'Tareas y Notificaciones', + 'COAR Notify Metadata': 'Metadatos de COAR Notify', }, crossrefRetrievalEmail: 'Correo electrónico para búsqueda de citas', crossrefSearchResultCount: @@ -770,6 +798,17 @@ const esLa = { Wiley: 'Wiley', 'Wolters Kluwer': 'Wolters Kluwer', }, + coar: { + authToken: 'Token de autenticación de COAR Notify', + refreshAuthToken: 'Actualizar token de autenticación', + copyMessage: '¡El token de autenticación solo se muestra una vez!', + copyDescription: + 'Por favor copie el token de autenticación ahora y guárdelo en su sistema COAR Notify de terceros. Siempre puede actualizar el token aquí, pero los tokens anteriores quedarán invalidados.', + }, + warnings: { + 'form-integrationsAndPublishing_integrations_coarNotify_repoIpAddress': + 'La lista de direcciones IP ha sido descontinuada y pronto será eliminada. Por favor, utiliza la opción "Token de autenticación de COAR Notify" a continuación para autenticarte.', + }, }, notificationPage: { title: 'Eventos de Notificación', @@ -1369,6 +1408,8 @@ const esLa = { 'Field inline': 'Campo en línea', 'Field sectioncss': 'Campo sectioncss', 'Field isReadOnly': '¿El campo es de solo lectura?', + 'Field aiPrompt': 'Prompt de IA', + 'Field metadataMapping': 'Mapeo de Metadatos', 'Please give the form a name.': 'Por favor, asigna un nombre al formulario.', 'Give the form a title': 'Asigna un título al formulario', @@ -1508,6 +1549,19 @@ const esLa = { true: 'Sí', false: 'No', }, + allowFutureDatesOnly: { + true: 'Sí', + false: 'No', + }, + embargo: { + true: 'Sí', + false: 'No', + }, + metadataMapping: { + description: + 'Mapear automáticamente este campo a un valor importado desde una fuente de terceros', + title: 'Mapeo de Metadatos', + }, }, }, } diff --git a/packages/client/app/i18n/fr/translation.js b/packages/client/app/i18n/fr/translation.js index dc174427c..2f20f9629 100644 --- a/packages/client/app/i18n/fr/translation.js +++ b/packages/client/app/i18n/fr/translation.js @@ -96,6 +96,12 @@ const fr = { day: 'jour', day_plural: 'jours', }, + statuses: { + success: 'Succès', + info: 'Information', + warning: 'Avertissement', + error: 'Erreur', + }, }, leftMenu: { 'Summary Info': 'Résumé des infos', @@ -250,6 +256,7 @@ const fr = { 'Manuscript text': 'Texte du manuscrit', Metadata: 'Métadonnées', 'Tasks & Notifications': 'Tâches et notifications', + 'COAR Notify Metadata': 'Métadonnées COAR Notify', 'Assign Editors': 'Attribuer des éditeurs', 'Reviewer Status': 'Statut du réviseur', authorStatus: "Statut d'auteur", @@ -340,6 +347,26 @@ const fr = { 'Delete this author': 'Supprimer cet auteur', invalidDoi: 'Le DOI est invalide', unauthorized: "Cette ressource n'est pas accessible.", + coarTab: { + noMessages: "Il n'y a pas de messages COAR Notify", + Offer: 'Offrir', + Accept: 'Accepter', + Reject: 'Rejeter', + TentativeAccept: 'Accepter provisoirement', + TentativeReject: 'Rejeter provisoirement', + Announce: 'Annoncer', + Flag: 'Signaler', + Undo: 'Annuler', + EndorsementAction: 'Approbation', + ReviewAction: 'Révision', + RelationshipAction: 'Relation', + UnprocessableNotification: 'Notification non traitable', + IngestAction: 'Importer', + actor: 'Acteur', + resourceUrl: 'URL de la ressource', + from: 'De', + rawPayload: 'Charge utile brute', + }, }, editorSection: { noFileLoaded: 'Aucun fichier de manuscrit chargé', @@ -705,6 +732,7 @@ const fr = { 'Manuscript text': 'Texte du manuscrit', Metadata: 'Métadonnées', 'Tasks & Notifications': 'Tâches et notifications', + 'COAR Notify Metadata': 'Métadonnées COAR Notify', }, crossrefRetrievalEmail: 'E-mail à utiliser pour la recherche de citations', @@ -781,6 +809,18 @@ const fr = { Wiley: 'Wiley', 'Wolters Kluwer': 'Wolters Kluwer', }, + coar: { + authToken: "Jeton d'authentification COAR Notify", + refreshAuthToken: "Actualiser le jeton d'authentification", + copyMessage: + "Le jeton d'authentification n'est affiché qu'une seule fois !", + copyDescription: + "Veuillez copier le jeton d'authentification maintenant et le stocker dans votre système COAR Notify tiers. Vous pouvez toujours actualiser le jeton ici, mais les jetons précédents seront invalidés.", + }, + warnings: { + 'form-integrationsAndPublishing_integrations_coarNotify_repoIpAddress': + "La liste des adresses IP est obsolète et sera bientôt supprimée. Veuillez utiliser l'option « Jeton d'authentification COAR Notify » ci-dessous pour vous authentifier.", + }, }, notificationPage: { title: 'Événements de Notification', @@ -1384,6 +1424,8 @@ const fr = { 'Field inline': 'Champ en ligne', 'Field sectioncss': 'CSS de la section du champ', 'Field isReadOnly': 'Le champ est-il en lecture seule ?', + 'Field aiPrompt': 'Invite IA', + 'Field metadataMapping': 'Mappage des métadonnées', 'Please give the form a name.': 'Veuillez donner un nom au formulaire.', 'Give the form a title': 'Donnez un titre au formulaire', 'Edit form settings': 'Modifier les paramètres du formulaire', @@ -1522,6 +1564,19 @@ const fr = { true: 'Oui', false: 'Non', }, + allowFutureDatesOnly: { + true: 'Oui', + false: 'Non', + }, + embargo: { + true: 'Oui', + false: 'Non', + }, + metadataMapping: { + description: + 'Mapper automatiquement ce champ à une valeur importée depuis une source tierce', + title: 'Mappage des métadonnées', + }, }, }, } diff --git a/packages/client/app/i18n/ru/translation.js b/packages/client/app/i18n/ru/translation.js index 718f67b29..985c2968b 100644 --- a/packages/client/app/i18n/ru/translation.js +++ b/packages/client/app/i18n/ru/translation.js @@ -98,6 +98,12 @@ const ru = { day_few: 'дня', day_many: 'дней', }, + statuses: { + success: 'Успех', + info: 'Информация', + warning: 'Предупреждение', + error: 'Ошибка', + }, }, leftMenu: { 'Summary Info': 'Сводная информация', @@ -253,6 +259,7 @@ const ru = { 'Manuscript text': 'Текст статьи', Metadata: 'Основные данные', 'Tasks & Notifications': 'Задачи и уведомления', + 'COAR Notify Metadata': 'Метаданные COAR Notify', 'Assign Editors': 'Назначить редактора', 'Reviewer Status': 'Этап рецензирования', authorStatus: 'Статус автора', @@ -341,6 +348,26 @@ const ru = { 'Delete this author': 'Удалить', invalidDoi: 'DOI недействителен', unauthorized: 'Этот ресурс недоступен.', + coarTab: { + noMessages: 'Нет сообщений COAR Notify', + Offer: 'Предложить', + Accept: 'Принять', + Reject: 'Отклонить', + TentativeAccept: 'Предварительно принять', + TentativeReject: 'Предварительно отклонить', + Announce: 'Объявить', + Flag: 'Пометить', + Undo: 'Отменить', + EndorsementAction: 'Поддержка', + ReviewAction: 'Рецензирование', + RelationshipAction: 'Связь', + UnprocessableNotification: 'Уведомление не может быть обработано', + IngestAction: 'Импортировать', + actor: 'Субъект', + resourceUrl: 'URL ресурса', + from: 'От', + rawPayload: 'Необработанная полезная нагрузка', + }, }, editorSection: { noFileLoaded: 'Файл рукописи не загружен', @@ -694,6 +721,7 @@ const ru = { 'Manuscript text': 'Текст статьи', Metadata: 'Основные данные', 'Tasks & Notifications': 'Задачи и уведомления', + 'COAR Notify Metadata': 'Метаданные COAR Notify', }, crossrefRetrievalEmail: 'Адрес электронной почты, который будет использоваться для поиска цитат', @@ -767,6 +795,17 @@ const ru = { Wiley: 'Wiley', 'Wolters Kluwer': 'Wolters Kluwer', }, + coar: { + authToken: 'Токен аутентификации COAR Notify', + refreshAuthToken: 'Обновить токен аутентификации', + copyMessage: 'Токен аутентификации отображается только один раз!', + copyDescription: + 'Пожалуйста, скопируйте токен аутентификации сейчас и сохраните его в вашей сторонней системе COAR Notify. Вы всегда можете обновить токен здесь, но предыдущие токены будут аннулированы.', + }, + warnings: { + 'form-integrationsAndPublishing_integrations_coarNotify_repoIpAddress': + 'Список IP-адресов устарел и скоро будет удалён. Пожалуйста, используйте опцию «Токен аутентификации COAR Notify» ниже для аутентификации.', + }, }, notificationPage: { title: 'События Уведомлений', @@ -1360,6 +1399,8 @@ const ru = { 'Field inline': 'Строчное поле', 'Field sectioncss': 'Дополнительное стили CSS', 'Field isReadOnly': 'Поле только для чтения?', + 'Field aiPrompt': 'Запрос ИИ', + 'Field metadataMapping': 'Сопоставление метаданных', 'Please give the form a name.': 'Пожалуйста, дайте форме имя.', 'Give the form a title': 'Дайте форме заголовок', 'Edit form settings': 'Редактировать настройки формы', @@ -1497,6 +1538,19 @@ const ru = { true: 'Да', false: 'Нет', }, + allowFutureDatesOnly: { + true: 'Да', + false: 'Нет', + }, + embargo: { + true: 'Да', + false: 'Нет', + }, + metadataMapping: { + description: + 'Автоматически сопоставлять это поле со значением, импортированным из стороннего источника', + title: 'Сопоставление метаданных', + }, }, }, } diff --git a/packages/client/app/queries/index.js b/packages/client/app/queries/index.js index f55a247ce..1606bf447 100644 --- a/packages/client/app/queries/index.js +++ b/packages/client/app/queries/index.js @@ -34,6 +34,17 @@ export const GET_BLACKLIST_INFORMATION = gql` } ` +export const GET_COAR_NOTIFICATIONS_FOR_MANUSCRIPT = gql` + query getCoarNotificationsForManuscript($manuscriptId: ID!) { + coarNotificationsForManuscript(manuscriptId: $manuscriptId) { + id + manuscriptId + payload + created + } + } +` + export const GET_EMAIL_INVITED_REVIEWERS = gql` query getEmailInvitedReviewers($manuscriptId: ID!) { getEmailInvitedReviewers(manuscriptId: $manuscriptId) { diff --git a/packages/client/config/sampleConfigFormData.js b/packages/client/config/sampleConfigFormData.js index 051cea266..4a2a63722 100644 --- a/packages/client/config/sampleConfigFormData.js +++ b/packages/client/config/sampleConfigFormData.js @@ -102,6 +102,7 @@ module.exports = { 'Manuscript text', 'Metadata', 'Tasks & Notifications', + 'COAR Notify Metadata', ], hideReview: false, sharedReview: false, diff --git a/packages/client/package.json b/packages/client/package.json index 02821dd33..0eeb74706 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -5,7 +5,7 @@ "description": "Kotahi - open journals", "repository": { "type": "git", - "url": "https://gitlab.coko.foundation/kotahi/kotahi" + "url": "https://github.com/eLifePathways/Kotahi" }, "license": "MIT", "scripts": { @@ -35,6 +35,7 @@ "@fontsource/roboto": "^4.5.8", "@rjsf/core": "4.2.3", "@uiw/react-codemirror": "^4.21.20", + "antd": "^5.6.1", "apollo-upload-client": "14.1.2", "atob": "^2.1.2", "cheerio": "1.0.0-rc.10", diff --git a/packages/client/public/orcid-id-icon.svg b/packages/client/public/orcid-id-icon.svg new file mode 100644 index 000000000..375b457a4 --- /dev/null +++ b/packages/client/public/orcid-id-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/client/stories/chat-collapse/CollapseWrapper.stories.js b/packages/client/stories/chat-collapse/CollapseWrapper.stories.js index 785785a09..36d900ca6 100644 --- a/packages/client/stories/chat-collapse/CollapseWrapper.stories.js +++ b/packages/client/stories/chat-collapse/CollapseWrapper.stories.js @@ -1,16 +1,16 @@ import React from 'react' -import Collapse from '../../app/components/shared/Collapse' +import ChatCollapse from '../../app/components/shared/ChatCollapse' import DesignEmbed from '../common/utils' export const Base = args => ( - +

hello

-
+ ) export default { title: 'Shared/CollapseWrapper', - component: Collapse, + component: ChatCollapse, parameters: { docs: { page: () => ( diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock index f0f592673..1b316554c 100644 --- a/packages/client/yarn.lock +++ b/packages/client/yarn.lock @@ -14678,6 +14678,7 @@ __metadata: "@fontsource/roboto": "npm:^4.5.8" "@rjsf/core": "npm:4.2.3" "@uiw/react-codemirror": "npm:^4.21.20" + antd: "npm:^5.6.1" apollo-upload-client: "npm:14.1.2" atob: "npm:^2.1.2" babel-jest: "npm:23.6.0" diff --git a/packages/devdocs/docs/deployment/Kotahi deployment guide.mdx b/packages/devdocs/docs/deployment/Kotahi deployment guide.mdx index d5fd3f5b8..dc9cddd45 100644 --- a/packages/devdocs/docs/deployment/Kotahi deployment guide.mdx +++ b/packages/devdocs/docs/deployment/Kotahi deployment guide.mdx @@ -296,7 +296,7 @@ To create the credentials, run the following commands: ```bash # Get the xsweet microservice -git clone git@gitlab.coko.foundation:cokoapps/xsweet.git +git clone git@github.com:Coko-Foundation/xsweet-microservice.git cd xsweet # Set up @@ -510,5 +510,4 @@ You can reach out to the kotahi team for questions and help in the following way - Send a chat message on the [Kotahi chat channel](https://mattermost.coko.foundation/coko/channels/kotahi) - If you think you've found a bug, or that these docs are incorrect in some way, -open a gitlab issue in [Kotahi's gitlab repo](https://gitlab.coko.foundation/kotahi/kotahi). -- Ask a question on the [Kotahi community forum](https://forum.kotahi.community/) +open a GitHub issue in [Kotahi's GitHub repo](https://github.com/eLifePathways/Kotahi/). diff --git a/packages/devdocs/docs/development/Getting started.mdx b/packages/devdocs/docs/development/Getting started.mdx index 9122e534d..ae143a8b3 100644 --- a/packages/devdocs/docs/development/Getting started.mdx +++ b/packages/devdocs/docs/development/Getting started.mdx @@ -7,8 +7,7 @@ You'll need the following in order to run the app locally without issues: * `git` It is strongly recommended that you are familiar with docker and docker compose -before diving in. If you're not already, you can read -[our team's guide](https://devdocs.coko.app/docs/general/other-guides/docker). +before diving in. ## Install modules locally @@ -36,9 +35,9 @@ mailinator email address. * Go to [mailinator.com](http://www.mailinator.com). * In the search bar at the top of the page enter your desired username -(we'll use `mycokotestemail` for this guide) and click "GO". (tip: choose a +(we'll use `myelifetestemail` for this guide) and click "GO". (tip: choose a username that is unlikely to be used already by someone else) -* You'll be taken to a new page. This is your inbox for mycokotestemail@mailinator.com. +* You'll be taken to a new page. This is your inbox for myelifetestemail@mailinator.com. Keep this page open. (also keep in mind that this is a fully public inbox) @@ -87,7 +86,7 @@ ORCID_CLIENT_SECRET=your-client-secret ``` :::warning Disclaimer -ORCID is a separate organisation from Coko and we are in no way affiliated with +ORCID is a separate organisation from eLife Pathways and we are in no way affiliated with them. This is meant as a guide to make a developer's life easier. If you encounter issues with ORCID services not working as expected, please contact their support. diff --git a/packages/devdocs/docusaurus.config.js b/packages/devdocs/docusaurus.config.js index 402ac0608..d618e5f2a 100644 --- a/packages/devdocs/docusaurus.config.js +++ b/packages/devdocs/docusaurus.config.js @@ -20,8 +20,8 @@ const config = { // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. - // organizationName: 'Coko', // Usually your GitHub org/user name. - // projectName: 'Coko apps', // Usually your repo name. + // organizationName: 'eLifePathways', // Usually your GitHub org/user name. + // projectName: 'Kotahi', // Usually your repo name. onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', @@ -55,7 +55,7 @@ const config = { navbar: { title: 'Kotahi developer documentation', logo: { - alt: 'Coko foundation', + alt: 'eLife Pathways', src: 'favicon.ico', }, items: [ @@ -72,10 +72,10 @@ const config = { label: 'Deployment', }, { - href: 'https://gitlab.coko.foundation/kotahi/kotahi', + href: 'https://github.com/eLifePathways/Kotahi', position: 'right', - className: 'header-gitlab-link', - 'aria-label': 'Gitlab repository', + className: 'header-github-link', + 'aria-label': 'GitHub repository', }, ], }, diff --git a/packages/devdocs/src/css/custom.css b/packages/devdocs/src/css/custom.css index e7c38714e..9b5656e2d 100644 --- a/packages/devdocs/src/css/custom.css +++ b/packages/devdocs/src/css/custom.css @@ -39,7 +39,7 @@ body { font-size: 7rem; } -.header-gitlab-link::before { +.header-github-link::before { background-color: var(--ifm-navbar-link-color); content: ''; display: flex; @@ -47,7 +47,7 @@ body { transition: background-color var(--ifm-transition-fast) var(--ifm-transition-timing-default); - mask-image: url('../../static/custom/gitlab.svg'); + mask-image: url('../../static/custom/github.svg'); mask-repeat: no-repeat; width: 24px; diff --git a/packages/devdocs/static/custom/github.svg b/packages/devdocs/static/custom/github.svg new file mode 100644 index 000000000..7c3e0e4eb --- /dev/null +++ b/packages/devdocs/static/custom/github.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/packages/server/api/graphql/coar/coar.graphql b/packages/server/api/graphql/coar/coar.graphql new file mode 100644 index 000000000..82a323a97 --- /dev/null +++ b/packages/server/api/graphql/coar/coar.graphql @@ -0,0 +1,10 @@ +extend type Query { + coarNotificationsForManuscript(manuscriptId: ID!): [CoarNotification!]! +} + +type CoarNotification { + id: ID! + manuscriptId: ID! + payload: String! + created: DateTime! +} diff --git a/packages/server/api/graphql/coar/coar.resolvers.js b/packages/server/api/graphql/coar/coar.resolvers.js new file mode 100644 index 000000000..7fb479fd1 --- /dev/null +++ b/packages/server/api/graphql/coar/coar.resolvers.js @@ -0,0 +1,11 @@ +const { + getNotificationsForManuscript, +} = require('../../../controllers/coar/coar.controllers') + +module.exports = { + Query: { + async coarNotificationsForManuscript(_, { manuscriptId }) { + return getNotificationsForManuscript(manuscriptId) + }, + }, +} diff --git a/packages/server/api/graphql/form/form.graphql b/packages/server/api/graphql/form/form.graphql index efa63ee5c..f66834b8f 100644 --- a/packages/server/api/graphql/form/form.graphql +++ b/packages/server/api/graphql/form/form.graphql @@ -60,6 +60,7 @@ input FormElementInput { publishingTag: String aiPrompt: String isReadOnly: String + metadataMapping: String } input FormElementOptionInput { @@ -125,6 +126,7 @@ type FormElement { publishingTag: String aiPrompt: String isReadOnly: String + metadataMapping: String } type FormElementOption { diff --git a/packages/server/api/graphql/index.js b/packages/server/api/graphql/index.js index 903a4f1ed..ae2775e24 100644 --- a/packages/server/api/graphql/index.js +++ b/packages/server/api/graphql/index.js @@ -6,6 +6,7 @@ const anyStyleResolvers = require('./anyStyle/anyStyle.resolvers') const articleTemplatesResolvers = require('./articleTemplates/articleTemplates.resolvers') const channelResolvers = require('./channel/channel.resolvers') const cmsResolvers = require('./cms/cms.resolvers') +const coarResolvers = require('./coar/coar.resolvers') const configResolvers = require('./config/config.resolvers') const docmapResolvers = require('./docmap/docmap.resolvers') const emailTemplateResolvers = require('./emailTemplate/emailTemplate.resolvers') @@ -34,6 +35,7 @@ const rorResolvers = require('./ror/ror.resolvers') const taskResolvers = require('./task/task.resolvers') const teamResolvers = require('./team/team.resolvers') const threadedDiscussionResolvers = require('./threadedDiscussion/threadedDiscussion.resolvers') +const tokenResolvers = require('./token/token.resolvers') const userResolvers = require('./user/user.resolvers') const xsweetResolvers = require('./xsweet/xsweet.resolvers') @@ -44,6 +46,7 @@ const typeDefFilePaths = [ 'articleTemplates/articleTemplates.graphql', 'channel/channel.graphql', 'cms/cms.graphql', + 'coar/coar.graphql', 'config/config.graphql', 'docmap/docmap.graphql', 'emailTemplate/emailTemplate.graphql', @@ -72,6 +75,7 @@ const typeDefFilePaths = [ 'task/task.graphql', 'team/team.graphql', 'threadedDiscussion/threadedDiscussion.graphql', + 'token/token.graphql', 'user/user.graphql', 'xsweet/xsweet.graphql', ] @@ -90,6 +94,7 @@ const resolvers = merge( articleTemplatesResolvers, channelResolvers, cmsResolvers, + coarResolvers, configResolvers, docmapResolvers, emailTemplateResolvers, @@ -118,6 +123,7 @@ const resolvers = merge( taskResolvers, teamResolvers, threadedDiscussionResolvers, + tokenResolvers, userResolvers, xsweetResolvers, ) diff --git a/packages/server/api/graphql/token/token.graphql b/packages/server/api/graphql/token/token.graphql new file mode 100644 index 000000000..69925cb82 --- /dev/null +++ b/packages/server/api/graphql/token/token.graphql @@ -0,0 +1,3 @@ +extend type Mutation { + generateNewToken(name: String!, groupId: ID!): String! +} diff --git a/packages/server/api/graphql/token/token.resolvers.js b/packages/server/api/graphql/token/token.resolvers.js new file mode 100644 index 000000000..3332e4295 --- /dev/null +++ b/packages/server/api/graphql/token/token.resolvers.js @@ -0,0 +1,9 @@ +const { generateToken } = require('../../../controllers/token.controllers') + +module.exports = { + Mutation: { + async generateNewToken(_, { name, groupId }) { + return generateToken(name, groupId) + }, + }, +} diff --git a/packages/server/api/rest/coar/inbox.js b/packages/server/api/rest/coar/inbox.js index 3e83f100e..d443f2819 100644 --- a/packages/server/api/rest/coar/inbox.js +++ b/packages/server/api/rest/coar/inbox.js @@ -6,6 +6,7 @@ const { processNotification, sendUnprocessableCoarNotification, validateIPs, + validateAuthToken, } = require('../../../controllers/coar/coar.controllers') module.exports = async app => { @@ -13,6 +14,7 @@ module.exports = async app => { const payload = req.body const groupName = req.params.group const requestIP = req.socket.localAddress.split(':').pop() + const authHeader = req.headers.authorization let message = '' let hasError = false @@ -25,6 +27,13 @@ module.exports = async app => { hasError = true } + if (!hasError && !(await validateAuthToken(authHeader, group.id))) { + message = 'Unauthorized Request' + res.status(403).send({ message }) + hasError = true + } + + // TODO: remove if (!hasError && !(await validateIPs(requestIP, group))) { message = 'Unauthorized Request' res.status(403).send({ message }) @@ -50,8 +59,8 @@ module.exports = async app => { } catch (error) { message = 'Failed to create notification.' logger.error(error) - res.status(500).send({ message }) await sendUnprocessableCoarNotification(message, payload) + res.status(500).send({ message }) } }) } diff --git a/packages/server/controllers/coar/announcement.js b/packages/server/controllers/coar/announcement.js index 0e651fe44..4de2e9221 100644 --- a/packages/server/controllers/coar/announcement.js +++ b/packages/server/controllers/coar/announcement.js @@ -2,7 +2,13 @@ const config = require('config') const { clientUrl, serverUrl, request, uuid } = require('@coko/server') -const { Config, Group, Identity, Review } = require('../../models') +const { + Config, + Group, + Identity, + Review, + CoarNotification, +} = require('../../models') const flaxConfig = config['flax-site'] @@ -194,7 +200,9 @@ const makeAnnouncementOnCOAR = async ( options, ) - const inboxUrl = JSON.parse(requestData).target.inbox + const payload = JSON.parse(requestData) + + const inboxUrl = payload.target.inbox const requestLength = requestData.length if (!isReviewDoi() && !isFlaxSetup() && type === 'review') { @@ -212,7 +220,11 @@ const makeAnnouncementOnCOAR = async ( data: requestData, }) - return response ? response.data : false + const { groupId, id: manuscriptId } = manuscript + + await CoarNotification.query().insert({ groupId, manuscriptId, payload }) + + return response?.data || false } catch (err) { console.error(err) throw err diff --git a/packages/server/controllers/coar/coar.controllers.js b/packages/server/controllers/coar/coar.controllers.js index afbf8787c..e798f5000 100644 --- a/packages/server/controllers/coar/coar.controllers.js +++ b/packages/server/controllers/coar/coar.controllers.js @@ -1,5 +1,7 @@ +const { get } = require('lodash') const { logger, serverUrl, request } = require('@coko/server') const { getCrossrefDataViaDoi } = require('./crossRef') +const { getDataciteViaDoi } = require('./dataCite') const { makeAnnouncementOnCOAR, generateUrn } = require('./announcement') const { @@ -8,8 +10,11 @@ const { Group, Manuscript, User, + Token, } = require('../../models') +const { getSubmissionForm } = require('../review.controllers') + let archiveManuscript setImmediate(() => { /* eslint-disable global-require */ @@ -18,6 +23,27 @@ setImmediate(() => { /* eslint-enable global-require */ }) +const supportedDoiRegistrationAgencies = ['Crossref', 'DataCite'] +const raUrl = 'https://doi.org/doiRA' + +const getDoiRegistrationAgency = async doi => { + try { + const { data } = await request({ method: 'get', url: `${raUrl}/${doi}` }) + + if (Array.isArray(data)) { + const [{ RA, status }] = data + + return RA ?? status + } + + const { RA, status } = data + return RA ?? status + } catch (error) { + console.error(`Failed to find RA for DOI ${doi}`) + return error.message + } +} + const sendAnnouncementNotification = ( notification, manuscript, @@ -95,6 +121,8 @@ const sendTentativeAcceptCoarNotification = async ( data: stringifiedPayload, }) + await createNotification(tentativeAcceptPayload, group.id, manuscript.id) + return response ? response.data : false } catch (e) { logger.error(e) @@ -172,6 +200,8 @@ const sendRejectCoarNotification = async ( data: stringifiedPayload, }) + await createNotification(rejectPayload, group.id, manuscript.id) + return response ? response.data : false } catch (e) { logger.error(e) @@ -182,6 +212,8 @@ const sendRejectCoarNotification = async ( const sendUnprocessableCoarNotification = async ( reason, originalPayload = {}, + manuscriptId = null, + groupId = null, ) => { const { id: notificationId, origin, target } = originalPayload @@ -225,6 +257,10 @@ const sendUnprocessableCoarNotification = async ( data: stringifiedPayload, }) + if (manuscriptId) { + await CoarNotification.query().insert({ payload, manuscriptId, groupId }) + } + return response ? response.data : false } catch (e) { logger.error(e) @@ -232,10 +268,11 @@ const sendUnprocessableCoarNotification = async ( } } -const createNotification = async (payload, groupId) => { +const createNotification = async (payload, groupId, manuscriptId = null) => { const notification = await CoarNotification.query().insert({ payload, groupId, + ...(manuscriptId ? { manuscriptId } : {}), }) return notification @@ -246,16 +283,57 @@ const getManuscriptByDoi = async (doi, groupId) => { return manuscript } -const extractManuscriptFromNotification = async (notification, groupId) => { - const doi = extractDoi(notification.payload) - const crossrefData = await getCrossrefDataViaDoi(doi) +const getAdditionalMetadata = async (data, groupId) => { + const submissionForm = await getSubmissionForm(groupId) + + const mappedMetadata = submissionForm.structure.children + .filter(e => !!e.metadataMapping) + .reduce((acc, e) => { + const parsedName = e.name && e.name.split('.')[1] + if (!parsedName) return acc + + acc[parsedName] = get(data, e.metadataMapping) + return acc + }, {}) + + return mappedMetadata +} + +const extractManuscriptFromNotification = async (payload, groupId, doiRa) => { + const doi = extractDoi(payload) + + const doiMetadata = + doiRa === 'Crossref' + ? await getCrossrefDataViaDoi(doi) + : await getDataciteViaDoi(doi) + + // const crossrefData = await getCrossrefDataViaDoi(doi) const existingManuscript = await getManuscriptByDoi(doi, groupId) if (existingManuscript) return null - if (!crossrefData) return null - const { title, topics, publishedDate, abstract, journal, author, url } = - crossrefData + if (!doiMetadata) { + throw new Error( + `Could not find metadata for DOI ${doi}. Please verify with ${doiRa}`, + ) + } + + const { + title, + topics, + publishedDate, + abstract, + journal, + author, + $authors, + url, + data: rawDoiData, + } = doiMetadata + + const additionalMappedMetadata = await getAdditionalMetadata( + rawDoiData, + groupId, + ) const newManuscript = { submission: { @@ -267,6 +345,8 @@ const extractManuscriptFromNotification = async (notification, groupId) => { $doi: doi, url, $title: title, + $authors, + ...additionalMappedMetadata, }, status: 'new', meta: { @@ -298,6 +378,20 @@ const extractManuscriptFromNotification = async (notification, groupId) => { return manuscript } +const validateAuthToken = async (authHeader, groupId) => { + const coarAuthToken = await Token.getTokenByNameAndGroupId('coar', groupId) + + if (!coarAuthToken) { + return true + } + + const providedToken = authHeader?.startsWith('Bearer ') + ? authHeader.slice(7) + : null + + return coarAuthToken === providedToken +} + const validateIPs = async (requestIP, group) => { const groupId = group.id @@ -331,7 +425,7 @@ const linkManuscriptToNotification = async (notification, manuscript) => { const extractDoi = payload => { const doi = payload.object && payload.object['ietf:cite-as'] - return doi ? doi.replace('https://doi.org/', '') : null + return doi ? doi.replace(/^https?:\/\/(dx\.)?doi\.org\//i, '') : null } const extractNotificationType = payload => { @@ -389,7 +483,7 @@ const processNotification = async (group, payload) => { return { status: 404, message: 'Manuscript not found' } } - await createNotification(payload, groupId) + await createNotification(payload, groupId, existingManuscript.id) await archiveManuscript(existingManuscript.id) return { status: 202, message: 'Manuscript archived successfully' } @@ -402,31 +496,55 @@ const processNotification = async (group, payload) => { } } - const notification = await createNotification(payload, groupId) + // may contain the error message to be returned + const doiRa = await getDoiRegistrationAgency(doi) - // only returns a new manuscipt, else null for existing - const manuscript = await extractManuscriptFromNotification( - notification, - groupId, - ) + if (!supportedDoiRegistrationAgencies.includes(doiRa)) { + return { status: 400, message: doiRa } + } + + let newManuscript + + try { + // only returns a new manuscipt, else null for existing + newManuscript = await extractManuscriptFromNotification( + payload, + groupId, + doiRa, + ) + } catch (extractError) { + return { status: 400, message: extractError.message } + } + + const notification = await createNotification(payload, groupId) // existing manuscript - if (!manuscript) { + if (!newManuscript) { await CoarNotification.query() .findById(notification.id) .patch({ status: false }) } else { - await linkManuscriptToNotification(notification, manuscript) + await linkManuscriptToNotification(notification, newManuscript) } return { status: 202, message: 'Notification created successfully.' } } +const getNotificationsForManuscript = async manuscriptId => { + const notifications = ( + await CoarNotification.getNotificationsForManuscript(manuscriptId) + ).map(n => ({ ...n, payload: JSON.stringify(n.payload) })) + + return notifications +} + module.exports = { + getNotificationsForManuscript, sendAnnouncementNotification, sendTentativeAcceptCoarNotification, sendRejectCoarNotification, sendUnprocessableCoarNotification, processNotification, + validateAuthToken, validateIPs, } diff --git a/packages/server/controllers/coar/crossRef.js b/packages/server/controllers/coar/crossRef.js index 42d819eef..b2273e2d3 100644 --- a/packages/server/controllers/coar/crossRef.js +++ b/packages/server/controllers/coar/crossRef.js @@ -1,6 +1,8 @@ +const { uuid } = require('@coko/server') const axios = require('axios') +const { getRorOrganisation } = require('./utils') -const apiUrl = 'https://api.crossref.org/v1/works' +const apiUrl = 'https://api.crossref.org/works/doi' const getDataByDoi = async doi => { try { @@ -8,7 +10,7 @@ const getDataByDoi = async doi => { return response.data.message } catch (error) { - console.error(`Resource not found in crossref for DOI ${doi}`) + console.error(`Resource not found in Crossref for DOI ${doi}`) return null } } @@ -23,7 +25,7 @@ const getPublishedDate = data => { if (![year, month, date].length) { const publish = assertion.find(p => p.name === 'published') - return publish.value + return publish?.value || '' } return `${date ? `${date}-` : ''}${month ? `${month}-` : ''}${ @@ -39,14 +41,56 @@ const getTopics = data => { } const getJournal = data => { - const { institution } = data - const { publisher } = data + const { institution, publisher } = data return institution ? institution[0].name : publisher } const getAuthor = data => { const authors = data.author - return authors ? authors[0].given : '' + + if (!authors || !Array.isArray(authors)) return '' + + const author = authors.find(a => a.sequence === 'first') ?? authors[0] + + return author ? `${author.family ?? ''}, ${author.given ?? ''}` : '' +} + +const getAuthors = async data => { + const { author } = data + + if (!author || !Array.isArray(author)) return [] + + return Promise.all( + author.map(async a => { + const { affiliation, family, given, ORCID } = a + + const ror = + (await Promise.all( + affiliation?.map(async af => { + const value = af.id[0]?.id ?? '' + + const label = + value !== '' && af.name + ? af.name + : await getRorOrganisation(value) + + return { label, value } + }), + )) ?? [] + + const orcid = ORCID?.match(/\d{4}-\d{4}-\d{4}-\d{3}[0-9X]\b/) ?? '' + + return { + firstName: given, + middleName: '', + lastName: family, + email: '', + id: uuid(), + ror, + orcid, + } + }), + ) } const getCrossrefDataViaDoi = async doi => { @@ -59,6 +103,7 @@ const getCrossrefDataViaDoi = async doi => { const topics = getTopics(data) const journal = getJournal(data) const author = getAuthor(data) + const $authors = await getAuthors(data) return { title: title[0], @@ -68,10 +113,12 @@ const getCrossrefDataViaDoi = async doi => { journal, author, url: resource?.primary.URL, + $authors, + data, } } module.exports = { - getDataByDoi, getCrossrefDataViaDoi, + getDataByDoi, } diff --git a/packages/server/controllers/coar/dataCite.js b/packages/server/controllers/coar/dataCite.js new file mode 100644 index 000000000..3f7a3307b --- /dev/null +++ b/packages/server/controllers/coar/dataCite.js @@ -0,0 +1,155 @@ +const { logger, uuid } = require('@coko/server') +const axios = require('axios') + +const apiUrl = 'https://api.datacite.org/dois' + +const allowedAuthorContributorTypes = [ + 'ContactPerson', + 'DataCollector', + 'DataCurator', + 'ProjectLeader', + 'ProjectManager', + 'ProjectMember', + 'RelatedPerson', + 'Researcher', + 'RightsHolder', +] + +const getDataByDoi = async doi => { + try { + const response = await axios.get(`${apiUrl}/${doi}`, {}) + + return response.data.data.attributes + } catch (error) { + logger.error(`Resource not found in DataCite eefor DOI ${doi}`) + return null + } +} + +const getTitle = titles => { + const titleObj = titles[0] + + return titleObj?.title || '' +} + +const getSubjects = subjects => subjects.map(s => s.subject) + +const getPublishedDate = (dates, publicationYear) => { + const dateObj = dates.find(d => d.dateType === 'Issued') + + if (dateObj) return dateObj.date + + if (publicationYear) return parseInt(publicationYear, 10) + + return '' +} + +const getPublisher = publisher => { + if (!publisher) return '' + + if (typeof publisher === 'string') return publisher + + return publisher.name +} + +const getFirstAuthor = data => { + const { contributors, creators } = data + const creator = creators.find(c => c.nameType === 'Personal') + + if (creator) return creator.name + + const contactPerson = contributors.find( + c => c.nameType === 'Personal' && c.contributorType === 'ContactPerson', + ) + + if (contactPerson) return contactPerson.name + + const contributor = creators.find( + c => + c.nameType === 'Personal' && + allowedAuthorContributorTypes.includes(c.contributorType), + ) + + return contributor?.name || '' +} + +const getAuthors = contributors => { + return contributors + .filter( + c => + c.nameType === 'Personal' && + allowedAuthorContributorTypes.includes(c.contributorType), + ) + .map(c => { + const ror = + c.affiliation + ?.find( + a => + typeof a === 'object' && a.affiliationIdentifierScheme === 'ROR', + ) + ?.map(a => ({ label: a.name, value: a.affiliationIdentifier })) ?? [] + + const orcid = + c.nameIdentifiers + ?.find( + n => typeof n === 'object' && n.nameIdentifierScheme === 'ORCID', + ) + ?.nameIdentifier.match(/\d{4}-\d{4}-\d{4}-\d{3}[0-9X]\b/) ?? '' + + return { + firstName: c.givenName, + middleName: '', + lastName: c.familyName, + email: '', + id: uuid(), + ror, + orcid, + } + }) +} + +const getUrl = contentUrl => { + if (!contentUrl) { + return '' + } + + return Array.isArray(contentUrl) ? contentUrl[0] : contentUrl +} + +const getDataciteViaDoi = async doi => { + const data = await getDataByDoi(doi) + + if (!data) return null + + const { + contentUrl, + contributors = [], + dates = [], + publicationYear, + publisher, + subjects = [], + titles = [], + } = data + + const title = getTitle(titles ?? []) + const topics = getSubjects(subjects) + const publishedDate = getPublishedDate(dates, publicationYear) + const journal = getPublisher(publisher) + const author = getFirstAuthor(data) + const $authors = getAuthors(contributors) + const url = getUrl(contentUrl) + + return { + title, + topics, + publishedDate, + // abstract, + journal, + author, + url, + $authors, + data, + } +} + +module.exports = { getDataciteViaDoi } diff --git a/packages/server/controllers/coar/sciety.js b/packages/server/controllers/coar/sciety.js index 2f27df45c..5f697febb 100644 --- a/packages/server/controllers/coar/sciety.js +++ b/packages/server/controllers/coar/sciety.js @@ -88,13 +88,16 @@ const getRequestData = async manuscript => { } const sendAnnouncementNotificationToSciety = async manuscript => { + const { groupId, id: manuscriptId } = manuscript const requestData = await getRequestData(manuscript) - const inboxUrl = await getScietyInboxUrl(manuscript.groupId) + const inboxUrl = await getScietyInboxUrl(groupId) if ((!isReviewDoi() && !isFlaxSetup()) || !inboxUrl || !requestData) { return false } + const payload = JSON.parse(requestData) + try { const response = await request({ method: 'post', @@ -105,6 +108,8 @@ const sendAnnouncementNotificationToSciety = async manuscript => { data: requestData, }) + await CoarNotification.query().insert({ groupId, manuscriptId, payload }) + return response ? response.data : false } catch (err) { logger.error(err) diff --git a/packages/server/controllers/coar/utils.js b/packages/server/controllers/coar/utils.js new file mode 100644 index 000000000..666e42a81 --- /dev/null +++ b/packages/server/controllers/coar/utils.js @@ -0,0 +1,23 @@ +const { default: axios } = require('axios') + +const apiUrl = 'https://api.ror.org/v2/organizations' + +const getRorOrganisation = async value => { + const id = value.split('/').pop() + + try { + const { data } = await axios.get(`${apiUrl}/${id}`, {}) + + const rorDisplay = + data.names?.find(n => n.types.includes('ror_display'))?.value ?? '' + + return rorDisplay + } catch (error) { + console.error('Failed to get organisation for ROR', id) + return '' + } +} + +module.exports = { + getRorOrganisation, +} diff --git a/packages/server/controllers/manuscript/manuscript.controllers.js b/packages/server/controllers/manuscript/manuscript.controllers.js index fb96df40f..cf1b6a9cd 100644 --- a/packages/server/controllers/manuscript/manuscript.controllers.js +++ b/packages/server/controllers/manuscript/manuscript.controllers.js @@ -1307,9 +1307,10 @@ const publishManuscript = async (id, groupId) => { status: 'published', }) - const notification = await CoarNotification.query().findOne({ - manuscriptId: manuscript.id, - }) + const notification = await CoarNotification.getOfferNotificationForManuscript( + manuscript.id, + {}, + ) // This will also collect any properties we may want to update in the DB const update = { diff --git a/packages/server/controllers/pdfExport.controllers.js b/packages/server/controllers/pdfExport.controllers.js index 357dc3564..721c5f85c 100644 --- a/packages/server/controllers/pdfExport.controllers.js +++ b/packages/server/controllers/pdfExport.controllers.js @@ -30,9 +30,7 @@ const copyFile = promisify(fs.copyFile) // THINGS TO KNOW ABOUT THIS: // // 1. It is expecting two .env variables: PAGED_JS_CLIENT_ID and PAGED_JS_CLIENT_SECRET -// The process for generating these is here: https://gitlab.coko.foundation/cokoapps/pagedjs#creating-clients-credentials -// -// editoria version of this code is here: https://gitlab.coko.foundation/editoria/editoria/-/blob/master/server/api/useCases/services.js +// The process for generating these is here: https://github.com/Coko-Foundation/pagedjs-microservice#creating-clients-credentials const randomBytes = promisify(crypto.randomBytes) diff --git a/packages/server/controllers/token.controllers.js b/packages/server/controllers/token.controllers.js new file mode 100644 index 000000000..f8d072d4f --- /dev/null +++ b/packages/server/controllers/token.controllers.js @@ -0,0 +1,15 @@ +const { uuid } = require('@coko/server') +const { Token } = require('../models') + +const generateToken = async (name, groupId) => { + const rawToken = uuid() + const encodedValue = btoa(rawToken) + + const { value } = await Token.generateToken(name, encodedValue, groupId) + + return value +} + +module.exports = { + generateToken, +} diff --git a/packages/server/controllers/xsweet.controllers.js b/packages/server/controllers/xsweet.controllers.js index 662812dfb..4dac6c4d8 100644 --- a/packages/server/controllers/xsweet.controllers.js +++ b/packages/server/controllers/xsweet.controllers.js @@ -96,7 +96,7 @@ const getXsweet = async url => { method: 'post', url: `${serverUrl}/api/v1/sync/DOCXToHTML`, // NOTE THAT THERE ARE OTHER WAYS TO DO THIS! - // See https://gitlab.coko.foundation/cokoapps/xsweet/-/blob/master/server/api/api.js + // See https://github.com/Coko-Foundation/xsweet-microservice/blob/master/server/api/api.js // – that's different from what's in the README, which is wrong. maxContentLength: Infinity, maxBodyLength: Infinity, diff --git a/packages/server/models/coarNotification/coarNotification.model.js b/packages/server/models/coarNotification/coarNotification.model.js index 52bf2d871..70597b645 100644 --- a/packages/server/models/coarNotification/coarNotification.model.js +++ b/packages/server/models/coarNotification/coarNotification.model.js @@ -16,6 +16,12 @@ class CoarNotification extends BaseModel { } } + static async getNotificationsForManuscript(manuscriptId, options = {}) { + const { trx } = options + + return this.query(trx).where({ manuscriptId }) + } + static async getOfferNotificationForManuscript(manuscriptId, options = {}) { const { trx } = options diff --git a/packages/server/models/config/migrations/1709014395-update-production-citation-settings.js b/packages/server/models/config/migrations/1709014395-update-production-citation-settings.js index 5bcf65168..951bb1fa9 100644 --- a/packages/server/models/config/migrations/1709014395-update-production-citation-settings.js +++ b/packages/server/models/config/migrations/1709014395-update-production-citation-settings.js @@ -2,8 +2,6 @@ const { useTransaction, logger } = require('@coko/server') const Config = require('../config.model') -// as requested here: https://gitlab.coko.foundation/kotahi/kotahi/-/merge_requests/1258#note_142075 - exports.up = async knex => { return useTransaction(async trx => { const configs = await Config.query(trx) diff --git a/packages/server/models/index.js b/packages/server/models/index.js index 53ca9c806..ce81b02d0 100644 --- a/packages/server/models/index.js +++ b/packages/server/models/index.js @@ -36,6 +36,7 @@ const TaskEmailNotificationLog = require('./taskEmailNotificationLog/taskEmailNo const Team = require('./team/team.model') const TeamMember = require('./teamMember/teamMember.model') const ThreadedDiscussion = require('./threadedDiscussion/threadedDiscussion.model') +const Token = require('./token/token.model') const User = require('./user/user.model') module.exports = { @@ -73,5 +74,6 @@ module.exports = { Team, TeamMember, ThreadedDiscussion, + Token, User, } diff --git a/packages/server/models/manuscript/manuscript.loaders.js b/packages/server/models/manuscript/manuscript.loaders.js new file mode 100644 index 000000000..d80d352a0 --- /dev/null +++ b/packages/server/models/manuscript/manuscript.loaders.js @@ -0,0 +1,49 @@ +const { + getFilesWithUrl, + replaceImageSrc, +} = require('../../utils/fileStorageUtils') + +const Manuscript = require('./manuscript.model') + +const metaSourceLoader = async (manuscriptMetas, options = {}) => { + const { trx } = options + + const results = new Array(manuscriptMetas.length).fill(null) + + const withSource = manuscriptMetas + .map((m, index) => ({ m, index })) + .filter(({ m }) => typeof m.source === 'string') + + if (!withSource.length) return results + + const needFiles = withSource.filter( + ({ m }) => !Array.isArray(m.manuscriptFiles), + ) + + let filesByManuscriptId = new Map() + + if (needFiles.length) { + const ids = needFiles.map(({ m }) => m.manuscriptId) + const rows = await Manuscript.relatedQuery('files', trx).for(ids) + + filesByManuscriptId = rows.reduce((acc, file) => { + if (!acc.has(file.manuscriptId)) acc.set(file.manuscriptId, []) + acc.get(file.manuscriptId).push(file) + return acc + }, new Map()) + } + + await Promise.all( + withSource.map(async ({ m, index }) => { + const files = + m.manuscriptFiles || filesByManuscriptId.get(m.manuscriptId) || [] + + const filesWithUrl = await getFilesWithUrl(files) + results[index] = await replaceImageSrc(m.source, filesWithUrl, 'medium') + }), + ) + + return results +} + +module.exports = { metaSourceLoader } diff --git a/packages/server/models/modelComponents.js b/packages/server/models/modelComponents.js index 647da0314..7f297ac6e 100644 --- a/packages/server/models/modelComponents.js +++ b/packages/server/models/modelComponents.js @@ -35,6 +35,7 @@ const modelPaths = [ 'team', 'teamMember', 'threadedDiscussion', + 'token', 'user', 'collaborative-doc', 'notification', diff --git a/packages/server/models/team/team.model.js b/packages/server/models/team/team.model.js index 5a86e8371..c30bc53ef 100644 --- a/packages/server/models/team/team.model.js +++ b/packages/server/models/team/team.model.js @@ -52,7 +52,7 @@ class Team extends TeamBase { } } - // TODO add $beforeDelete once https://gitlab.coko.foundation/cokoapps/server/-/issues/43 is resolved + // TODO add $beforeDelete once https://github.com/Coko-Foundation/cokoserver/issues/43 is resolved async $beforeInsert(queryContext) { await super.$beforeInsert(queryContext) evictFromCache(`teamsForObject:${this.objectId}`) diff --git a/packages/server/models/teamMember/teamMember.model.js b/packages/server/models/teamMember/teamMember.model.js index d8d1410c7..d785ef529 100644 --- a/packages/server/models/teamMember/teamMember.model.js +++ b/packages/server/models/teamMember/teamMember.model.js @@ -12,7 +12,7 @@ class TeamMember extends TeamMemberBase { } } - // TODO add $beforeDelete once https://gitlab.coko.foundation/cokoapps/server/-/issues/43 is resolved + // TODO add $beforeDelete once https://github.com/Coko-Foundation/cokoserver/issues/43 is resolved async $beforeInsert(queryContext) { await super.$beforeInsert(queryContext) evictFromCacheByPrefix('userIs') diff --git a/packages/server/models/token/__tests__/migrations.test.js b/packages/server/models/token/__tests__/migrations.test.js new file mode 100644 index 000000000..6669bf6ea --- /dev/null +++ b/packages/server/models/token/__tests__/migrations.test.js @@ -0,0 +1,72 @@ +const { db, migrationManager, uuid } = require('@coko/server') +const Group = require('../../group/group.model') +const Token = require('../token.model') + +describe('Token Migrations', () => { + beforeEach(async () => { + const tables = await db('pg_tables') + .select('tablename') + .where('schemaname', 'public') + + /* eslint-disable-next-line no-restricted-syntax */ + for (const t of tables) { + /* eslint-disable-next-line no-await-in-loop */ + await db.raw(`DROP TABLE IF EXISTS public.${t.tablename} CASCADE`) + } + }) + + afterAll(async () => { + await db.destroy() + }) + + it('creates and drops tokens table', async () => { + await migrationManager.migrate({ + to: '1757670017-activate-empty-notifications.js', + }) + + let tableExists = await db.schema.hasTable('tokens') + + expect(tableExists).toBe(false) + + await migrationManager.migrate({ step: 1 }) + + tableExists = await db.schema.hasTable('tokens') + + expect(tableExists).toBe(true) + + await migrationManager.rollback({ step: 1 }) + + tableExists = await db.schema.hasTable('tokens') + + expect(tableExists).toBe(false) + }, 30000) + + it('creates a constraint on names and groupId pairs', async () => { + await migrationManager.migrate({ to: '1775729239-token.js' }) + + const { id: groupId } = await Group.insert({}) + + const firstToken = await Token.insert({ + name: 'specialToken', + value: uuid(), + groupId, + }) + + await migrationManager.migrate({ step: 1 }) + + await expect(async () => { + await Token.insert({ name: 'specialToken', value: uuid(), groupId }) + }).rejects.toThrow() + + await migrationManager.rollback({ step: 1 }) + + const secondToken = await Token.insert({ + name: 'specialToken', + value: uuid(), + groupId, + }) + + expect(firstToken.name).toBe(secondToken.name) + expect(firstToken.groupId).toBe(secondToken.groupId) + }, 30000) +}) diff --git a/packages/server/models/token/index.js b/packages/server/models/token/index.js new file mode 100644 index 000000000..6baa540b2 --- /dev/null +++ b/packages/server/models/token/index.js @@ -0,0 +1,6 @@ +const model = require('./token.model') + +module.exports = { + model, + modelName: 'Token', +} diff --git a/packages/server/models/token/migrations/1775729239-token.js b/packages/server/models/token/migrations/1775729239-token.js new file mode 100644 index 000000000..7526a24a2 --- /dev/null +++ b/packages/server/models/token/migrations/1775729239-token.js @@ -0,0 +1,21 @@ +exports.up = async knex => { + return knex.schema.createTable('tokens', table => { + table.uuid('id').primary() + table.string('name').notNullable() + table.string('value').notNullable() + table.uuid('group_id').references('groups.id').notNullable() + table + .timestamp('created', { useTz: true }) + .notNullable() + .defaultTo(knex.fn.now()) + table.timestamp('updated', { useTz: true }) + }) +} + +exports.down = async knex => { + const tableExists = await knex.schema.hasTable('tokens') + + if (tableExists) { + await knex.schema.dropTable('tokens') + } +} diff --git a/packages/server/models/token/migrations/1776094540-unique-name-group-id.js b/packages/server/models/token/migrations/1776094540-unique-name-group-id.js new file mode 100644 index 000000000..b9499dc31 --- /dev/null +++ b/packages/server/models/token/migrations/1776094540-unique-name-group-id.js @@ -0,0 +1,29 @@ +exports.up = async knex => { + try { + await knex.schema.alterTable('tokens', table => { + table.unique(['name', 'group_id'], { + indexName: 'tokens_unique_name_groupid', + }) + }) + + return true + } catch (e) { + throw new Error( + `Migration: Token: create unique name and groupId failed: ${e}`, + ) + } +} + +exports.down = async knex => { + try { + await knex.schema.alterTable('tokens', table => { + table.dropUnique(['name', 'group_id'], 'tokens_unique_name_groupid') + }) + + return true + } catch (e) { + throw new Error( + `Migration: Token: drop unique name and groupId failed: ${e}`, + ) + } +} diff --git a/packages/server/models/token/token.model.js b/packages/server/models/token/token.model.js new file mode 100644 index 000000000..e282ad647 --- /dev/null +++ b/packages/server/models/token/token.model.js @@ -0,0 +1,61 @@ +const { BaseModel } = require('@coko/server') + +class Token extends BaseModel { + static get tableName() { + return 'tokens' + } + + static get relationMappings() { + // eslint-disable-next-line global-require + const Group = require('../group/group.model') + + return { + group: { + relation: BaseModel.BelongsToOneRelation, + modelClass: Group, + join: { + from: 'tokens.group_id', + to: 'groups.id', + }, + }, + } + } + + static get schema() { + return { + properties: { + name: { type: 'string' }, + value: { type: 'string' }, + groupId: { type: 'string', format: 'uuid' }, + }, + } + } + + static async deleteTokenById(tokenId, options = {}) { + const { trx } = options + + return this.query(trx).deleteById(tokenId) + } + + static async deleteByNameAndGroupId(name, groupId, options = {}) { + const { trx } = options + + return this.query(trx).delete().where({ name, groupId }) + } + + static async generateToken(name, value, groupId, options = {}) { + const { trx } = options + + await this.deleteByNameAndGroupId(name, groupId, { trx }) + + return this.query(trx).insert({ name, value, groupId }) + } + + static async getTokenByNameAndGroupId(name, groupId, options = {}) { + const { value } = (await this.findOne({ name, groupId }, options)) ?? {} + + return value + } +} + +module.exports = Token diff --git a/packages/server/package.json b/packages/server/package.json index 986846a1b..4a577720a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -5,7 +5,7 @@ "description": "Kotahi - open journals", "repository": { "type": "git", - "url": "https://gitlab.coko.foundation/kotahi/kotahi" + "url": "https://github.com/eLifePathways/Kotahi/" }, "license": "MIT", "config": { diff --git a/packages/server/scripts/seedConfig.js b/packages/server/scripts/seedConfig.js index e159f4598..0fd44014a 100644 --- a/packages/server/scripts/seedConfig.js +++ b/packages/server/scripts/seedConfig.js @@ -275,6 +275,7 @@ const seedConfig = async (group, instanceName, index, options) => { 'Manuscript text', 'Metadata', 'Tasks & Notifications', + 'COAR Notify Metadata', ], hideReview: process.env.REVIEW_HIDE === 'true', sharedReview: process.env.REVIEW_SHARED === 'true', @@ -336,6 +337,7 @@ const seedConfig = async (group, instanceName, index, options) => { 'Manuscript text', 'Metadata', 'Tasks & Notifications', + 'COAR Notify Metadata', ], hideReview: process.env.REVIEW_HIDE === 'true', sharedReview: process.env.REVIEW_SHARED === 'true', @@ -399,6 +401,7 @@ const seedConfig = async (group, instanceName, index, options) => { 'Manuscript text', 'Metadata', 'Tasks & Notifications', + 'COAR Notify Metadata', ], hideReview: process.env.REVIEW_HIDE === 'true', sharedReview: process.env.REVIEW_SHARED === 'true', diff --git a/packages/server/utils/ziputils.js b/packages/server/utils/ziputils.js index 02d79a412..4848007ab 100644 --- a/packages/server/utils/ziputils.js +++ b/packages/server/utils/ziputils.js @@ -4,8 +4,6 @@ const crypto = require('crypto') const list = require('list-contents') const fs = require('fs-extra') -// this is mostly taken from https://gitlab.coko.foundation/editoria/editoria/-/blob/master/server/api/helpers/utils.js - const dirContents = async pathString => new Promise((resolve, reject) => { list(pathString, o => {