├── .cfignore ├── .editorconfig ├── .env.sample ├── .eslintignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── documentation.md │ └── feature_request.md └── workflows │ ├── codeql.yml │ └── main_workflow.yaml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── .vscode ├── extensions.json └── launch.json ├── CONTRIBUTION.adoc ├── LICENSE ├── README.adoc ├── changelog.md ├── db ├── README.adoc ├── flyway.js └── migration │ ├── V001__Setup_Tables.sql │ ├── V002__Initial_Data.sql │ ├── V003__Queue_Data.sql │ ├── V004__Api_User.sql │ ├── V005__Store_Device_Token.sql │ ├── V006__Questionnaire_Version_History.sql │ ├── V007__i18n.sql │ └── V008__UpdateTables.sql ├── devfile.yaml ├── docs ├── README.adoc ├── api_user │ └── README.adoc ├── customization │ ├── QuestionnaireStates.png │ └── README.adoc └── reminder │ └── index.adoc ├── jest.config.js ├── nodemon.json ├── ocp_deployment ├── README.adoc ├── images │ ├── Deployment.png │ ├── NUMapp-backend_TopologyView.png │ └── PostgreSQLSetup.png └── templates │ ├── .gitignore │ ├── compass-backend-template-prod.yaml │ ├── compass-backend-template.yaml │ └── google-services-secret.yaml.sample ├── package-lock.json ├── package.json ├── renovate.json ├── scripts └── createSubjectIDs │ ├── README.adoc │ └── createSubjectIDs.js ├── src ├── app.ts ├── assets │ ├── .gitkeep │ └── openapi.yaml ├── config │ ├── AuthConfig.ts │ ├── COMPASSConfig.ts │ ├── DBCredentials.ts │ ├── Environment.ts │ └── PushServiceConfig.ts ├── controllers │ ├── ApiController.ts │ ├── AuthorizationController.ts │ ├── CronController │ │ ├── AbstractCronJob.ts │ │ ├── CronController.ts │ │ └── CronJobNotification.ts │ ├── DownloadController.ts │ ├── ParticipantController.ts │ ├── QuestionnaireController.ts │ ├── QueueController.ts │ ├── SubjectIdentitiesController.ts │ └── index.ts ├── models │ ├── ApiUserModel.ts │ ├── ExampleStateModel.ts │ ├── ParticipantModel.ts │ ├── QuestionnaireModel.ts │ ├── QueueModel.ts │ ├── StateModel.ts │ └── SubjectIdentitiesModel.ts ├── server │ ├── CustomRoutes.ts │ ├── DB.ts │ ├── ExpressServer.ts │ ├── Route.ts │ └── SwaggerUI.ts ├── services │ ├── IdHelper.ts │ ├── PerformanceLogger.ts │ ├── PushService.ts │ └── SecurityService.ts └── types │ ├── ApiUserEntry.ts │ ├── CTransfer.ts │ ├── MeasurementObject.ts │ ├── ParticipantEntry.ts │ ├── QueueEntry.ts │ ├── StateChangeTrigger.ts │ └── index.ts ├── tests ├── models │ └── ExampleStateModel.test.ts └── services │ ├── IdHelper.test.ts │ └── SecurityService.test.ts └── tsconfig.json /.cfignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | db 3 | src 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # database connection values 2 | DB_HOST='localhost' 3 | DB_PORT='5432' 4 | DB_NAME='compass' 5 | DB_PASSWORD='123456' 6 | DB_USER='compass_user' 7 | 8 | # a secret used for JWT generation 9 | JWT_SECRET='mein_schatz' 10 | 11 | # optional Push Service configuration 12 | GOOGLE_APPLICATION_CREDENTIALS='./google-services.json' 13 | 14 | # have node use a common time zone 15 | TZ = 'Europe/Amsterdam' 16 | 17 | # encryption keys 18 | COMPASS_PUBLIC_KEY='-----BEGIN PUBLIC KEY-----\nMII ... \n ... \n-----END PUBLIC KEY-----' 19 | COMPASS_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\nMII ... \n ... =\n-----END PRIVATE KEY-----' 20 | 21 | COMPASS_RECIPIENT_CERTIFICATE='-----BEGIN CERTIFICATE-----\nMII .... \n ... \n-----END CERTIFICATE-----' 22 | 23 | # Uncomment the following the line to disable SSL for dev 24 | # DB_USE_SSL='false' 25 | 26 | # Uncomment the following line and enter the ssl certificate for your database 27 | # if you don't use the ibm cloud (openshift) for deployment 28 | # Attention: self signed certificates won't work! 29 | # DB_SSL_CA='-----BEGIN CERTIFICATE-----\nMII .... \n ... \n-----END CERTIFICATE-----' 30 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | db 5 | doc 6 | coverage 7 | scripts 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | "prettier", 7 | "jest" 8 | ], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier" 13 | ], 14 | "rules": { 15 | "no-console": 1 16 | }, 17 | "env": { 18 | "node": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Smartphone (please complete the following information):** 27 | - Device: [e.g. iPhone6] 28 | - OS: [e.g. iOS8.1] 29 | - Browser [e.g. stock browser, safari] 30 | - Version [e.g. 22] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Report an issue or improvement for the documentation 4 | title: '' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the problem with the Documentation** 11 | A clear and concise description of what the problem is. Ex. I don't understand how... 12 | 13 | **[Optional] Suggest information, that might be helpful** 14 | Explain the information you need or suggest changes that might be helpful 15 | Add any other context or screenshots about the feature request here. 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: "39 15 * * 5" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v2 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v2 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/main_workflow.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | name: main workflow 4 | on: 5 | push: 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | jobs: 10 | run_tests: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | max-parallel: 24 14 | matrix: 15 | node-version: [18.x] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: cache node_modules 23 | uses: actions/cache@v3 24 | with: 25 | path: ~/.npm 26 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 27 | restore-keys: ${{ runner.OS }}-build-${{ env.cache-name }}- 28 | ${{ runner.OS }}-build- 29 | ${{ runner.OS }}- 30 | - name: install npm 31 | run: npm install -g npm@latest 32 | - name: install node dependencies 33 | run: npm ci 34 | env: 35 | CI: true 36 | - name: create /private key pair 37 | run: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:4096 38 | - name: create public key 39 | run: openssl rsa -pubout -in private_key.pem -out public_key.pem 40 | - name: run all tests 41 | run: npm test 42 | lint_annotations: 43 | runs-on: ubuntu-latest 44 | strategy: 45 | max-parallel: 24 46 | matrix: 47 | node-version: [18.x] 48 | steps: 49 | - uses: actions/checkout@v4 50 | - name: Use Node.js ${{ matrix.node-version }} 51 | uses: actions/setup-node@v3 52 | with: 53 | node-version: ${{ matrix.node-version }} 54 | - name: cache node_modules 55 | uses: actions/cache@v3 56 | with: 57 | path: ~/.npm 58 | key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} 59 | restore-keys: ${{ runner.OS }}-build-${{ env.cache-name }}- 60 | ${{ runner.OS }}-build- 61 | ${{ runner.OS }}- 62 | - name: install npm 63 | run: npm install -g npm@latest 64 | - name: install node dependencies 65 | run: npm ci 66 | - name: run lint and generate annotations 67 | run: npm run lint:annotate 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # node-waf configuration 26 | .lock-wscript 27 | 28 | # Dependency directories 29 | node_modules/ 30 | jspm_packages/ 31 | 32 | # TypeScript v1 declaration files 33 | typings/ 34 | 35 | # TypeScript cache 36 | *.tsbuildinfo 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | # Optional eslint cache 42 | .eslintcache 43 | 44 | # Eslint Report 45 | eslint_report.json 46 | 47 | # Optional REPL history 48 | .node_repl_history 49 | 50 | # Output of 'npm pack' 51 | *.tgz 52 | 53 | # Yarn Integrity file 54 | .yarn-integrity 55 | 56 | # dotenv environment variables file 57 | .env 58 | .env.test 59 | 60 | # build folder 61 | build 62 | 63 | # TernJS port file 64 | .tern-port 65 | 66 | # DS_store files 67 | **/.DS_store 68 | 69 | # CSV file in scripts folder 70 | scripts/createSubjectIDs/*.csv 71 | 72 | # Local test file 73 | test.ts 74 | 75 | # Certificate files 76 | private*.pem 77 | public*.pem 78 | *cert*.pem 79 | google-services.json 80 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run test && npm run prettier-format && npm run lint 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "steoates.autoimport", 4 | "mike-co.import-sorter", 5 | "esbenp.prettier-vscode", 6 | "dbaeumer.vscode-eslint", 7 | "editorconfig.editorconfig", 8 | "gruntfuggly.todo-tree" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "command": "npm run dev:debug", 5 | "name": "Run dev:debug", 6 | "request": "launch", 7 | "type": "node-terminal" 8 | }, 9 | { 10 | "type": "node", 11 | "request": "launch", 12 | "name": "Jest Current File", 13 | "program": "${workspaceFolder}/node_modules/.bin/jest", 14 | "args": ["${relativeFile}"], 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen", 17 | "windows": { 18 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 19 | } 20 | }, 21 | //Configuration for Debugging of Unit-Tests 22 | { 23 | "name": "Debug Unit-Tests", 24 | "type": "node", 25 | "request": "launch", 26 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/jest", 27 | "args": ["test","--runInBand", "--no-cache", "watchAll=false"], 28 | "cwd": "${workspaceRoot}", 29 | "protocol": "inspector", 30 | "console": "integratedTerminal", 31 | "internalConsoleOptions": "neverOpen", 32 | "env": {"CI": "true"}, 33 | "disableOptimisticBPs": true 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /CONTRIBUTION.adoc: -------------------------------------------------------------------------------- 1 | == Contributing 2 | 3 | Bug reports and pull requests are welcome on https://github.com/NUMde/compass-numapp-backend[GitHub]. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the http://contributor-covenant.org[Contributor Covenant] code of conduct. 4 | 5 | === Submitting code changes: 6 | 7 | - Open a https://github.com/NUMde/compass-numapp-backend/pulls[Pull Request] 8 | - Ensure all tests pass 9 | - Await code review 10 | 11 | === Design and development principles 12 | 13 | 1. As few dependencies as possible 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2021, IBM Deutschland GmbH 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.adoc: -------------------------------------------------------------------------------- 1 | :tip-caption: :bulb: 2 | :note-caption: :information_source: 3 | :important-caption: :heavy_exclamation_mark: 4 | :caution-caption: :fire: 5 | :warning-caption: :warning: 6 | 7 | = NUM-App Mobile Back End 8 | 9 | image:https://img.shields.io/badge/license-Apache2-green?style=flat-square[license: Apache2.0,link=https://opensource.org/licenses/Apache-2.0] 10 | image:https://img.shields.io/lgtm/grade/javascript/g/NUMde/compass-numapp-backend.svg?logo=lgtm&logoWidth=18&style=flat-square[Language Grade: JavaScript,link=https://lgtm.com/projects/g/NUMde/compass-numapp-backend/context:javascript] 11 | image:https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square[PRs Welcome,link=https://makeapullrequest.com] 12 | 13 | https://github.com/NUMde/compass-numapp[Main Repository] | link:./docs[Back End Documentation] 14 | 15 | == Welcome 16 | 17 | This repository provides the source code for the mobile back end of the link:https://github.com/NUMde/compass-numapp[Compass NUM-App Project]. This project provides a set of open source components meant for the digital conduct of questionnaire based studies. The mobile back end itself is a part of link:https://num-compass.science/[COMPASS] (**C**oordination **O**n **M**obile **P**andemic **A**pps best practice and **S**olution **S**haring). 18 | 19 | The mobile back end provides study data for the NUM-App in form of link:https://www.hl7.org/fhir/questionnaire.html[FHIR Questionnaires]. It also stores the study data that is uploaded from the mobile app. 20 | Additionally it makes the collected data accessible for other parties. 21 | 22 | You can find an exemplary questionnaire https://github.com/NUMde/compass-implementation-guide/blob/master/input/questionnaire-generic.json[here]. 23 | 24 | == Development 25 | 26 | === Local Setup 27 | 28 | * Make sure you have a recent version (LTS recommended) of 29 | https://nodejs.org/[Node.js] installed and run the following commands to 30 | download and prepare this repository: 31 | 32 | [source,bash] 33 | ---- 34 | git clone https://github.com/NUMde/compass-numapp-backend.git 35 | cd compass-numapp-backend/ 36 | npm install 37 | ---- 38 | 39 | * In case you use VSCode as your editor, install the recommended extensions 40 | 41 | === Run the back end locally 42 | 43 | === Generating RSA key pair 44 | 45 | For local development we need an RSA key pair for the encryption with the client. 46 | Execute the following commands to create a key pair. 47 | The resulting files are picked automatically. But they can also be inserted into the `.env` file. 48 | 49 | ==== Create .env file 50 | Some configuration values need to be present as environment variables during runtime. 51 | The application loads a file with the name `.env` during startup, if it is present. 52 | 53 | To get started copy the file `.env.sample` to `.env` and add your values. 54 | 55 | [source,bash] 56 | ---- 57 | $ openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:4096 58 | $ openssl rsa -pubout -in private_key.pem -out public_key.pem 59 | ---- 60 | 61 | ==== Generating certificate 62 | 63 | To submit the public key to the client, a certificate must be created. 64 | To create a self-signed certificate, which will be valid for 1 year, use the following command: 65 | 66 | [source,bash] 67 | ---- 68 | $ openssl req -x509 -days 365 -new -out self-signed-certificate.pem -key private_key.pem 69 | ---- 70 | 71 | The certificate must be put in the .env file. If no certificate is provided, the native app will default to a built-in 72 | one (defined in src/config/appConfig.js). 73 | 74 | 75 | === Scripts 76 | 77 | ==== npm run start 78 | 79 | Start the built application 80 | 81 | ==== npm run dev 82 | 83 | Start the application on the local machine in watch mode. This is the preferred command for local development. 84 | 85 | ==== npm run build 86 | 87 | Build the application into the `build` folder. 88 | 89 | ==== npm run clean 90 | 91 | Clean the `dist` folder. 92 | 93 | ==== npm run lint 94 | 95 | Lint the source code. 96 | 97 | ==== npm run prettier-format 98 | 99 | Have prettier format your code. 100 | 101 | ==== npm run test 102 | 103 | Run your unit tests with jest. 104 | 105 | === Environment variables used by the application 106 | 107 | There are many different environment variables used by the application. 108 | Most of them have default values, that can be overridden by exposing a different value as environment variable. 109 | All used variables can be found in the `src/config` folder. 110 | 111 | === Committing code changes 112 | 113 | This app uses https://typicode.github.io/husky[Husky] to trigger some actions during a commit. 114 | Before each commit passes automatic tests and linting is run. If any action fails, the commit fails. 115 | 116 | == Deployment 117 | 118 | The application should run in any environment that provides a Node.js runtime. 119 | The current configuration is suited for an OpenShift deployment. Find detailed instructions here link:./ocp_deployment[OCP Deployment]. 120 | 121 | == Database Setup 122 | 123 | Dedicated documentation for the database setup can be found here link:./db[DB Setup]. 124 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | **Changelog** 2 | ==================================== 3 | **v1.0.24 (2021-07-28)** 4 | ------------------------------------ 5 | * Removed open-choice question from questionnaire 6 | * Removed open-choice question from questionnaire 7 | * Merge pull request #25 from NUMde/fix_questionnaire_structure. Fixed questionnaire structure 8 | 9 | **v1.0.23 (2021-07-22)** 10 | ------------------------------------ 11 | * #20: Cleanup 12 | * Increase size limit of uploaded JSON content 13 | * Merge pull request #21 from lenzch/main. Use Firebase Cloud Messaging for Push 14 | * Merge pull request #24 from NUMde/increase-json-size-limit. Increase size limit of uploaded JSON content 15 | 16 | **v1.0.22 (2021-07-19)** 17 | ------------------------------------ 18 | * of course not the private key but the public key is submitted to the client 19 | * Merge pull request #23 from JohannesOehm/main. Adding information on how to generate and embed certificate 20 | 21 | **v1.0.21 (2021-07-16)** 22 | ------------------------------------ 23 | * Adding information on how to generate and embed certificate 24 | 25 | **v1.0.20 (2021-07-05)** 26 | ------------------------------------ 27 | * Merge branch 'NUMde:main' into main 28 | * add the last commits to the changelog 29 | * Merge branch 'main' of https://github.com/NUMde/compass-numapp-backend Merge with NUM 30 | * Merge pull request #18 from mahvaezi/main (Add Changelog) 31 | * Merge branch 'main' of https://github.com/NUMde/compass-numapp-backend (Merge with Main after add Changelog) 32 | * Merge pull request #22 from mahvaezi/main. Add changes from last commits to changelog.md 33 | 34 | **v1.0.19 (2021-07-02)** 35 | ------------------------------------ 36 | * #20: Document OC setup for FCM 37 | 38 | **v1.0.18 (2021-07-01)** 39 | ------------------------------------ 40 | * #20: Use Firebase Cloud Messaging for Push-Notification (to send the participant messages) 41 | 42 | **v1.0.17 (2021-06-29)** 43 | ------------------------------------ 44 | * Merge pull request #19 from jonathan-reisdorf/cors 45 | (Add cors support for Webapp) 46 | 47 | **v1.0.16 (2021-06-24)** 48 | ------------------------------------ 49 | * Merge pull request #13 from NUMde/generify-tests. 50 | Add hour as parameter for calucation of dates 51 | * Merge pull request #12 from NUMde/fix-spelling 52 | (fix spelling) 53 | * Add cors support for Webapp 54 | 55 | **v1.0.15 (2021-06-16)** 56 | ------------------------------------ 57 | * Update dependencies 58 | * Merge branch 'main' of github.com:NUMde/compass-numapp-backend 59 | * Merge branch 'NUMde:main' into main 60 | * Merge branch 'main' of https://github.com/mahvaezi/compass-numapp-backend 61 | * Merge pull request #16 from mahvaezi/main 62 | (subject_id to subjectId) 63 | 64 | **v1.0.14 (2021-06-15)** 65 | ------------------------------------ 66 | * Changing subject_id to subjectId 67 | * Merge pull request #15 from NUMde/fix_questionnaire_structure 68 | (Removed unnecessary level for valueCodeableConcept to ensure standard compliance) 69 | * Debug Configuration for Unit-Tests 70 | * Merge branch 'NUMde:main' into main 71 | 72 | **v1.0.13 (2021-06-14)** 73 | ------------------------------------ 74 | * Removed unnecessary level for valueCodeableConcept to ensure standard compliance 75 | 76 | **v1.0.12 (2021-06-08)** 77 | ------------------------------------ 78 | * Merge pull request #11 from NUMde/generify-tests 79 | ,Generify tests 80 | 81 | **v1.0.11 (2021-06-07)** 82 | ------------------------------------ 83 | * Merge pull request #8 from lenzch/add-fhir-config-in-api-file 84 | ,Add fhir config in api file 85 | * Move calculation of dates in on method 86 | 87 | **v1.0.10 (2021-06-03)** 88 | ------------------------------------ 89 | * Made the API doc more specific 90 | 91 | **v1.0.9 (2021-05-31)** 92 | ------------------------------------ 93 | * Fixed indentation 94 | 95 | **v1.0.8 (2021-05-28)** 96 | ------------------------------------ 97 | * Merge pull request #10 from NUMde/studyid_fix 98 | ,Applied study_id renaming to tests 99 | * Make tests generic 100 | * Added missing generifications 101 | * Added remark regarding Windows development 102 | 103 | **v1.0.7 (2021-05-27)** 104 | ------------------------------------ 105 | * Applied study_id renaming to tests 106 | 107 | **v1.0.6 (2021-05-20)** 108 | ------------------------------------ 109 | * Merge branch 'extract-gcs-logic' 110 | * Merge pull request #4 from NUMde/mbastian93-patch-1 111 | , Update issue templates 112 | * Renaming leftovers 113 | * Merge branch 'NUMde:main' into add-fhir-config-in-api-file 114 | 115 | **v1.0.5 (2021-05-19)** 116 | ------------------------------------ 117 | * Updated documentation 118 | * Merge branch 'main' into studyId_fix 119 | * Merge pull request NUMde#7 from NUMde/studyId_fix (Merge Study id fix) 120 | 121 | **v1.0.4 (2021-05-18)** 122 | ------------------------------------ 123 | * Renamed folder 124 | * Addition of documentation on how to add study IDs 125 | 126 | **v1.0.3 (2021-05-17)** 127 | ------------------------------------ 128 | * Reworked based on Review comments 129 | 130 | **v1.0.2 (2021-05-12)** 131 | ------------------------------------ 132 | * Specified naming of subject / participant of remaining resources 133 | * Updated route name 134 | 135 | **v1.0.1 (2021-05-11)** 136 | ------------------------------------ 137 | * Removed GCS references 138 | 139 | **v1.0.0 (2021-05-10)** 140 | ------------------------------------ 141 | * Specified naming of subject / participant resources, NUMde#5 (Database column name "study_id" is misleading) 142 | -------------------------------------------------------------------------------- /db/README.adoc: -------------------------------------------------------------------------------- 1 | :important-caption: :heavy_exclamation_mark: 2 | 3 | link:../docs[← Table of Contents] 4 | 5 | == DB Setup 6 | 7 | === Local DB Server 8 | 9 | For development purposes it is beneficial to setup a local db service. 10 | 11 | The SQL database of choice for COMPASS is https://www.postgresql.org/[PostgreSQL]. It is a open source relational database system and is easy to install. 12 | 13 | There are different options for setting up a Postgres instance locally. Two are described in the following sections. 14 | 15 | For any variant of installation please make sure to use the correct version of Postgres. The db schema for compass doesn't have special requirements on the RDBMS, but it is tested and know to be working with Postgres in version 12. 16 | 17 | ==== Docker 18 | 19 | To start an instance with docker, follow the documentation on https://github.com/docker-library/docs/blob/master/postgres/README.md[Postgres image documentation]. Please pay special attention to the section https://github.com/docker-library/docs/blob/master/postgres/README.md#where-to-store-data[Where to Store Data]. 20 | 21 | ==== Native Install 22 | 23 | Follow the OS specific documentation on https://www.postgresql.org/download/[PostgreSQL Downloads] to install Postgres onto your local machine. 24 | 25 | === Remote DB Server 26 | 27 | The setup of a remote db service is out of scope, as it heavily depends on the environment. 28 | 29 | == DB scripts 30 | 31 | Put your db migration scripts here and comply with the naming rules of https://flywaydb.org/documentation/migrations#discovery[Flyway]. Copy the questionnaire https://github.com/NUMde/compass-implementation-guide/blob/master/input/questionnaire-generic.json[here] and paste it to https://github.com/NUMde/compass-numapp-backend/tree/main/db/migration/V002__Initial_Data.sql[SQL-command]. 32 | 33 | 34 | == Steps to Set Up the Schema 35 | 36 | === Connect to Database 37 | .Preconditions 38 | * `oc login` was called and the correct project is selected 39 | * DB runs on default port (5432) 40 | * DB deployment name is database 41 | 42 | Identify the pod that runs the DB: `oc get pod -l name=database -o name` 43 | 44 | Run following command (replace the pod name) to create a port forwarding to the remote database: 45 | 46 | `oc port-forward :` 47 | 48 | E.g. `oc port-forward database-1-ptdq5 15432:5432` 49 | 50 | To check that everything is working run `npx flyway -c db/flyway.js info` from the project root folder. 51 | 52 | === Apply Schema changes to DB 53 | 54 | From the project root folder run `npx flyway -c db/flyway.js migrate` (When you run Flyway the first time, it downloads some binaries and exits. Rerun the command to start the migration.) User and password can be found in the secret database-creds. 55 | 56 | The output should look like this (flyway an postgres versions might differ): 57 | [source] 58 | ---- 59 | $ npx flyway -c db/flyway.js migrate 60 | Database user: MY_DB_USER 61 | Database password: 62 | 63 | Flyway Community Edition 7.5.0 by Redgate 64 | Database: jdbc:database://localhost:15432/compass (PostgreSQL 12.1) 65 | Successfully validated 6 migrations (execution time 00:00.038s) 66 | Current version of schema "public": 001 67 | Migrating schema "public" to version "002 - Initial Data" 68 | Migrating schema "public" to version "003 - Queue Data" 69 | Migrating schema "public" to version "004 - Api User" 70 | Migrating schema "public" to version "005 - Store Device Token" 71 | Migrating schema "public" to version "006 - Questionnaire Version History" 72 | Successfully applied 6 migrations to schema "public", now at version v006 (execution time 00:00.153s) 73 | ---- 74 | 75 | The scripts will setup: 76 | 77 | * 6 tables 78 | * 1 api user (API_ID = "test", API_KEY = "gKdKLYG2g0-Y1EllI0-W") 79 | * 2 study participants 80 | * 17 exemplary queue items (you won't be able to decrypt those with your key, they are just for reference) 81 | 82 | It will not setup an exemplary questionnaire. Therefore, before you can connect the mobile application to the backend, ensure that you add a questionnaire to the table called 'questionnaires'. You can find an exemplary questionnaire https://github.com/NUMde/compass-implementation-guide/blob/master/input/questionnaire-generic.json[here]. 83 | 84 | Instead of inserting questionnaires into the database using sql, you can use REST calls. Those endpoints are described in the openapi documentation, reachable under http://{your-backend-url}/docs. 85 | 86 | == Access DB with Admin Tool 87 | 88 | .Preconditions 89 | * pgAdmin 4 is installed on your local machine 90 | * Port forwarding is established. See <> 91 | 92 | .Connect 93 | . Open PGAdmin 94 | . Click Add new Server 95 | . Fill in a Server Name 96 | . Open tab Connection 97 | . 127.0.0.1:15432 as address 98 | . Enter Username and Password (can be found in the secret database-creds) 99 | . Click Save 100 | 101 | == Access DB with CLI 102 | 103 | .Preconditions 104 | * You identified the pod that runs the DB: `oc get pod -l name=database -o name` 105 | 106 | .Connect 107 | The database name and user can be found in the secret database-creds. 108 | 109 | [source,shell] 110 | ---- 111 | $ oc rsh 112 | sh-4.2$ psql 113 | sampledb=> 114 | ---- 115 | 116 | You can now execute regular queries. 117 | -------------------------------------------------------------------------------- /db/flyway.js: -------------------------------------------------------------------------------- 1 | // Borrowed from: https://github.com/markgardner/node-flywaydb/blob/HEAD/sample/config.js 2 | const dotenv = require('dotenv'); 3 | 4 | module.exports = function () { 5 | dotenv.config('../env'); 6 | const { DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD } = process.env; 7 | return { 8 | flywayArgs: { 9 | // JDBC string that addresses the database 10 | url: `jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}`, 11 | // the default schema of a Postgresql database 12 | schemas: 'public', 13 | // local location where the db migration scripts are stored 14 | locations: 'filesystem:db/migration', 15 | 16 | user: DB_USER, 17 | password: DB_PASSWORD 18 | } 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /db/migration/V001__Setup_Tables.sql: -------------------------------------------------------------------------------- 1 | 2 | -- Table: apiuser 3 | CREATE TABLE apiuser 4 | ( 5 | api_id character varying(355) NOT NULL, 6 | api_key character varying(355) NOT NULL, 7 | api_key_salt character varying(355) NOT NULL, 8 | CONSTRAINT apiuser_pkey PRIMARY KEY (api_id) 9 | ); 10 | 11 | -- Table: questionnairehistory 12 | CREATE TABLE questionnairehistory 13 | ( 14 | id character varying(355) NOT NULL, 15 | subject_id character varying(355) NOT NULL, 16 | questionnaire_id character varying(355) NOT NULL, 17 | date_received timestamp without time zone NOT NULL, 18 | date_sent timestamp without time zone, 19 | instance_id character varying(355), 20 | CONSTRAINT questionnairehistory_pkey PRIMARY KEY (id), 21 | CONSTRAINT questionnairehistory_id_key UNIQUE (id) 22 | ); 23 | 24 | -- Table: questionnaires 25 | CREATE TABLE questionnaires 26 | ( 27 | id character varying(355) NOT NULL, 28 | body json NOT NULL, 29 | CONSTRAINT questionnaires_pkey PRIMARY KEY (id) 30 | ); 31 | 32 | -- Table: queue 33 | CREATE TABLE queue 34 | ( 35 | id character varying NOT NULL, 36 | subject_id character varying NOT NULL, 37 | encrypted_resp text NOT NULL, 38 | date_sent timestamp without time zone NOT NULL, 39 | date_received timestamp without time zone NOT NULL, 40 | downloaded boolean DEFAULT false, 41 | CONSTRAINT queue_pkey PRIMARY KEY (id) 42 | ); 43 | 44 | -- Table: studyparticipant 45 | CREATE TABLE studyparticipant 46 | ( 47 | subject_id character varying NOT NULL, 48 | last_action timestamp without time zone, 49 | current_questionnaire_id character varying, 50 | start_date timestamp without time zone, 51 | due_date timestamp without time zone, 52 | current_instance_id character varying(355), 53 | current_interval smallint, 54 | additional_iterations_left smallint, 55 | status character varying(9) DEFAULT 'on-study', 56 | general_study_end_date DATE DEFAULT '9999-12-31', 57 | personal_study_end_date DATE DEFAULT '9999-12-31', 58 | CONSTRAINT studyparticipant_pkey PRIMARY KEY (subject_id) 59 | ); 60 | -------------------------------------------------------------------------------- /db/migration/V002__Initial_Data.sql: -------------------------------------------------------------------------------- 1 | -- Create test participant 2 | INSERT INTO studyparticipant (subject_id) VALUES('7bfc3b07-a97d-4e11-8ac6-b970c1745476'); 3 | INSERT INTO studyparticipant (subject_id) VALUES('dd06747c-03df-4adb-9dbb-90b38dccab16'); 4 | -------------------------------------------------------------------------------- /db/migration/V004__Api_User.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO apiuser(api_id, api_key, api_key_salt) 2 | VALUES ( 3 | 'test', 4 | 'e363888e31ad8b568cc300334b8db6b3ace0d79f551f90a512b01d64d34b477c94d79907aaf09a2561f339620c08d124a065d03f117cc06d96ec6be0a208b097', 5 | 'ce133c8467f3542c' 6 | ); 7 | -------------------------------------------------------------------------------- /db/migration/V005__Store_Device_Token.sql: -------------------------------------------------------------------------------- 1 | -- Add column to store device token for Firebase push notifications 2 | ALTER TABLE studyparticipant 3 | ADD COLUMN registration_token TEXT; 4 | -------------------------------------------------------------------------------- /db/migration/V006__Questionnaire_Version_History.sql: -------------------------------------------------------------------------------- 1 | -- Table: questionnaire_version_history 2 | CREATE TABLE questionnaire_version_history 3 | ( 4 | id character varying(355) NOT NULL, 5 | url character varying(355) NOT NULL, 6 | version character varying(355) NOT NULL, 7 | name character varying(355) NOT NULL, 8 | body json NOT NULL, 9 | CONSTRAINT questionnaire_version_history_pkey PRIMARY KEY (id), 10 | CONSTRAINT fk_name FOREIGN KEY (name) REFERENCES questionnaires(id) 11 | ); 12 | -------------------------------------------------------------------------------- /db/migration/V007__i18n.sql: -------------------------------------------------------------------------------- 1 | -- Add a language code to the needed places 2 | 3 | -- Table: questionnaires 4 | ALTER TABLE questionnaires ADD language_code varchar NOT NULL DEFAULT 'de'; 5 | COMMENT ON COLUMN questionnaires.language_code IS 'The language the questionnaire is written in. Stored as ISO-639-1 code.'; 6 | 7 | ALTER TABLE questionnaire_version_history DROP CONSTRAINT fk_name; 8 | ALTER TABLE questionnaires DROP CONSTRAINT questionnaires_pkey; 9 | ALTER TABLE questionnaires ADD CONSTRAINT questionnaires_pkey PRIMARY KEY (id,language_code); 10 | 11 | -- Table: studyparticipant 12 | ALTER TABLE studyparticipant ADD language_code varchar NOT NULL DEFAULT 'de'; 13 | 14 | -- Table: questionnaire_version_history 15 | ALTER TABLE questionnaire_version_history ADD language_code varchar NOT NULL DEFAULT 'de'; 16 | ALTER TABLE questionnaire_version_history DROP CONSTRAINT questionnaire_version_history_pkey; 17 | ALTER TABLE questionnaire_version_history ADD CONSTRAINT questionnaire_version_history_pkey PRIMARY KEY (id,language_code); 18 | ALTER TABLE questionnaire_version_history ADD CONSTRAINT questionnaire_version_history_fk FOREIGN KEY (name,language_code) REFERENCES public.questionnaires(id,language_code); 19 | 20 | -- Table: queue 21 | -- TODO decide, if the language should be stored in the queue table as well 22 | -- ALTER TABLE queue ADD language_code varchar NOT NULL DEFAULT 'de'; 23 | 24 | -- Table: questionnairehistory 25 | ALTER TABLE questionnairehistory ADD language_code varchar NOT NULL DEFAULT 'de'; 26 | -------------------------------------------------------------------------------- /db/migration/V008__UpdateTables.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE queue ADD questionnaire_id varchar(255); 2 | ALTER TABLE queue ADD version varchar; 3 | -------------------------------------------------------------------------------- /devfile.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: 1.0.0 3 | metadata: 4 | generateName: numapp-backend- 5 | projects: 6 | - name: compass-backend 7 | source: 8 | location: 'git@eu-de.git.cloud.ibm.com:num/compass/compass-backend.git' 9 | type: git 10 | branch: main 11 | clonePath: COMPASS/compass-backend 12 | components: 13 | - id: redhat/vscode-openshift-connector/latest 14 | memoryLimit: 1280Mi 15 | type: chePlugin 16 | alias: openshift-connector 17 | - id: che-incubator/typescript/latest 18 | memoryLimit: 512Mi 19 | type: chePlugin 20 | - type: chePlugin 21 | id: ms-vscode/node-debug2/latest 22 | preferences: 23 | debug.node.useV3: false 24 | - type: dockerimage 25 | alias: nodejs 26 | image: quay.io/eclipse/che-nodejs10-ubi:nightly 27 | memoryLimit: 512Mi 28 | endpoints: 29 | - name: 'nodejs' 30 | port: 3000 31 | mountSources: true 32 | commands: 33 | - name: download dependencies 34 | actions: 35 | - type: exec 36 | component: nodejs 37 | command: npm install 38 | workdir: ${CHE_PROJECTS_ROOT}/nodejs-web-app/app 39 | - name: run the web app 40 | actions: 41 | - type: exec 42 | component: nodejs 43 | command: nodemon app.js 44 | workdir: ${CHE_PROJECTS_ROOT}/nodejs-web-app/app 45 | - name: run the web app (debugging enabled) 46 | actions: 47 | - type: exec 48 | component: nodejs 49 | command: nodemon --inspect app.js 50 | workdir: ${CHE_PROJECTS_ROOT}/nodejs-web-app/app 51 | - name: stop the web app 52 | actions: 53 | - type: exec 54 | component: nodejs 55 | command: >- 56 | node_server_pids=$(pgrep -fx '.*nodemon (--inspect )?app.js' | tr "\\n" " ") && 57 | echo "Stopping node server with PIDs: ${node_server_pids}" && 58 | kill -15 ${node_server_pids} &>/dev/null && echo 'Done.' 59 | - name: Attach remote debugger 60 | actions: 61 | - type: vscode-launch 62 | referenceContent: | 63 | { 64 | "version": "0.2.0", 65 | "configurations": [ 66 | { 67 | "type": "node", 68 | "request": "attach", 69 | "name": "Attach to Remote", 70 | "address": "localhost", 71 | "port": 9229, 72 | "localRoot": "${workspaceFolder}", 73 | "remoteRoot": "${workspaceFolder}" 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /docs/README.adoc: -------------------------------------------------------------------------------- 1 | = COMPASS Mobile Back End 2 | 3 | https://github.com/NUMde/compass-numapp[Main Repository] 4 | 5 | These docs will provide you with additional information on the back end. 6 | 7 | The following documentation topics ar available (as of now): 8 | 9 | * link:../ocp_deployment[OpenShift Deployment] 10 | * link:../db[DB Setup] 11 | * link:../scripts/createSubjectIDs[Addition of new study participants] 12 | * link:./reminder[Logic of the reminder feature] 13 | * link:./customization[Distribution logic of the questionnaires] 14 | -------------------------------------------------------------------------------- /docs/api_user/README.adoc: -------------------------------------------------------------------------------- 1 | = API User Setup 2 | 3 | For different purposes there is the need for authenticated access to the COMPASS back end service. 4 | One use case is the download of encrypted questionnaire responses, another the management of subject identities. 5 | 6 | For this purpose the application uses API users. 7 | 8 | == Creating API Users 9 | 10 | The creation of API users is done manually by importing the needed values into the database. 11 | 12 | The table that stores the users has the following structure: 13 | 14 | * api_id - a string containing the name of the user 15 | * api_key - the hashed key of the user; for hashing SHA512 is used 16 | * api_key_salt - the salt that was used to hash the key 17 | 18 | To create the hashed key and the salt, one can use the REST api of this application. 19 | On a running instance perform a post request to the route `/api/auth/helper/passwordhash` with a body 20 | of the structure `{"password":"My-Secret-Password"}`. 21 | 22 | The result provides the salt and the hashed password. Those values need to be added into the database. 23 | Either by creating an import script similar to 24 | link:../../db/migration/V004__Api_User.sql[Api User import script] 25 | or by issuing the import statement directly. 26 | 27 | == Accessing the routes as an API user 28 | 29 | The routes that are available only to API users need an authentication token. This token can be aquired by using the `/api/auth` route. Please see the OpenAPI definition for details. 30 | -------------------------------------------------------------------------------- /docs/customization/QuestionnaireStates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NUMde/compass-numapp-backend/04599e9a4f82b51389d43a2e390a7295c9e4254f/docs/customization/QuestionnaireStates.png -------------------------------------------------------------------------------- /docs/customization/README.adoc: -------------------------------------------------------------------------------- 1 | :important-caption: :heavy_exclamation_mark: 2 | 3 | link:../README.adoc[← Table of Contents] 4 | 5 | == Questionnaire distribution logic 6 | 7 | === Current Model 8 | 9 | The current model that is used by the application is defined in link:../../../src/models/ExampleStateModel.ts[ExampleStateModel.ts]. 10 | It was defined by the GCS project and uses four different questionnaires that are send to the study participant based on specific rules. 11 | 12 | The following image shows the different questionnaires and under what condition each one is send to the participants. 13 | -- 14 | .Questionnaire State Chart 15 | image:./QuestionnaireStates.png[Questionnaire State Chart] 16 | -- 17 | 18 | === Implement your own model 19 | 20 | If the requirements regarding the questionnaire state model are not met by the given model defined in link:../../../src/models/ExampleStateModel.ts[ExampleStateModel.ts], 21 | you can provide your own model. 22 | 23 | All you have to do is implement the given interface in link:../../../src/models/StateModel.ts[StateModel.ts] and set the values depending on your requirements. 24 | -------------------------------------------------------------------------------- /docs/reminder/index.adoc: -------------------------------------------------------------------------------- 1 | :important-caption: :heavy_exclamation_mark: 2 | 3 | link:../README.adoc[← Table of Contents] 4 | 5 | == Regeln für die Erinnerungsfunktion 6 | 7 | === 7 Tage Modus (nicht Short-track) 8 | 9 | ==== A - Initialer Download 10 | 11 | Trigger: User logt sich das erste mal in der App ein Aktion: Versende 12 | Erinnerungstext morgens um 6 Uhr ``Es liegt ein neuer Fragebogen für 13 | Sie bereit'' Ende: Participant lädt Fragebogen runter oder vier Erinnerungen 14 | wurden versendet / vier Tage sind vergangen 15 | 16 | DB-Zustand: Start_Date und Due_Date in Participant gesetzt && Due Date nicht 17 | überschritten && *kein* Eintrag in History Table 18 | 19 | ==== B - Beantworten des Fragebogens 20 | 21 | Trigger: Participant lädt Fragebogen runter Aktion: Versende Erinnerungstext 22 | morgens um 6 Uhr ``Bitte denken Sie an das Absenden Ihres 23 | Fragebogens'' Ende: Participant lädt Fragebogen hoch oder vier Erinnerungen 24 | wurden versendet / vier Tage sind vergangen 25 | 26 | DB-Zustand: Start_Date und Due_Date in Participant gesetzt && Due Date nicht 27 | überschritten && Eintrag in History Table mit `date_sent = null` 28 | 29 | === 2 Tage Modus (Short-track) 30 | 31 | Trigger für den Modus: Meldebutton gedrückt oder ein Fragebogen mit 32 | positiven Triggerfrage wurde abgesendet 33 | 34 | ==== C - Meldebutton 35 | 36 | Trigger: Meldebutton gedrückt Aktion: Versende Erinnerungstext morgens 37 | um 6 Uhr ``Bitte denken Sie an das Absenden Ihres Fragebogens'' 38 | (Etwas andere Wortwahl!!) Ende: Participant lädt Fragebogen hoch oder zwei 39 | Erinnerungen wurden versendet 40 | 41 | DB-Zustand: Start_Date und Due_Date in Participant gesetzt && Due Date nicht 42 | überschritten && Eintrag in History Table mit `date_sent = null` (wie 43 | link:#b---beantworten-des-fragebogens[B], aber Achtung, da mehrere 44 | Einträge in History Table vorhanden) 45 | 46 | ==== D - Triggerfrage - Noch nicht geladen 47 | 48 | Trigger: Fragebogen mit positiven Triggerfragen wurde abgesendet Aktion: 49 | Versende Erinnerungstext morgens um 6 Uhr ``Es liegt ein neuer 50 | Fragebogen für Sie bereit'' Versende Erinnerungstext sofort ``Es 51 | liegt ein neuer Fragebogen für Sie bereit'' (kann das die App nicht 52 | machen?) Ende: Participant lädt Fragebogen runter oder zwei Erinnerungen wurden 53 | versendet 54 | 55 | DB-Zustand: Start_Date und Due_Date in Participant gesetzt && Due Date nicht 56 | überschritten && *kein* Eintrag in History Table (wie 57 | link:#a---initialer-download[A]) 58 | 59 | ==== E - Triggerfrage - Geladen 60 | 61 | Trigger: Fragebogen mit positiven Triggerfragen wurde abgesendet Aktion: 62 | Versende Erinnerungstext morgens um 6 Uhr ``Bitte denken Sie an das 63 | Absenden Ihres Fragebogens'' Ende: Participant lädt Fragebogen hoch oder 64 | zwei Erinnerungen wurden versendet 65 | 66 | DB-Zustand: Start_Date und Due_Date in Participant gesetzt && Due Date nicht 67 | überschritten && Eintrag in History Table mit `date_sent = null` (wie 68 | link:#b---beantworten-des-fragebogens[B]) 69 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { '^.+\\.ts?$': 'ts-jest' }, 3 | testEnvironment: 'node', 4 | testRegex: '/tests/.*\\.(test|spec)?\\.(ts|tsx)$', 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] 6 | }; 7 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "restartable": "rs", 3 | "ignore": [".git", "node_modules/**/node_modules"], 4 | "verbose": true, 5 | "execMap": { 6 | "ts": "node --require ts-node/register" 7 | }, 8 | "watch": ["src/", ".env", "test.ts"], 9 | "env": { 10 | "NODE_ENV": "development" 11 | }, 12 | "ext": "js,json,ts" 13 | } 14 | -------------------------------------------------------------------------------- /ocp_deployment/README.adoc: -------------------------------------------------------------------------------- 1 | :important-caption: :heavy_exclamation_mark: 2 | 3 | link:../docs[← Table of Contents] 4 | 5 | == Deployment of backend components on Red Hat OpenShift 6 | 7 | === Background 8 | 9 | ==== What is Red Hat OpenShift? 10 | 11 | The Red Hat OpenShift Container Platform is a collection of services and components that extend the Kubernetes container infrastructure. OpenShift simplifies the management of containerized application and provides monitoring and auditing capabilities with a high focus on security. The operating system used by OpenShift is Red Hat Enterprise Linux CoreOS 12 | 13 | Refer to the section on <> for more details on Red Hat and OpenShift. 14 | 15 | ==== Why OpenShift? 16 | 17 | OpenShift is the preferred plattform for the provisioning of the COMPASS backend components based on the following advantages: 18 | 19 | 1. Openshift guarantees *no vendor lock-in* 20 | 2. OpenShift is *open source* 21 | 3. OpenShift provides integrated developer workflows such as https://github.com/openshift/source-to-image[S2I] 22 | 4. Logging and metric services can be used out of the box 23 | 5. The web console provides unified access to the OpenShift capabilities 24 | 25 | You can freely choose how to use OpenShift. 26 | Either run a cluster on your local machine with https://developers.redhat.com/products/codeready-containers/overview[CodeReady Containers], use https://www.okd.io/#v3[OKD] - The (free) Community Distribution of Kubernetes that powers Red Hat OpenShift - or set up an OpenShift cluster with your preferred Cloud provider such as https://www.ibm.com/cloud/openshift[Red Hat OpenShift on IBM Cloud], https://aws.amazon.com/de/quickstart/architecture/openshift/[Red Hat OpenShift in AWS], or https://azure.microsoft.com/de-de/services/openshift/[Azure Red Hat OpenShift]. 27 | 28 | ==== OpenShift Templates 29 | 30 | This project provides templates to automate the setup of all necessary OpenShift resources. A template defines objects which will be created on the OpenShift Container Platform when it is processed. Information on templates is available https://docs.openshift.com/container-platform/4.6/openshift_images/using-templates.html[here]. 31 | 32 | === Prerequisites 33 | 34 | Before you can set up the COMPASS backend components you need a running OpenShift Cluster. 35 | Define in which project you want to deploy the backend components. It is recommended, to use separate OpenShift projects for development and production. 36 | 37 | * Login to your OpenShift Cluster using the CLI (https://docs.openshift.com/enterprise/3.2/cli_reference/get_started_cli.html[details]) 38 | * Check existing SSH key connection (https://docs.github.com/en/github/authenticating-to-github/adding-a-new-ssh-key-to-your-github-account[details]) 39 | 40 | [#developmentDeployment] 41 | === Development Deployment 42 | 43 | The backend components - mobile backend and PostgreSQL database - can easily be set up via templates. 44 | Different template files are provided for the development and production environment. Refer to the following sections for more information: 45 | 46 | * <> 47 | * <> 48 | * <> 49 | * <> 50 | 51 | [#mbSetup] 52 | ==== Mobile Backend Setup 53 | 54 | The template file compass-backend-template.yaml is designed to create all resources necessary for the deployment of the mobile backend including an instance of a persistent PostgreSQL database. It generates the following resources: 55 | 56 | Resources for PostgreSQL database: 57 | 58 | * 1 Service 59 | * 1 Deployment Config 60 | * 2 Secrets including database parameters and access data 61 | * 1 Persistent Volume Claim 62 | 63 | Resources for Mobile Backend: 64 | 65 | * 1 Service 66 | * 1 Image Stream 67 | * 1 Route 68 | * 1 Build Config 69 | * 1 Deployment Config 70 | * 4 Secrets for the webhooks and the git deploy key 71 | * 1 Config map 72 | 73 | 74 | The following step can be skipped, if you use a public repository. 75 | 76 | Before you can process the template, you must set up a read-only SSH key pair to configure access for OpenShift to your Git-based SCM tool. Please note: This should not be your personal SSH key pair, but a dedicated deploy key which is used to permit read-access to the mobile back end repository. 77 | 78 | In your .ssh directory generate a key pair with: 79 | 80 | [source,shell] 81 | ---- 82 | $ ssh-keygen -o -a 100 -t ed25519 -f FileName -C "Comment" -N '' 83 | ---- 84 | 85 | Please do not set a passphrase for this SSH key, as this will prevent OpenShift from building successfully. (https://docs.openshift.com/online/pro/dev_guide/builds/build_inputs.html[details]) 86 | 87 | Insert the newly created public key in the deploy keys section of your Git-repository. Check out the documentation on how this is done for https://docs.github.com/en/free-pro-team@latest/developers/overview/managing-deploy-keys[GitHub] and https://docs.gitlab.com/ee/user/project/deploy_keys/[GitLab]. 88 | 89 | You can now apply the compass-backend-template and create the resources stated above via the command below. When using a Windows machine preferably run the command in the Git Bash or Commander in order to prevent problems with the Linux commands cat and base64. 90 | 91 | [source,shell] 92 | ---- 93 | $ oc new-app --file your/path/to/compass-backend-template.yaml \ 94 | > -n your_openshift_project_name \ 95 | > -p APP_GIT_URL="repository_link" \ 96 | > -p "PUBLIC_KEY=$(echo "$(cat your/path/to/public_key)" | base64 )" \ 97 | > -p "PRIVATE_KEY=$(echo "$(cat your/path/to/private_key)" | base64 )" \ 98 | > -p "CERTIFICATE=$(echo "$(cat your/path/to/certificate)" | base64 )" \ 99 | > -p "SSH_PRIVATE_KEY=$(echo "$(cat your/path/to/private_deploy_key)" | base64 )" \ 100 | > [-p param=value] #optional 101 | ---- 102 | 103 | [horizontal] 104 | APP_GIT_URL:: "Clone with SSH" link to the Git-repository if repository is private and a deploy key is set. Otherwise use HTTPS link. 105 | PRIVATE_KEY:: Private SSH key of mobile backend corresponding to PUBLIC_KEY, needed for signature creation of encrypted documents in backend 106 | PUBLIC_KEY:: Public SSH key of mobile backend corresponding to PRIVATE_KEY, primarily used for testing purposes for signature verification. 107 | CERTIFICATE:: The Certificate used for the encryption of the questionnaire responses by the NUM app. This is optional, so you can just leave this parameter out, if for example you set it in the frontend, i.e. the app 108 | SSH_PRIVATE_KEY:: Dedicated private deploy key added to your Git-repository for authentication to deploy a private repository to OpenShift. In case the repository is public, you can omit this parameter. 109 | 110 | You obtain more information on the template parameters via the following command: 111 | [source,shell] 112 | ---- 113 | $ oc process --parameters -f your/path/to/compass-backend-template.yaml 114 | ---- 115 | 116 | If you want to observe the build, use `oc logs -f bc/mobile-backend`` 117 | 118 | To see the resulting OpenShift resources, use `oc status` and make sure you take a look at the Topology View in the OpenShift web console: 119 | image:images/NUMapp-backend_TopologyView.png[OpenShift Developer Perspective - Project Topology] 120 | 121 | The build config for the mobile backend is configured with a webhook. Add this to your Git repository in order to automatically deploy a new version of the app when a new commit is made. Consult the documentation of https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/about-webhooks[GitHub] and https://docs.gitlab.com/ee/user/project/integrations/webhooks.html[GitLab] on how this is done. 122 | 123 | [#dbSetup] 124 | ==== Database Setup 125 | 126 | After you have created the database and mobile backend resources you can setup the database. 127 | Follow the documentation in the db/migration directory. 128 | 129 | [#productionDeployment] 130 | === Production Deployment 131 | 132 | It is recommended to perform the setup for production in a separate project. 133 | 134 | ==== Mobile Backend Setup 135 | 136 | Two deployment types are suggested for the mobile backend. Both are visualized in the picture below. 137 | 138 | image:images/Deployment.png[Alt Deployment] 139 | 140 | IMPORTANT: For both types it is strongly recommended to setup limit ranges for the resource consumption. Refer to the end of the <> on how to do this. 141 | 142 | ===== Preferred Method 143 | 144 | The preferred method for deploying the mobile backend in the production environment is to manually update the corresponding image stream. Thereby, you can use a pre-existing image which has been fully tested before. 145 | 146 | Use the template named compass-backend-template-*prod*.yaml to set up the required resources. 147 | [source,shell] 148 | ---- 149 | $ oc new-app --file your/path/to/compass-backend-template-prod.yaml \ 150 | > -n your_openshift_project_name 151 | > -p "PUBLIC_KEY=$(echo "$(cat your/path/to/public_key)" | base64 )" \ 152 | > -p "PRIVATE_KEY=$(echo "$(cat your/path/to/private_key)" | base64 )" \ 153 | > -p "CERTIFICATE=$(echo "$(cat your/path/to/certificate)" | base64 )" 154 | > [-p param=value] #optional 155 | ---- 156 | 157 | You can obtain more information on the template parameters via the following command: 158 | [source,shell] 159 | ---- 160 | $ oc process --parameters -f your/path/to/compass-backend-template-prod.yaml 161 | ---- 162 | 163 | The template creates the following resources: 164 | 165 | 166 | Resources for PostgreSQL database: 167 | 168 | * 1 Service 169 | * 1 Deployment Config 170 | * 2 Secrets including database parameters and access data 171 | * 1 Persistent Volume Claim 172 | 173 | Resources for Mobile Backend: 174 | 175 | * 1 Service 176 | * 1 Image Stream 177 | * 1 Route 178 | * 1 Deployment Config 179 | * 1 Config map 180 | 181 | Update the created image stream to point to the latest image that was build in the development environment. 182 | [source,shell] 183 | ---- 184 | $ oc tag your_dev_project/name_of_dev_is:dev \ 185 | > your_prod_project/name_of_prod_is:prod 186 | ---- 187 | 188 | This will *not* result in an automatic update of the tag, if you issue a new build in the development environment. 189 | 190 | Use this command whenever you want to start a new production deployment from a new image. 191 | If you wish to target another image than the latest, reference it specifically in the `oc tag` command. 192 | 193 | ===== Simple Method 194 | 195 | Alternatively, you can use the same procedure as described in the *Development Deployment* section, with the only difference that new builds are triggered manually. Thereby you can decide which code base to deploy to production. 196 | 197 | Apply the compass-backend-template and specify the necessary parameters. 198 | 199 | IMPORTANT: The template will generate webhooks, which you can use. However, it is *not* recommended to set up automatic build hooks for production environments. 200 | 201 | You can either trigger a new build via the OpenShift web console or via the CLI. Both options will automatically result in a new deployment after the build is successfully completed. 202 | 203 | Use the *Developer* view of the web console to navigate to the build configuration of the mobile backend in the *Builds* section. Start a new build by selecting the corresponding option from the *Actions* drop-down. 204 | 205 | Use the following command if you prefer to start a new build via the CLI: 206 | 207 | [source,shell] 208 | ---- 209 | $ oc start-build bc/name_of_your_bc -n 210 | ---- 211 | 212 | ==== Database Setup 213 | 214 | Refer to <>. 215 | 216 | ==== Push Service Credentials (FCM) 217 | 218 | Step 1. Create service account credentials for Firebase Cloud Messaging by following the steps outlined here: https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually and download the service account ".json" 219 | 220 | Step 2. We will create a secret with the file as content. 221 | First we need to encode the file content: 222 | [source,shell] 223 | ---- 224 | $ base64 -i ~/path/to/downloads/credentials.json 225 | ---- 226 | 227 | Step 3. Import the secret into your OpenShift project. 228 | Copy "ocp_deployment/templates/google-services-secret.yaml.sample" and rename to "ocp_deployment/templates/google-services-secret.yaml" 229 | Replace the string "BASE64_CREDENTIAL_STRING" in this file with the encoded content generated in the previous step. 230 | 231 | Step 4. Deploy the secret to the cluster: 232 | [source,shell] 233 | ---- 234 | $ oc create -f google-services-secret.yaml 235 | ---- 236 | 237 | Step5. Update Deployment Config to use the secret as file. 238 | For example: 239 | [source,yaml] 240 | ---- 241 | kind: DeploymentConfig 242 | apiVersion: apps.openshift.io/v1 243 | metadata: 244 | name: mobile-backend 245 | spec: 246 | 247 | template: 248 | spec: 249 | containers: 250 | - resources: {} 251 | ... 252 | env: 253 | - name: GOOGLE_APPLICATION_CREDENTIALS 254 | value: /opt/app-root/secrets/google-services.json 255 | volumeMounts: 256 | - name: secrets 257 | mountPath: /opt/app-root/secrets 258 | readOnly: true 259 | volumes: 260 | - name: secrets 261 | secret: 262 | secretName: google-services-secret 263 | ---- 264 | 265 | [#troubleshooting] 266 | === Troubleshooting 267 | 268 | [horizontal] 269 | "No such file or directory":: Make sure, the paths to all files and directories are specified correctly (absolute and relative path notation). 270 | 271 | "Permission denied (publickey,keyboard-interactive)":: Verify your repository access rights. Permission will also be denied, if your deploy key (SSH_PRIVATE_KEY) was created with a passphrase. 272 | 273 | "dquote>":: A double quote character (") is not properly closed. Only use pairs of quote characters and check for wrong character formats (“) which can occur during copy-paste processes. 274 | 275 | Wiping Project Resources:: To re-run "oc new-app", make sure all resources are deleted properly. Delete all resources using the OpenShift UI in Administrator view, or use "oc delete all --all" and "oc delete secrets --all" and verify every resource is deleted in the UI. You may have to manually remove Persistent Volume Claims (PVC) and Config Maps. 276 | 277 | 278 | 279 | [#furtherInformation] 280 | === Further Information 281 | 282 | * OKD 4 (free community distribution of kubernetes, that powers OpenShift): https://www.okd.io/ 283 | * Red Hat OpenShift Documentation: https://docs.openshift.com/ 284 | * PostgreSQL: https://www.postgresql.org/ 285 | * PostgreSQL template: https://docs.okd.io/latest/using_images/db_images/postgresql.html 286 | -------------------------------------------------------------------------------- /ocp_deployment/images/Deployment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NUMde/compass-numapp-backend/04599e9a4f82b51389d43a2e390a7295c9e4254f/ocp_deployment/images/Deployment.png -------------------------------------------------------------------------------- /ocp_deployment/images/NUMapp-backend_TopologyView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NUMde/compass-numapp-backend/04599e9a4f82b51389d43a2e390a7295c9e4254f/ocp_deployment/images/NUMapp-backend_TopologyView.png -------------------------------------------------------------------------------- /ocp_deployment/images/PostgreSQLSetup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NUMde/compass-numapp-backend/04599e9a4f82b51389d43a2e390a7295c9e4254f/ocp_deployment/images/PostgreSQLSetup.png -------------------------------------------------------------------------------- /ocp_deployment/templates/.gitignore: -------------------------------------------------------------------------------- 1 | google-services-secret.yaml 2 | -------------------------------------------------------------------------------- /ocp_deployment/templates/google-services-secret.yaml.sample: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: google-services-secret 5 | type: Opaque 6 | data: 7 | google-services.json: BASE64_CREDENTIAL_STRING 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compass-backend", 3 | "version": "1.0.0", 4 | "description": "compass-backend", 5 | "main": "app.ts", 6 | "private": true, 7 | "dependencies": { 8 | "@cloudnative/health-connect": "2.1.0", 9 | "@overnightjs/core": "1.7.6", 10 | "cors": "2.8.5", 11 | "cron": "2.4.3", 12 | "dotenv": "16.3.1", 13 | "env-var": "7.4.1", 14 | "express": "4.18.2", 15 | "express-jwt": "8.4.1", 16 | "firebase-admin": "11.11.1", 17 | "jet-logger": "1.3.1", 18 | "js-yaml": "4.1.0", 19 | "jsonwebtoken": "9.0.2", 20 | "jws": "4.0.0", 21 | "pg": "8.11.3", 22 | "swagger-ui-express": "5.0.0", 23 | "uuid": "9.0.0" 24 | }, 25 | "devDependencies": { 26 | "@types/cron": "2.0.1", 27 | "@types/express": "4.17.17", 28 | "@types/express-jwt": "6.0.4", 29 | "@types/jest": "29.5.4", 30 | "@types/jsonwebtoken": "9.0.2", 31 | "@types/jws": "3.2.5", 32 | "@types/node": "18.17.14", 33 | "@types/pg": "8.10.2", 34 | "@types/request": "2.48.8", 35 | "@types/swagger-ui-express": "4.1.3", 36 | "@types/uuid": "9.0.3", 37 | "@typescript-eslint/eslint-plugin": "6.6.0", 38 | "@typescript-eslint/parser": "6.6.0", 39 | "cpx2": "5.0.0", 40 | "eslint": "8.48.0", 41 | "eslint-config-prettier": "9.0.0", 42 | "eslint-formatter-github-annotations": "0.1.0", 43 | "eslint-plugin-jest": "27.2.3", 44 | "eslint-plugin-prettier": "5.0.0", 45 | "husky": "8.0.3", 46 | "jest": "29.6.4", 47 | "node-flywaydb": "3.0.7", 48 | "nodemon": "3.0.1", 49 | "npm-run-all": "4.1.5", 50 | "prettier": "3.0.3", 51 | "rimraf": "5.0.1", 52 | "run-script-os": "1.1.6", 53 | "ts-jest": "29.1.1", 54 | "ts-node": "10.9.1", 55 | "typescript": "5.2.2" 56 | }, 57 | "engines": { 58 | "node": ">=18.0.0" 59 | }, 60 | "repository": { 61 | "type": "git", 62 | "url": "https://github.com/NUMde/compass-numapp-backend.git" 63 | }, 64 | "author": "IBM Corp.", 65 | "contributors": [ 66 | { 67 | "name": "Sebastian Kowalski", 68 | "email": "sebastian.kowalski@de.ibm.com" 69 | }, 70 | { 71 | "name": "Christian Lenz", 72 | "email": "christian.lenz@de.ibm.com" 73 | } 74 | ], 75 | "scripts": { 76 | "start": "node build/app.js", 77 | "dev": "nodemon src/app.ts", 78 | "dev:debug": "nodemon --inspect src/app.ts", 79 | "dev:watch": "./node_modules/nodemon/bin/nodemon.js -e ts --exec \"npm run build:src\"", 80 | "build": "run-s -ls clean build:src build:static", 81 | "build:src": "tsc", 82 | "build:static": "cpx \"src/assets/**\" \"build/assets\"", 83 | "clean": "rimraf build .tsbuildinfo", 84 | "lint": "eslint . --ext .ts", 85 | "lint:report": "eslint . --ext .ts --output-file eslint_report.json --format json", 86 | "lint:annotate": "eslint -f github-annotations . --ext .js,.ts", 87 | "prettier-format": "run-script-os", 88 | "prettier-format:win32": "prettier --config .prettierrc \"./src/**/*.ts\" --write", 89 | "prettier-format:darwin:linux": "prettier --config .prettierrc 'src/**/*.ts' --write", 90 | "prettier-format:default": "prettier --config .prettierrc 'src/**/*.ts' --write", 91 | "test": "jest", 92 | "coverage": "jest --coverage", 93 | "prepare": "husky install" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "timezone": "Europe/Berlin", 4 | "packageRules": [ 5 | { 6 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 7 | "automerge": true 8 | } 9 | ], 10 | "schedule": ["after 6pm every weekday", "before 5am every weekday", "every weekend"] 11 | } 12 | -------------------------------------------------------------------------------- /scripts/createSubjectIDs/README.adoc: -------------------------------------------------------------------------------- 1 | = Addition of Study Participants 2 | 3 | Study participants must be registered in the database with their subject id in order to gain access to the app. 4 | The script in this folder can be used for this purpose. It establishes a connection to the database and inserts the subject ids into the table "studyparticipant". 5 | 6 | == Prerequisites 7 | 8 | Before you can execute the creation script you require a text file named "SUBJECTID_input.txt" with the subject ids that you wish to register. These are the identifiers which you provide to the study participants in the form of QR-codes. The file should be located in the same folder as the creation script. The file name is referenced in the script by the constant named _fileName_. Change this value if your file has another name. 9 | 10 | Provide the list of subject ids in the file "SUBJECTID_input.txt", separated by single (!) line breaks. 11 | 12 | The content of the file could look like this: 13 | 14 | [source] 15 | ---- 16 | 96329187-8b46-48b3-894f-9a89d9a7ae0c 17 | 105a4c05-aa44-4ae1-9803-16e62c174c0d 18 | 7160c75c-a9be-48ba-bb77-73a8aea21f74 19 | f55bb4cb-bdae-4ec0-ad31-c12b3426c8bc 20 | 8d7b202e-7fad-4578-a154-70043e2364c9 21 | ---- 22 | 23 | Apart from the list of subject ids, you require a _.env_ file which contains the required values for the database connection:DB_USERNAME, DB_HOST, DB_DB, DB_PASSWORD, DB_PORT, DB_USE_SSL. To get started copy the file `.env.sample` to `.env` and add your values. Read the link:../../db/README.adoc[database documentation] for further information on how to locally connect to your database. 24 | 25 | == Script Execution 26 | 27 | Navigate into the _createSubjectIDs_ folder: 28 | 29 | [source,shell] 30 | ---- 31 | cd /your/path/to/compass-numapp-backend/scripts/createSubjectIDs 32 | ---- 33 | 34 | Run the script: 35 | [source,shell] 36 | ---- 37 | node createSubjectIDs.js 38 | ---- 39 | 40 | This will iterate over the the input document line by line and add the subject ids to your database. -------------------------------------------------------------------------------- /scripts/createSubjectIDs/createSubjectIDs.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | require('dotenv').config({ path: __dirname + '/./../../.env' }); 6 | const { Pool } = require('pg'); 7 | const fs = require('fs'); 8 | const readline = require('readline'); 9 | 10 | const fileName = 'SUBJECTID_input.txt'; 11 | 12 | async function processLineByLine(pool) { 13 | const fileStream = fs.createReadStream(fileName); 14 | 15 | const rl = readline.createInterface({ 16 | input: fileStream, 17 | crlfDelay: Infinity 18 | }); 19 | // Note: we use the crlfDelay option to recognize all instances of CR LF 20 | // ('\r\n') in SUBJECTID_input.txt as a single line break. 21 | 22 | let i = 1; 23 | 24 | for await (const line of rl) { 25 | // Each line in input.txt will be successively available here as `line`. 26 | console.log('Processing line ' + i + ' with content: ' + line); 27 | try { 28 | await pool.query('INSERT INTO studyparticipant(subject_id) VALUES ($1);', [line]); 29 | } catch (err) { 30 | console.log(err); 31 | process.exit(1); 32 | } 33 | i++; 34 | } 35 | } 36 | 37 | async function start() { 38 | const sslConnOptions = {}; 39 | if (process.env.DB_USE_SSL === 'true') { 40 | sslConnOptions.rejectUnauthorized = true; 41 | try { 42 | sslConnOptions.ca = Buffer.from(process.env.DB_SSL_CA, 'base64').toString(); 43 | } catch (err) { 44 | console.warn( 45 | "Cannot get CA from environment variable DB_SSL_CA. Self-signed certificates in DB connection won't work!" 46 | ); 47 | } 48 | } 49 | 50 | const pool = new Pool({ 51 | user: process.env.DB_USERNAME, 52 | host: process.env.DB_HOST, 53 | database: process.env.DB_DB, 54 | password: process.env.DB_PASSWORD, 55 | port: process.env.DB_PORT, 56 | ssl: process.env.DB_USE_SSL ? sslConnOptions : false 57 | }); 58 | 59 | // you can also use async/await 60 | const res = await pool.query('SELECT NOW()'); 61 | console.log(res.rows[0]); 62 | 63 | await processLineByLine(pool); 64 | 65 | await pool.end(); 66 | } 67 | 68 | start(); 69 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | import { Environment } from './config/Environment'; 6 | import ExpressServer from './server/ExpressServer'; 7 | 8 | // the starting point of the application 9 | const expressServer = new ExpressServer(); 10 | expressServer.start(Environment.getPort()); 11 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/NUMde/compass-numapp-backend/04599e9a4f82b51389d43a2e390a7295c9e4254f/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/config/AuthConfig.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { randomBytes } from 'crypto'; 5 | import * as env from 'env-var'; 6 | 7 | import * as jwtManager from 'jsonwebtoken'; 8 | 9 | /** 10 | * Config holder for the authentication logic. 11 | */ 12 | const AuthConfig = { 13 | // the json webtoken secret which is either read from an environment variable (if set) or randomly generated 14 | jwtSecret: env.get('JWT_SECRET').default(randomBytes(256).toString('base64')).asString(), 15 | 16 | // Flag to toggle a time check for the API authentication 17 | enableTimeCheckForAPIAuth: env.get('AUTH_USE_API_TIME_CHECK').default('true').asBoolStrict(), 18 | 19 | //create json webtoken from payload and secret with validity of 30 minutes 20 | sign: (payload: Record): string => { 21 | return jwtManager.sign(payload, AuthConfig.jwtSecret, { 22 | algorithm: 'HS256', 23 | expiresIn: '30m' 24 | }); 25 | } 26 | }; 27 | 28 | export { AuthConfig }; 29 | -------------------------------------------------------------------------------- /src/config/COMPASSConfig.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import * as env from 'env-var'; 5 | 6 | export class COMPASSConfig { 7 | /** 8 | * Gets the value the type-attribute of a request must hold so that the backend knows that 9 | * its body holds an encrypted questionnaire response. This is necessary because of said encryption, 10 | * as there is no other way to determine what the body holds without decrypting it. 11 | * 12 | * @static 13 | * @return {*} {string} 14 | * @memberof COMPASSConfig 15 | */ 16 | public static getQuestionnaireResponseType(): string { 17 | return env.get('COMPASS_QR_REQUEST_TYPE').default('questionnaire_response').asString(); 18 | } 19 | 20 | /** 21 | * Gets the default interval for a regular questionnaire. This determines after what amount of days a new iteration starts. 22 | * 23 | * @static 24 | * @return {*} {number} 25 | * @memberof COMPASSConfig 26 | */ 27 | public static getDefaultInterval(): number { 28 | return env.get('COMPASS_DEFAULT_INTERVAL').default(7).asIntPositive(); 29 | } 30 | 31 | /** 32 | * Get the default duration for a regular questionnaire. This determines how many days the participant has to complete the questionnaire after the start of an interval. 33 | * 34 | * @static 35 | * @return {*} {number} 36 | * @memberof COMPASSConfig 37 | */ 38 | public static getDefaultDuration(): number { 39 | return env.get('COMPASS_DEFAULT_DURATION').default(3).asIntPositive(); 40 | } 41 | 42 | /** 43 | * Gets the default interval of the short track. 44 | * 45 | * Defaults to 2 46 | * @static 47 | * @return {*} {number} 48 | * @memberof COMPASSConfig 49 | */ 50 | public static getDefaultShortInterval(): number { 51 | return env.get('COMPASS_DEFAULT_SHORT_INTERVAL').default(2).asIntPositive(); 52 | } 53 | 54 | /** 55 | * Gets the default duration of the short track. 56 | * 57 | * @static 58 | * @return {*} {number} 59 | * @memberof COMPASSConfig 60 | */ 61 | public static getDefaultShortDuration(): number { 62 | return env.get('COMPASS_DEFAULT_SHORT_DURATION').default(1).asIntPositive(); 63 | } 64 | 65 | /** 66 | * Gets the default due hour for regular questionnaires. It is the full hour of the day, when a regular questionnaire has to be submitted. 67 | * 68 | * @static 69 | * @return {*} {number} 70 | * @memberof COMPASSConfig 71 | */ 72 | public static getDefaultDueHour(): number { 73 | return env.get('COMPASS_DEFAULT_DUE_HOUR').default(18).asIntPositive(); 74 | } 75 | 76 | /** 77 | * Gets the default start hour for regular questionnaires. The full hour of day, when a regular questionnaire starts. 78 | * 79 | * @static 80 | * @return {*} {number} 81 | * @memberof COMPASSConfig 82 | */ 83 | public static getDefaultStartHour(): number { 84 | return env.get('COMPASS_DEFAULT_START_HOUR').default(6).asIntPositive(); 85 | } 86 | 87 | /** 88 | * Gets the default due hour for questionnaires on the short track. It is the time of the day, when a questionnaire has to be submitted for the short track. 89 | * 90 | * @static 91 | * @return {*} {number} 92 | * @memberof COMPASSConfig 93 | */ 94 | public static getDefaultShortDueHour(): number { 95 | return env.get('COMPASS_DEFAULT_SHORT_DUE_HOUR').default(18).asIntPositive(); 96 | } 97 | 98 | /** 99 | * Gets the default start hour for questionnaires on the short track. The time of day, when a questionnaire starts for the short track. 100 | * 101 | * @static 102 | * @return {*} {number} 103 | * @memberof COMPASSConfig 104 | */ 105 | public static getDefaultShortStartHour(): number { 106 | return env.get('COMPASS_DEFAULT_SHORT_START_HOUR').default(6).asIntPositive(); 107 | } 108 | 109 | /** 110 | * The iterative questionnaire track only lasts for a predetermined number of iteration. This getter provides that value. 111 | * 112 | * @static 113 | * @return {*} {number} 114 | * @memberof COMPASSConfig 115 | */ 116 | public static getDefaultIterationCount(): number { 117 | return env.get('COMPASS_DEFAULT_ITERATION_COUNT').default(5).asIntPositive(); 118 | } 119 | 120 | /** 121 | * The id of the initial questionnaire. 122 | * 123 | * @static 124 | * @return {*} {string} 125 | * @memberof COMPASSConfig 126 | */ 127 | public static getInitialQuestionnaireId(): string { 128 | return env.get('COMPASS_INITIAL_QUESTIONNAIRE_ID').default('initial').asString(); 129 | } 130 | 131 | /** 132 | * The id of the default questionnaire. 133 | * 134 | * @static 135 | * @return {*} {string} 136 | * @memberof COMPASSConfig 137 | */ 138 | public static getDefaultQuestionnaireId(): string { 139 | return env 140 | .get('COMPASS_DEFAULT_QUESTIONNAIRE_ID') 141 | .default('https://num-compass.science/fhir/Questionnaires/GECCO|1.0') 142 | .asString(); 143 | } 144 | 145 | /** 146 | * The id if the questionnaire for the short track. 147 | * 148 | * @static 149 | * @return {*} {string} 150 | * @memberof COMPASSConfig 151 | */ 152 | public static getDefaultShortQuestionnaireId(): string { 153 | return env.get('COMPASS_DEFAULT_SHORT_QUESTIONNAIRE_ID').default('q_1.0').asString(); 154 | } 155 | 156 | /** 157 | * The id of the questionnaire with limited interval and on short track. 158 | * 159 | * @static 160 | * @return {*} {string} 161 | * @memberof COMPASSConfig 162 | */ 163 | public static getDefaultShortLimitedQuestionnaireId(): string { 164 | return env 165 | .get('COMPASS_DEFAULT_SHORT_LIMITED_QUESTIONNAIRE_ID') 166 | .default('7f13fc11-51ed-4277-b92e-770e9739895b') 167 | .asString(); 168 | } 169 | 170 | /** 171 | * Gets the starting index for a new interval. Example: If a participant sends in a report, he/she switches to 172 | * another track. Does this track start today (meaning now) or tomorrow morning? 173 | * With the defaultInterval being 0 the new track starts immediately, with 1 the track would start tomorrow. 174 | * 175 | * Defaults to 1. 176 | * 177 | * @static 178 | * @return {*} {number} 179 | * @memberof COMPASSConfig 180 | */ 181 | public static getDefaultIntervalStartIndex(): number { 182 | return env.get('COMPASS_DEFAULT_INTERVAL_START_INDEX').default(1).asIntPositive(); 183 | } 184 | 185 | /** 186 | * The default language_code for questionnaire retrieval. 187 | * Used as fallback if no questionnaire exists for preferred user language. 188 | * 189 | * @static 190 | * @return {*} {string} 191 | * @memberof COMPASSConfig 192 | */ 193 | public static getDefaultLanguageCode(): string { 194 | return env.get('COMPASS_DEFAULT_LANGUAGE_CODE').default('de').asString(); 195 | } 196 | 197 | /** 198 | * The certificate to use for the encryption of the client data. 199 | * It is the public certificate of the receiver of the data. 200 | * 201 | * @static 202 | * @return {*} {string} 203 | * @memberof COMPASSConfig 204 | */ 205 | public static getRecipientCertificate(): string { 206 | return env 207 | .get('COMPASS_RECIPIENT_CERTIFICATE') 208 | .default('false') 209 | .asString() 210 | .replace(/\\n/g, '\n'); 211 | } 212 | 213 | /** 214 | * Flag to toggle fake date calculation to ease testing. 215 | */ 216 | public static useFakeDateCalculation(): boolean { 217 | return env.get('COMPASS_USE_FAKE_DATES').default('false').asBoolStrict(); 218 | } 219 | 220 | /** 221 | * A private key. 222 | * 223 | * @static 224 | * @return {*} {string} 225 | * @memberof COMPASSConfig 226 | */ 227 | public static getIBMPrivateKey(): string { 228 | return env.get('COMPASS_PRIVATE_KEY').default('false').asString().replace(/\\n/g, '\n'); 229 | } 230 | 231 | /** 232 | * A public key. 233 | * 234 | * @static 235 | * @return {*} {string} 236 | * @memberof COMPASSConfig 237 | */ 238 | public static getIBMPublicKey(): string { 239 | return env.get('COMPASS_PUBLIC_KEY').default('false').asString().replace(/\\n/g, '\n'); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/config/DBCredentials.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import * as env from 'env-var'; 5 | 6 | /** 7 | * All database related configuration. 8 | */ 9 | export class DBCredentials { 10 | /** 11 | * 12 | * The data base hostname. 13 | * @static 14 | * @return {*} {string} 15 | * @memberof DBCredentials 16 | */ 17 | public static getHost(): string { 18 | return env.get('DB_HOST').required().asString(); 19 | } 20 | 21 | /** 22 | * The port of the database server to use. 23 | * 24 | * @static 25 | * @return {*} {number} 26 | * @memberof DBCredentials 27 | */ 28 | public static getPort(): number { 29 | return env.get('DB_PORT').required().asIntPositive(); 30 | } 31 | 32 | /** 33 | * The user of the database connection to use. 34 | * 35 | * @static 36 | * @return {*} {string} 37 | * @memberof DBCredentials 38 | */ 39 | public static getUser(): string { 40 | return env.get('DB_USER').required().asString(); 41 | } 42 | 43 | /** 44 | * The password of the database connection to use. 45 | * 46 | * @static 47 | * @return {*} {string} 48 | * @memberof DBCredentials 49 | */ 50 | public static getPassword(): string { 51 | return env.get('DB_PASSWORD').required().asString(); 52 | } 53 | 54 | /** 55 | * The database of the database connection to use. 56 | * 57 | * @static 58 | * @return {*} {string} 59 | * @memberof DBCredentials 60 | */ 61 | public static getDB(): string { 62 | return env.get('DB_NAME').required().asString(); 63 | } 64 | 65 | /** 66 | * Flag to enable SSL/TLS for the database connection. 67 | * 68 | * @static 69 | * @return {*} {boolean} 70 | * @memberof DBCredentials 71 | */ 72 | public static getUseSSL(): boolean { 73 | return env.get('DB_USE_SSL').default('true').asBoolStrict(); 74 | } 75 | 76 | /** 77 | * The SSL/TLS certificate to use for the database connection. 78 | * 79 | * @static 80 | * @return {*} {string} 81 | * @memberof DBCredentials 82 | */ 83 | public static getSSLCA(): string { 84 | // this is the public certificate for all IBM cloud databases - expires 2028-10-08 85 | const defaultCloudCert = 86 | 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURIVENDQWdXZ0F3SUJBZ0lVVmlhMWZrWElsTXhGY2lob3lncWg2Yit6N0pNd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0hqRWNNQm9HQTFVRUF3d1RTVUpOSUVOc2IzVmtJRVJoZEdGaVlYTmxjekFlRncweE9ERXdNVEV4TkRRNApOVEZhRncweU9ERXdNRGd4TkRRNE5URmFNQjR4SERBYUJnTlZCQU1NRTBsQ1RTQkRiRzkxWkNCRVlYUmhZbUZ6ClpYTXdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFESkYxMlNjbTJGUmpQb2N1bmYKbmNkUkFMZDhJRlpiWDhpbDM3MDZ4UEV2b3ZpMTRHNGVIRWZuT1JRY2g3VElPR212RWxITVllbUtFT3Z3K0VZUApmOXpqU1IxNFVBOXJYeHVaQmgvZDlRa2pjTkw2YmMvbUNUOXpYbmpzdC9qRzJSbHdmRU1lZnVIQWp1T3c4bkJuCllQeFpiNm1ycVN6T2FtSmpnVVp6c1RMeHRId21yWkxuOGhlZnhITlBrdGFVMUtFZzNQRkJxaWpDMG9uWFpnOGMKanpZVVVXNkpBOWZZcWJBL1YxMkFsT3AvUXhKUVVoZlB5YXozN0FEdGpJRkYybkxVMjBicWdyNWhqTjA4SjZQUwpnUk5hNXc2T1N1RGZiZ2M4V3Z3THZzbDQvM281akFVSHp2OHJMaWF6d2VPYzlTcDBKd3JHdUJuYTFPYm9mbHU5ClM5SS9BZ01CQUFHalV6QlJNQjBHQTFVZERnUVdCQlJGejFFckZFSU1CcmFDNndiQjNNMHpuYm1IMmpBZkJnTlYKSFNNRUdEQVdnQlJGejFFckZFSU1CcmFDNndiQjNNMHpuYm1IMmpBUEJnTlZIUk1CQWY4RUJUQURBUUgvTUEwRwpDU3FHU0liM0RRRUJDd1VBQTRJQkFRQ2t4NVJzbk9PMWg0dFJxRzh3R21ub1EwOHNValpsRXQvc2tmR0pBL2RhClUveEZMMndhNjljTTdNR1VMRitoeXZYSEJScnVOTCtJM1ROSmtVUEFxMnNhakZqWEtCeVdrb0JYYnRyc2ZKckkKQWhjZnlzN29tdjJmb0pHVGxJY0FybnBCL0p1bEZITmM1YXQzVk1rSTlidEh3ZUlYNFE1QmdlVlU5cjdDdlArSgpWRjF0YWxSUVpKandyeVhsWGJvQ0c0MTU2TUtwTDIwMUwyV1dqazBydlBVWnRKcjhmTmd6M24wb0x5MFZ0Zm93Ck1yUFh4THk5TlBqOGlzT3V0ckxEMjlJWTJBMFY0UmxjSXhTMEw3c1ZPeTB6RDZwbXpNTVFNRC81aWZ1SVg2YnEKbEplZzV4akt2TytwbElLTWhPU1F5dTRUME1NeTZmY2t3TVpPK0liR3JDZHIKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo='; 87 | return env.get('DB_SSL_CA').default(defaultCloudCert).asString(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/config/Environment.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import * as env from 'env-var'; 5 | 6 | /** 7 | * Environment related configuration. 8 | * 9 | * @export 10 | * @class Environment 11 | */ 12 | export class Environment { 13 | /** 14 | * Get the port for the express server. 15 | * 16 | * @static 17 | * @return {*} {number} 18 | * @memberof Environment 19 | */ 20 | public static getPort(): number { 21 | return env.get('PORT').default(8080).asPortNumber(); 22 | } 23 | 24 | /** 25 | * Determine if we are in development/local mode. 26 | * 27 | * @static 28 | * @return {*} {boolean} 29 | * @memberof Environment 30 | */ 31 | public static isLocal(): boolean { 32 | return Environment.getStage() === 'development'; 33 | } 34 | 35 | /** 36 | * Determine if we are in production mode. 37 | * 38 | * @static 39 | * @return {*} {boolean} 40 | * @memberof Environment 41 | */ 42 | public static isProd(): boolean { 43 | return Environment.getStage() === 'production'; 44 | } 45 | 46 | private static getStage(): string { 47 | return env.get('NODE_ENV').default('development').asString(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/config/PushServiceConfig.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import * as env from 'env-var'; 5 | 6 | /** 7 | * Configuration parameters for the push service. 8 | * 9 | * @export 10 | * @class PushServiceConfig 11 | */ 12 | export class PushServiceConfig { 13 | /** 14 | * The (relative) path to the configuration file for the Firebase Admin API. 15 | * See: https://cloud.google.com/docs/authentication/production 16 | * and: https://firebase.google.com/docs/cloud-messaging/auth-server#provide-credentials-manually 17 | */ 18 | public static getCredentialFile(): string { 19 | return env.get('GOOGLE_APPLICATION_CREDENTIALS').default('').asString(); 20 | } 21 | 22 | /** 23 | * The message send as a push notification in case of newly available questionnaires. 24 | */ 25 | public static getDownloadMessage(): string { 26 | return env 27 | .get('PUSH_MSG_DOWNLOAD') 28 | .default('Es liegt ein neuer Fragebogen für Sie bereit') 29 | .asString(); 30 | } 31 | 32 | /** 33 | * The message send as a push notification in case a participant needs to upload a questionnaire. 34 | */ 35 | public static getUploadMessage(): string { 36 | return env 37 | .get('PUSH_MSG_UPLOAD') 38 | .default('Bitte denken Sie an das Absenden Ihres Fragebogens') 39 | .asString(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/controllers/ApiController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { ChildControllers, ClassMiddleware, ClassOptions, Controller } from '@overnightjs/core'; 5 | import cors from 'cors'; 6 | 7 | import { AuthorizationController } from './AuthorizationController'; 8 | import { DownloadController } from './DownloadController'; 9 | import { QuestionnaireController } from './QuestionnaireController'; 10 | import { QueueController } from './QueueController'; 11 | import { ParticipantController } from './ParticipantController'; 12 | import { SubjectIdentitiesController } from './SubjectIdentitiesController'; 13 | 14 | /** 15 | * Parent controller 16 | * 17 | * @export 18 | * @class ApiController 19 | */ 20 | @Controller('api') 21 | @ClassOptions({ mergeParams: true }) 22 | @ClassMiddleware(cors()) 23 | @ChildControllers([ 24 | new AuthorizationController(), 25 | new DownloadController(), 26 | new ParticipantController(), 27 | new QueueController(), 28 | new QuestionnaireController(), 29 | new SubjectIdentitiesController() 30 | ]) 31 | export class ApiController {} 32 | -------------------------------------------------------------------------------- /src/controllers/AuthorizationController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 6 | 7 | import { timingSafeEqual } from 'crypto'; 8 | import { NextFunction, Request, Response } from 'express'; 9 | 10 | import { Controller, Post } from '@overnightjs/core'; 11 | import logger from 'jet-logger'; 12 | import { Jwt } from 'jsonwebtoken'; 13 | 14 | import { AuthConfig } from '../config/AuthConfig'; 15 | import { ApiUserModel } from '../models/ApiUserModel'; 16 | import { ParticipantModel } from '../models/ParticipantModel'; 17 | import { SecurityService } from '../services/SecurityService'; 18 | 19 | const MS_PER_MINUTE = 60000; 20 | 21 | /** 22 | * This class bundles authorization related logic like express middleware functions and rest API methods. 23 | * 24 | * @export 25 | * @class AuthorizationController 26 | */ 27 | @Controller('auth') 28 | export class AuthorizationController { 29 | private static apiUserModel: ApiUserModel = new ApiUserModel(); 30 | private static participantModel: ParticipantModel = new ParticipantModel(); 31 | 32 | /** 33 | * Express middleware that checks if the subject ID is valid. 34 | */ 35 | public static async checkStudyParticipantLogin( 36 | req: Request, 37 | res: Response, 38 | next: NextFunction 39 | ) { 40 | try { 41 | const bearerHeader = req.headers.authorization; 42 | const subjectID: string = bearerHeader 43 | ? bearerHeader.split(' ')[1] 44 | : req.params && req.params.subjectID 45 | ? req.params.subjectID 46 | : undefined; 47 | 48 | const checkLoginSuccess: boolean = 49 | await AuthorizationController.participantModel.checkLogin(subjectID); 50 | 51 | return checkLoginSuccess 52 | ? next() 53 | : res.status(401).json({ 54 | errorCode: 'AuthFailed', 55 | errorMessage: 'No valid authorization details provided.' 56 | }); 57 | } catch (err) { 58 | logger.err(err); 59 | return res.status(500).json({ 60 | errorCode: 'InternalErr', 61 | errorMessage: 'An internal error occurred.', 62 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 63 | }); 64 | } 65 | } 66 | 67 | /** 68 | * Express middleware that checks if the API user's access token is valid. 69 | */ 70 | public static async checkApiUserLogin(_req: Request, token: Jwt) { 71 | try { 72 | const success = await AuthorizationController.apiUserModel.checkIfExists( 73 | token.payload['api_id'] 74 | ); 75 | if (success) { 76 | return Promise.resolve(false); 77 | } else return Promise.resolve(true); 78 | } catch (err) { 79 | logger.err(err); 80 | return Promise.reject(); 81 | } 82 | } 83 | 84 | /** 85 | * Login method for an API user 86 | * 87 | * @param {Request} req 88 | * @param {Response} res 89 | * @return {*} 90 | * @memberof AuthorizationController 91 | */ 92 | @Post('') 93 | public async loginApiUser(req: Request, res: Response) { 94 | const encryptedCredentials = req.body.encrypted_creds; 95 | const encryptedKey = req.body.encrypted_key; 96 | const initializationVector = req.body.iv; 97 | 98 | if ( 99 | typeof encryptedCredentials !== 'string' || 100 | typeof encryptedKey !== 'string' || 101 | typeof initializationVector !== 'string' 102 | ) { 103 | return res.status(401).json({ 104 | errorCode: 'AuthInvalid', 105 | errorMessage: 'Invalid credentials provided. Only strings allowed.' 106 | }); 107 | } 108 | 109 | try { 110 | const decryptedCredentials = SecurityService.decryptLogin( 111 | encryptedCredentials, 112 | encryptedKey, 113 | initializationVector 114 | ); 115 | const credentials = JSON.parse(decryptedCredentials); 116 | const timeNow = new Date(); 117 | const timeMinus2Mins = new Date(timeNow.valueOf() - 2 * MS_PER_MINUTE); 118 | const timePlus2Mins = new Date(timeNow.valueOf() + 2 * MS_PER_MINUTE); 119 | 120 | if ( 121 | typeof credentials.ApiID !== 'string' || 122 | typeof credentials.ApiKey !== 'string' || 123 | typeof credentials.CurrentDate !== 'string' 124 | ) { 125 | return res.status(401).json({ 126 | errorCode: 'AuthInvalid', 127 | errorMessage: 'Invalid credentials provided. Only strings allowed.' 128 | }); 129 | } 130 | 131 | const credsDate = new Date(credentials.CurrentDate); 132 | 133 | if (AuthConfig.enableTimeCheckForAPIAuth) { 134 | if (credsDate < timeMinus2Mins || credsDate > timePlus2Mins) { 135 | return res.status(401).json({ 136 | errorCode: 'AuthOutdated', 137 | errorMessage: 'Invalid credentials provided. Timestamp is outdated.' 138 | }); 139 | } 140 | } 141 | 142 | const apiUser = await AuthorizationController.apiUserModel.getApiUserByID( 143 | credentials.ApiID 144 | ); 145 | 146 | const passwordHash = SecurityService.createPasswordHash( 147 | credentials.ApiKey, 148 | apiUser.api_key_salt 149 | ); 150 | 151 | const apiKeysMatching = timingSafeEqual( 152 | Buffer.from(apiUser.api_key), 153 | Buffer.from(passwordHash.passwordHash) 154 | ); 155 | 156 | if (apiKeysMatching) { 157 | // create accessToken which is a jwt containing the api_id as payload 158 | const accessToken = AuthConfig.sign({ 159 | api_id: apiUser.api_id 160 | }); 161 | 162 | return res.json({ 163 | access_token: accessToken 164 | }); 165 | } else { 166 | return res.status(401).json({ 167 | errorCode: 'AuthNoMatch', 168 | errorMessage: 'Invalid credentials provided: api_key and api_id do not match.' 169 | }); 170 | } 171 | } catch (err) { 172 | logger.err(err); 173 | return res.status(401).json({ 174 | errorCode: 'AuthError', 175 | errorMessage: 176 | process.env.NODE_ENV !== 'production' 177 | ? `Authorization failed with error: '${err.message}'.` 178 | : 'Authorization failed.', 179 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 180 | }); 181 | } 182 | } 183 | 184 | /** 185 | * Helper method to create password hashes. 186 | * 187 | * @param {Request} req 188 | * @param {Response} res 189 | * @return {*} 190 | * @memberof AuthorizationController 191 | */ 192 | @Post('helper/passwordhash') 193 | public async helperCreatePasswordHash(req: Request, res: Response) { 194 | try { 195 | if (!req.body || !req.body.password) { 196 | return res.status(400).json({ 197 | errorCode: 'InvalidQuery', 198 | errorMessage: 'Invalid authorization query.' 199 | }); 200 | } 201 | 202 | const password = req.body.password; 203 | const result = SecurityService.createPasswordHash(password); 204 | return res.json(result); 205 | } catch (err) { 206 | return res.status(500).json({ 207 | errorCode: 'InternalErr', 208 | errorMessage: 'Request failed due to an internal error.', 209 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 210 | }); 211 | } 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/controllers/CronController/AbstractCronJob.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | import { CronJob } from 'cron'; 6 | import { DateTime } from 'luxon'; 7 | 8 | import logger from 'jet-logger'; 9 | 10 | /** 11 | * A base class for all cron jobs. 12 | * 13 | * @export 14 | * @abstract 15 | * @class AbstractCronJob 16 | */ 17 | export abstract class AbstractCronJob { 18 | private job: CronJob; 19 | 20 | /** 21 | * Creates an instance of AbstractCronJob. 22 | * 23 | * @param {string} [pattern] Despite the official Unix cron format, the used library support seconds digits. Leaving it off will default to 0 and match the Unix behavior. 24 | * @memberof AbstractCronJob 25 | */ 26 | constructor(pattern: string) { 27 | this.job = new CronJob(pattern, () => this.executeJob(), null, true); 28 | logger.info('Created Cronjob'); 29 | logger.info('Running: [' + this.job.running + ']'); 30 | logger.imp('Next executions: '); 31 | const nextDates = this.job.nextDates(5) as DateTime[]; 32 | logger.imp( 33 | nextDates.map((date) => date.toString()), 34 | true 35 | ); 36 | } 37 | 38 | protected abstract executeJob(): Promise; 39 | } 40 | -------------------------------------------------------------------------------- /src/controllers/CronController/CronController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { ChildControllers, ClassOptions } from '@overnightjs/core'; 5 | 6 | import { CronJobNotification } from './CronJobNotification'; 7 | 8 | /** 9 | * A hack to have instances of cron jobs instantiated. 10 | * 11 | * @export 12 | * @class CronController 13 | */ 14 | @ClassOptions({ mergeParams: true }) 15 | @ChildControllers([new CronJobNotification()]) 16 | export class CronController {} 17 | -------------------------------------------------------------------------------- /src/controllers/CronController/CronJobNotification.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | import logger from 'jet-logger'; 6 | import { PushServiceConfig } from '../../config/PushServiceConfig'; 7 | 8 | import { ParticipantModel } from '../../models/ParticipantModel'; 9 | import { PerformanceLogger } from '../../services/PerformanceLogger'; 10 | import { PushService } from '../../services/PushService'; 11 | import { AbstractCronJob } from './AbstractCronJob'; 12 | 13 | /** 14 | * A cron job that sends push notifications. 15 | * 16 | * @export 17 | * @class CronJobNotification 18 | * @extends {AbstractCronJob} 19 | */ 20 | export class CronJobNotification extends AbstractCronJob { 21 | private participantModel: ParticipantModel = new ParticipantModel(); 22 | private pushService: PushService = new PushService(); 23 | 24 | constructor() { 25 | super('0 6 * * *'); // at 6:00 Local Time (GMT+02:00) 26 | } 27 | 28 | /** 29 | * Execute the job. 30 | * 31 | * @memberof CronJobNotification 32 | */ 33 | public async executeJob(): Promise { 34 | logger.info('Cronjob CronJobNotification fired at [' + new Date() + ']'); 35 | const perfLog = PerformanceLogger.startMeasurement('CronJobNotification', 'executeJob'); 36 | 37 | // ATTENTION: keep this in sync with the start time of this cron job 38 | // some dates in the DB are also set to 6 o'clock and this method is not guaranteed to run at 6 o'clock sharp, so we set an exact time, to prevent issues 39 | // Implementation detail: The timestamp columns in the DB are defined w/o timezone 40 | const now = new Date(); 41 | now.setUTCHours(6, 0, 0, 0); 42 | 43 | // Reminder - download questionnaire 44 | try { 45 | const participantsWithNewQuestionnaires = 46 | await this.participantModel.getParticipantsWithAvailableQuestionnairs(now); 47 | const downloadMsg = PushServiceConfig.getDownloadMessage(); 48 | await this.pushService.send(downloadMsg, participantsWithNewQuestionnaires); 49 | } catch (error) { 50 | logger.err(error, true); 51 | } 52 | 53 | // Reminder - upload questionnaire 54 | try { 55 | const participantsWithPendingUploads = 56 | await this.participantModel.getParticipantsWithPendingUploads(now); 57 | const uploadMsg = PushServiceConfig.getUploadMessage(); 58 | await this.pushService.send(uploadMsg, participantsWithPendingUploads); 59 | } catch (error) { 60 | logger.err(error, true); 61 | } 62 | 63 | PerformanceLogger.endMeasurement(perfLog); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/controllers/DownloadController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | import { Request, Response } from 'express'; 6 | import { expressjwt as jwt } from 'express-jwt'; 7 | 8 | import { ClassMiddleware, ClassErrorMiddleware, Controller, Get, Put } from '@overnightjs/core'; 9 | import logger from 'jet-logger'; 10 | 11 | import { CTransfer } from '../types/CTransfer'; 12 | import { QueueEntry } from '../types/QueueEntry'; 13 | import { QueueModel } from '../models/QueueModel'; 14 | import { SecurityService } from '../services/SecurityService'; 15 | import { AuthorizationController } from './AuthorizationController'; 16 | import { AuthConfig } from './../config/AuthConfig'; 17 | 18 | /** 19 | * Endpoint class for all download related restful methods. 20 | * 21 | * All routes use a middleware which checks if the header of the request contains a valid JWT with valid authentication data 22 | * 23 | * @export 24 | * @class DownloadController 25 | */ 26 | @Controller('download') 27 | @ClassMiddleware([ 28 | jwt({ 29 | secret: AuthConfig.jwtSecret, 30 | algorithms: ['HS256'], 31 | requestProperty: 'payload', 32 | isRevoked: AuthorizationController.checkApiUserLogin 33 | }) 34 | ]) 35 | @ClassErrorMiddleware((err, _req, res, next) => { 36 | res.status(err.status).json({ 37 | errorCode: err.code, 38 | errorMessage: err.inner.message, 39 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 40 | }); 41 | next(err); 42 | }) 43 | export class DownloadController { 44 | private queueModel: QueueModel = new QueueModel(); 45 | 46 | private limitEntries = 50; 47 | 48 | /** 49 | * Provide study data to the caller. 50 | * 51 | * @param {Request} req 52 | * @param {Response} res 53 | * @return {*} 54 | * @memberof DownloadController 55 | */ 56 | @Get() 57 | public async getAvailableDataFromQueue(req: Request, res: Response): Promise { 58 | try { 59 | const page: number = req.query.page ? parseInt(req.query.page.toString(), 10) : 1; 60 | if (page < 1) { 61 | return res.status(400).json({ 62 | errorCode: 'InvalidQuery', 63 | errorMessage: "Invalid query: param 'page' must be >= 1" 64 | }); 65 | } 66 | const totalEntries: number = parseInt( 67 | await this.queueModel.countAvailableQueueData(), 68 | 10 69 | ); 70 | const queueEntries: QueueEntry[] = await this.queueModel.getAvailableQueueData( 71 | this.limitEntries, 72 | page 73 | ); 74 | const transferItems: CTransfer[] = this.prepareQueueEntries(queueEntries); 75 | const signedItems = SecurityService.sign(transferItems); 76 | 77 | SecurityService.verifyJWS(signedItems); 78 | 79 | return res.status(200).json({ 80 | totalEntries, 81 | totalPages: Math.ceil(totalEntries / this.limitEntries), 82 | currentPage: page, 83 | cTransferList: signedItems 84 | }); 85 | } catch (err) { 86 | logger.err(err, true); 87 | return res.status(500).json({ 88 | errorCode: 'InternalErr', 89 | errorMessage: 'An internal error ocurred.', 90 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 91 | }); 92 | } 93 | } 94 | 95 | private prepareQueueEntries(queueEntries: QueueEntry[]) { 96 | const cTransferList: CTransfer[] = new Array(); 97 | for (const queueEntry of queueEntries) { 98 | const cTransfer: CTransfer = { 99 | UUID: queueEntry.id, 100 | SubjectId: queueEntry.subject_id, 101 | QuestionnaireId: queueEntry.questionnaire_id ?? '', 102 | Version: queueEntry.version ?? '0.1', 103 | JSON: queueEntry.encrypted_resp, 104 | AbsendeDatum: queueEntry.date_sent, 105 | ErhaltenDatum: queueEntry.date_received 106 | }; 107 | cTransferList.push(cTransfer); 108 | } 109 | return cTransferList; 110 | } 111 | 112 | /** 113 | * Mark queue entries as downloaded 114 | * 115 | * @param {Request} req 116 | * @param {Response} res 117 | * @return {*} 118 | * @memberof DownloadController 119 | */ 120 | @Put() 121 | public async markAsDownloaded(req: Request, res: Response): Promise { 122 | try { 123 | if (!Array.isArray(req.body)) { 124 | return res.status(400).json({ 125 | errorCode: 'InvalidQuery', 126 | errorMessage: 'Invalid query: no data provided.' 127 | }); 128 | } 129 | const updatedRowCount = await this.queueModel.markAsDownloaded(req.body); 130 | return res.status(200).json({ updatedRowCount: updatedRowCount }); 131 | } catch (err) { 132 | logger.err(err, true); 133 | return res.status(500).json({ 134 | errorCode: 'InternalErr', 135 | errorMessage: 'An internal error ocurred.', 136 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 137 | }); 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/controllers/ParticipantController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 6 | 7 | import { Request, Response } from 'express'; 8 | import { Controller, Get, Post, Middleware } from '@overnightjs/core'; 9 | import logger from 'jet-logger'; 10 | 11 | import { ParticipantEntry } from '../types/ParticipantEntry'; 12 | import { COMPASSConfig } from '../config/COMPASSConfig'; 13 | import { ParticipantModel } from '../models/ParticipantModel'; 14 | import { AuthorizationController } from './AuthorizationController'; 15 | 16 | /** 17 | * Endpoint class for all participant related restful methods. 18 | * 19 | * @export 20 | * @class ParticipantController 21 | */ 22 | @Controller('participant') 23 | export class ParticipantController { 24 | private participantModel: ParticipantModel = new ParticipantModel(); 25 | 26 | /** 27 | * Retrieve the current subject data. 28 | * Is called from the client during first login and also during refresh. 29 | */ 30 | @Get(':subjectID') 31 | @Middleware([AuthorizationController.checkStudyParticipantLogin]) 32 | public async getParticipant(req: Request, res: Response) { 33 | try { 34 | const participant: ParticipantEntry = 35 | await this.participantModel.getAndUpdateParticipantBySubjectID( 36 | req.params.subjectID 37 | ); 38 | this.participantModel.updateLastAction(req.params.subjectID); 39 | 40 | const returnObject = { 41 | current_instance_id: participant.current_instance_id, 42 | current_questionnaire_id: participant.current_questionnaire_id, 43 | due_date: participant.due_date, 44 | start_date: participant.start_date, 45 | subjectId: participant.subject_id, 46 | firstTime: 47 | participant.current_questionnaire_id === 48 | COMPASSConfig.getInitialQuestionnaireId(), 49 | additional_iterations_left: participant.additional_iterations_left, 50 | current_interval: participant.current_interval, 51 | recipient_certificate_pem_string: COMPASSConfig.getRecipientCertificate(), 52 | status: participant.status, 53 | general_study_end_date: participant.general_study_end_date, 54 | personal_study_end_date: participant.personal_study_end_date, 55 | language_code: participant.language_code 56 | }; 57 | return res.status(200).json(returnObject); 58 | } catch (err) { 59 | logger.err(err, true); 60 | return res.status(500).json({ 61 | errorCode: 'InternalErr', 62 | errorMessage: 'An internal error ocurred.', 63 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 64 | }); 65 | } 66 | } 67 | 68 | /** 69 | * Updates the device registration token that is used for the Firebase cloud messaging service. 70 | * Is called from the client, when the token is changed on the device. 71 | */ 72 | @Post('update-device-token/:subjectID') 73 | @Middleware([AuthorizationController.checkStudyParticipantLogin]) 74 | public async updateDeviceTokenForParticipant(req: Request, res: Response) { 75 | try { 76 | // validate parameter 77 | if (!req.params.subjectID || !req.body.token) { 78 | return res.status(400).send({ 79 | errorCode: 'InvalidQuery', 80 | errorMessage: 'Invalid credentials: missing subject_id or token' 81 | }); 82 | } 83 | await this.participantModel.updateDeviceToken( 84 | req.params.subjectID, 85 | req.body.token.toString() 86 | ); 87 | 88 | return res.sendStatus(204); 89 | } catch (err) { 90 | logger.err(err, true); 91 | return res.status(500).json({ 92 | errorCode: 'InternalErr', 93 | errorMessage: 'An internal error ocurred.', 94 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 95 | }); 96 | } 97 | } 98 | 99 | /** 100 | * Updates the language preference of a participant. 101 | * Is called from the client during first login and when the language is changed. 102 | */ 103 | @Post('update-language-code/:subjectID') 104 | @Middleware([AuthorizationController.checkStudyParticipantLogin]) 105 | public async updateLanguageCodeForParticipant(req: Request, resp: Response) { 106 | try { 107 | // validate parameter 108 | if (!req.params.subjectID || !req.body.language) { 109 | return resp.status(400).send({ error: 'missing_data' }); 110 | } 111 | await this.participantModel.updateLanguageCode(req.params.subjectID, req.body.language); 112 | 113 | return resp.sendStatus(204); 114 | } catch (err) { 115 | logger.err(err, true); 116 | return resp.sendStatus(500); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/controllers/QuestionnaireController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 6 | 7 | import { Request, Response } from 'express'; 8 | 9 | import { Controller, ClassErrorMiddleware, Get, Middleware, Post, Put } from '@overnightjs/core'; 10 | 11 | import { QuestionnaireModel } from '../models/QuestionnaireModel'; 12 | import { AuthorizationController } from './AuthorizationController'; 13 | import { expressjwt as jwt } from 'express-jwt'; 14 | import { AuthConfig } from '../config/AuthConfig'; 15 | import { COMPASSConfig } from '../config/COMPASSConfig'; 16 | 17 | /** 18 | * Endpoint class for all questionnaire related restful methods. 19 | * 20 | * @export 21 | * @class QuestionnaireController 22 | */ 23 | @Controller('questionnaire') 24 | @ClassErrorMiddleware((err, _req, res, next) => { 25 | res.status(err.status).json({ 26 | errorCode: err.code, 27 | errorMessage: err.inner.message, 28 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 29 | }); 30 | next(err); 31 | }) 32 | export class QuestionnaireController { 33 | private questionnaireModel: QuestionnaireModel = new QuestionnaireModel(); 34 | 35 | /** 36 | * Retrieve available questionnaire languages. 37 | * 38 | * @param {Request} req 39 | * @param {Response} res 40 | * @memberof QuestionnaireController 41 | */ 42 | @Get('get-languages') 43 | public async getQuestionnaireLanguages(req: Request, res: Response) { 44 | this.questionnaireModel 45 | .getQuestionnaireLanguages() 46 | .then((response) => { 47 | res.status(200).send(response); 48 | }) 49 | .catch((err) => { 50 | res.status(500).json({ 51 | errorCode: 'InternalErr', 52 | errorMessage: 'An internal error occurred.', 53 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 54 | }); 55 | }); 56 | } 57 | 58 | /** 59 | * Provide the questionnaire data for the requested questionnaire ID. 60 | * 61 | * @param {Request} req 62 | * @param {Response} res 63 | * @memberof QuestionnaireController 64 | */ 65 | @Get(':questionnaireId/:language?') 66 | @Middleware([AuthorizationController.checkStudyParticipantLogin]) 67 | public async getQuestionnaire(req: Request, res: Response) { 68 | const bearerHeader = req.headers.authorization; 69 | const subjectID: string = bearerHeader 70 | ? bearerHeader.split(' ')[1] 71 | : req.params && req.params.subjectID 72 | ? req.params.subjectID 73 | : undefined; 74 | 75 | const language = req.params.language 76 | ? req.params.language 77 | : COMPASSConfig.getDefaultLanguageCode(); 78 | 79 | this.questionnaireModel 80 | .getQuestionnaire(subjectID, req.params.questionnaireId, language) 81 | .then( 82 | (resp) => res.status(200).json(resp), 83 | (err) => { 84 | if (err.response) { 85 | res.status(err.response.status).send(); 86 | } else { 87 | res.status(500).json({ 88 | errorCode: 'InternalErr', 89 | errorMessage: 'An internal error occurred.', 90 | errorStack: 91 | process.env.NODE_ENV !== 'production' ? err.stack : undefined 92 | }); 93 | } 94 | } 95 | ); 96 | } 97 | 98 | /** 99 | * Add a questionnaire. 100 | * 101 | * @param {Request} req 102 | * @param {Response} res 103 | * @memberof QuestionnaireController 104 | */ 105 | @Post('') 106 | @Middleware( 107 | jwt({ 108 | secret: AuthConfig.jwtSecret, 109 | algorithms: ['HS256'], 110 | requestProperty: 'payload', 111 | isRevoked: AuthorizationController.checkApiUserLogin 112 | }) 113 | ) 114 | public async addQuestionnaire(req: Request, res: Response) { 115 | const url = req.body.url; 116 | const version = req.body.version; 117 | const name = req.body.name; 118 | const questionnaire = req.body.questionnaire; 119 | const languageCode = req.body.languageCode; 120 | 121 | if (!(url && version && name && questionnaire)) { 122 | return res.status(400).json({ 123 | errorCode: 'InvalidQuery', 124 | errMessage: 'Invalid query: params missing' 125 | }); 126 | } 127 | 128 | this.questionnaireModel 129 | .addQuestionnaire(url, version, name, questionnaire, languageCode) 130 | .then( 131 | () => { 132 | res.sendStatus(204); 133 | }, 134 | (err) => { 135 | if (err.code === 409) { 136 | res.status(409).json({ 137 | errorCode: 'QueueNameDuplicate', 138 | errorMessage: 'A questionnaire with this name already exists.' 139 | }); 140 | } else { 141 | res.status(500).json({ 142 | errorCode: 'InternalErr', 143 | errorMessage: 'An internal error occurred.', 144 | errorStack: 145 | process.env.NODE_ENV !== 'production' ? err.stack : undefined 146 | }); 147 | } 148 | } 149 | ); 150 | } 151 | 152 | /** 153 | * Update a questionnaire. 154 | * 155 | * @param {Request} req 156 | * @param {Response} res 157 | * @memberof QuestionnaireController 158 | */ 159 | @Put('') 160 | @Middleware( 161 | jwt({ 162 | secret: AuthConfig.jwtSecret, 163 | algorithms: ['HS256'], 164 | requestProperty: 'payload', 165 | isRevoked: AuthorizationController.checkApiUserLogin 166 | }) 167 | ) 168 | public async updateQuestionnaire(req: Request, res: Response) { 169 | const url = req.body.url; 170 | const version = req.body.version; 171 | const name = req.body.name; 172 | const questionnaire = req.body.questionnaire; 173 | const languageCode = req.body.languageCode; 174 | 175 | this.questionnaireModel 176 | .updateQuestionnaire(url, version, name, questionnaire, languageCode) 177 | .then( 178 | () => { 179 | res.sendStatus(204); 180 | }, 181 | (err) => { 182 | if (err.code === 409) { 183 | res.status(409).json({ 184 | errorCode: 'QueueVersionDuplicate', 185 | errorMessage: 'A questionnaire with this url and version already exists' 186 | }); 187 | } 188 | if (err.code === 404) { 189 | res.status(404).json({ 190 | errorCode: 'QueueNotFound', 191 | errorMessage: 'No questionnaire with given url and name found to update' 192 | }); 193 | } 194 | res.status(500).json({ 195 | errorCode: 'InternalErr', 196 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 197 | }); 198 | } 199 | ); 200 | } 201 | 202 | /** 203 | * Update a questionnaire. 204 | * 205 | * @param {Request} req 206 | * @param {Response} res 207 | * @memberof QuestionnaireController 208 | */ 209 | @Get('') 210 | @Middleware( 211 | jwt({ 212 | secret: AuthConfig.jwtSecret, 213 | algorithms: ['HS256'], 214 | requestProperty: 'payload', 215 | isRevoked: AuthorizationController.checkApiUserLogin 216 | }) 217 | ) 218 | public async getQuestionnaireByUrlAndVersion(req: Request, res: Response) { 219 | let url: string, version: string, languageCode: string; 220 | try { 221 | url = req.query.url.toString(); 222 | version = req.query.version.toString(); 223 | languageCode = req.query.languageCode?.toString() ?? null; 224 | } catch (err) { 225 | res.status(400).json({ 226 | errorCode: 'InvalidQuery', 227 | errMessage: `Query failed with error: '${err.message}'.`, 228 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 229 | }); 230 | return; 231 | } 232 | 233 | this.questionnaireModel.getQuestionnaireByUrlAndVersion(url, version, languageCode).then( 234 | (response) => { 235 | if (response.length === 0) { 236 | res.status(404).json({ 237 | errorCode: 'QuestionnaireNotFound', 238 | errorMessage: 'No questionnaire found that matches the given parameters.' 239 | }); 240 | } 241 | res.status(200).json(response[0]['body']); 242 | }, 243 | (err) => { 244 | res.status(500).json({ 245 | errorCode: 'Internal error', 246 | errorMessage: 'Query failed.', 247 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 248 | }); 249 | } 250 | ); 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /src/controllers/QueueController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 6 | 7 | import { Request, Response } from 'express'; 8 | 9 | import { Controller, Middleware, Post } from '@overnightjs/core'; 10 | 11 | import { QueueEntry } from '../types/QueueEntry'; 12 | import { COMPASSConfig } from '../config/COMPASSConfig'; 13 | import { QueueModel } from '../models/QueueModel'; 14 | import { ParticipantModel } from '../models/ParticipantModel'; 15 | import { AuthorizationController } from './AuthorizationController'; 16 | 17 | /** 18 | * Endpoint class for all study data queue related restful methods. 19 | * 20 | * @export 21 | * @class QueueController 22 | */ 23 | @Controller('queue') 24 | export class QueueController { 25 | private queueModel: QueueModel = new QueueModel(); 26 | private participantModel = new ParticipantModel(); 27 | 28 | /** 29 | * Add entries to the queue. It is called during following events from the client: 30 | * 1. A questionnaire is sent 31 | * 2. A positive result is reported 32 | * 3. Symptoms are reported 33 | * 34 | * @param {Request} req 35 | * @param {Response} res 36 | * @memberof QueueController 37 | */ 38 | @Post() 39 | @Middleware(AuthorizationController.checkStudyParticipantLogin) 40 | public async addToQueue(req: Request, res: Response) { 41 | let id: string, version: string; 42 | if (req.query.surveyId) { 43 | [id, version] = req.query.surveyId.toString().split('|'); 44 | } 45 | const queueEntry: QueueEntry = { 46 | id: null, 47 | subject_id: req.query.subjectId.toString(), 48 | questionnaire_id: id ?? 'Special_Report', 49 | version: version ?? 'N/A', 50 | encrypted_resp: req.body.payload, 51 | date_sent: new Date(), 52 | date_received: this.generateDateReceived(req) 53 | }; 54 | try { 55 | const result = await this.queueModel.addDataToQueue(queueEntry, req); 56 | if (!result) { 57 | //Data already sent through the other App 58 | res.status(409).json({ 59 | errorCode: 'QueueDuplicateRes', 60 | errorMessage: 61 | 'Queue already contains response object for the corresponding questionnaire.' 62 | }); 63 | } else { 64 | const newUserData = await this.participantModel.getAndUpdateParticipantBySubjectID( 65 | req.query.subjectId.toString() 66 | ); 67 | return res.status(200).json(newUserData); 68 | } 69 | } catch (err) { 70 | if (err.response) { 71 | res.status(err.response.status).send(); 72 | } else { 73 | res.status(500).json({ 74 | errorCode: 'InternalErr', 75 | errMessage: 'An internal error occurred.', 76 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 77 | }); 78 | } 79 | } 80 | } 81 | 82 | private generateDateReceived(req: Request) { 83 | const date = new Date(); 84 | if (req.query.type !== COMPASSConfig.getQuestionnaireResponseType()) { 85 | date.setDate(date.getDate() - 2); 86 | } 87 | return date; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/controllers/SubjectIdentitiesController.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 6 | 7 | import { Request, Response } from 'express'; 8 | 9 | import { Post, Controller, ClassMiddleware, ClassErrorMiddleware } from '@overnightjs/core'; 10 | import logger from 'jet-logger'; 11 | 12 | import { SubjectIdentitiesModel } from '../models/SubjectIdentitiesModel'; 13 | import { AuthorizationController } from './AuthorizationController'; 14 | import { expressjwt as jwt } from 'express-jwt'; 15 | import { AuthConfig } from '../config/AuthConfig'; 16 | 17 | /** 18 | * Endpoint class for all subjectId management related restful methods. 19 | * 20 | * @export 21 | * @class SubjectIdentitiesController 22 | */ 23 | @Controller('subjectIdentities') 24 | @ClassMiddleware( 25 | jwt({ 26 | secret: AuthConfig.jwtSecret, 27 | algorithms: ['HS256'], 28 | requestProperty: 'payload', 29 | isRevoked: AuthorizationController.checkApiUserLogin 30 | }) 31 | ) 32 | @ClassErrorMiddleware((err, _req, res, next) => { 33 | res.status(err.status).json({ 34 | errorCode: err.code, 35 | errorMessage: err.inner.message, 36 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 37 | }); 38 | next(err); 39 | }) 40 | export class SubjectIdentitiesController { 41 | private subjectIdentityModel: SubjectIdentitiesModel = new SubjectIdentitiesModel(); 42 | 43 | /** 44 | * Add new subject. 45 | * Is called by API user to register a new subject which will then get access to app by their id. 46 | */ 47 | @Post('addNew') 48 | public async addSubjectIdentity(req: Request, res: Response) { 49 | try { 50 | // validate parameter existence 51 | if (!req.body.subjectIdentity || !req.body.subjectIdentity.recordId) { 52 | return res.status(400).send({ 53 | error: 'missing_data' 54 | }); 55 | } 56 | 57 | const subjectIdentityExistence: boolean = 58 | await this.subjectIdentityModel.getSubjectIdentityExistence( 59 | req.body.subjectIdentity.recordId 60 | ); 61 | 62 | if (subjectIdentityExistence) { 63 | return res.status(409).send({ 64 | error: 'participant_already_exists' 65 | }); 66 | } 67 | 68 | await this.subjectIdentityModel.addNewSubjectIdentity( 69 | req.body.subjectIdentity.recordId 70 | ); 71 | 72 | return res.status(200).json({ 73 | return: true 74 | }); 75 | } catch (err) { 76 | logger.err(err, true); 77 | return res.status(500).json({ 78 | errorCode: 'InternalErr', 79 | errorMessage: 'An internal error ocurred.', 80 | errorStack: process.env.NODE_ENV !== 'production' ? err.stack : undefined 81 | }); 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | export * from './ApiController'; 5 | export * from './CronController/CronController'; 6 | -------------------------------------------------------------------------------- /src/models/ApiUserModel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { Pool } from 'pg'; 5 | 6 | import logger from 'jet-logger'; 7 | 8 | import { DB } from '../server/DB'; 9 | import { ApiUserEntry } from '../types/ApiUserEntry'; 10 | 11 | /** 12 | * Model class that bundles the logic for access to the "apiuser" table. 13 | * 14 | * @export 15 | * @class ApiUserModel 16 | */ 17 | export class ApiUserModel { 18 | /** 19 | * Find an api user by the given ID. 20 | * 21 | * @param {string} apiID 22 | * @return {*} 23 | * @memberof ApiUserModel 24 | */ 25 | public async getApiUserByID(apiID: string): Promise { 26 | try { 27 | const pool: Pool = DB.getPool(); 28 | const res = await pool.query('SELECT * FROM apiuser WHERE api_id = $1', [apiID]); 29 | if (res.rows.length !== 1) { 30 | throw new Error('api_id_not_found'); 31 | } else { 32 | return res.rows[0] as ApiUserEntry; 33 | } 34 | } catch (err) { 35 | logger.err(err); 36 | throw err; 37 | } 38 | } 39 | 40 | /** 41 | * Check if an entry in the database for the given apiID exists. 42 | * 43 | * @param {string} apiID 44 | * @return {*} 45 | * @memberof ApiUserModel 46 | */ 47 | public async checkIfExists(apiID: string): Promise { 48 | try { 49 | const pool: Pool = DB.getPool(); 50 | const res = await pool.query('SELECT * FROM apiuser WHERE api_id = $1', [apiID]); 51 | if (res.rows.length !== 1) { 52 | return false; 53 | } else { 54 | return true; 55 | } 56 | } catch (err) { 57 | logger.err(err); 58 | throw err; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/models/ExampleStateModel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { COMPASSConfig } from '../config/COMPASSConfig'; 5 | import { IdHelper } from '../services/IdHelper'; 6 | import { StateChangeTrigger, ParticipationStatus, ParticipantEntry } from '../types'; 7 | import { StateModel } from './StateModel'; 8 | 9 | /** 10 | * Example model based on the GCS state chart. 11 | * It uses four different questionnaires that are send to the participant depending on some conditions. 12 | * 13 | * @export 14 | * @class ExampleStateModel 15 | * @implements {StateModel} 16 | */ 17 | export class ExampleStateModel implements StateModel { 18 | /** 19 | * Determine new state relevant data for the given participant. 20 | * 21 | * @param {ParticipantEntry} participant 22 | * @param {string} parameters A stringified JSON with parameters that trigger state changes. 23 | * @return {*} {ParticipantEntry} 24 | * @memberof ExampleStateModel 25 | */ 26 | public calculateUpdatedData( 27 | participant: ParticipantEntry, 28 | parameters: StateChangeTrigger 29 | ): ParticipantEntry { 30 | const distValues = this.calculateStateValues(participant, parameters); 31 | const datesAndIterations = this.calculateDates( 32 | participant, 33 | distValues.nextInterval, 34 | distValues.nextDuration, 35 | distValues.nextStartHour, 36 | distValues.nextDueHour, 37 | distValues.startImmediately 38 | ); 39 | 40 | // handle iteration counter for questionnaires 41 | const iterationsLeft = distValues.additionalIterationsLeft 42 | ? distValues.additionalIterationsLeft - 1 43 | : 0; 44 | 45 | // check participation status in study based on defined study dates 46 | const participationStatus = 47 | participant.general_study_end_date < new Date() || 48 | participant.personal_study_end_date < new Date() 49 | ? ParticipationStatus['OffStudy'] 50 | : ParticipationStatus['OnStudy']; 51 | 52 | // clone the object and set updated values 53 | const updatedParticipant: ParticipantEntry = { ...participant }; 54 | updatedParticipant.current_instance_id = IdHelper.createID(); 55 | updatedParticipant.current_questionnaire_id = distValues.nextQuestionnaireId; 56 | updatedParticipant.start_date = datesAndIterations.startDate; 57 | updatedParticipant.due_date = datesAndIterations.dueDate; 58 | updatedParticipant.current_interval = distValues.nextInterval; 59 | updatedParticipant.additional_iterations_left = iterationsLeft; 60 | updatedParticipant.status = participationStatus; 61 | return updatedParticipant; 62 | } 63 | 64 | private calculateDates( 65 | participantData: ParticipantEntry, 66 | nextInterval: number, 67 | nextDuration: number, 68 | nextStartHour: number, 69 | nextDueHour: number, 70 | startImmediately: boolean 71 | ): { 72 | startDate: Date; 73 | dueDate: Date; 74 | } { 75 | const now = new Date(Date.now()); 76 | const intervalStart = new Date(now); 77 | intervalStart.setDate( 78 | intervalStart.getDate() + COMPASSConfig.getDefaultIntervalStartIndex() 79 | ); 80 | 81 | /** 82 | * TODO 83 | * 84 | * @param {Date} startDate 85 | * @param {boolean} [startImmediately] 86 | * @return {*} 87 | */ 88 | const calcTime = (startDate: Date, startImmediately?: boolean) => { 89 | let newStartDate: Date; 90 | let newDueDate: Date; 91 | 92 | if (COMPASSConfig.useFakeDateCalculation()) { 93 | // short circuit for testing 94 | // start date is set to be in 10 seconds and due date is in 30 minutes 95 | newStartDate = now; 96 | newStartDate.setSeconds(newStartDate.getSeconds() + 10); 97 | 98 | newDueDate = new Date(newStartDate); 99 | newDueDate.setSeconds(newDueDate.getSeconds() + 30 * 60); 100 | } else { 101 | newStartDate = new Date(startDate); 102 | if (participantData.start_date) { 103 | if (startImmediately) { 104 | newStartDate = new Date(intervalStart); 105 | } else { 106 | newStartDate.setDate(newStartDate.getDate() + nextInterval); 107 | } 108 | } 109 | newStartDate.setHours(nextStartHour, 0, 0, 0); 110 | 111 | newDueDate = new Date(newStartDate); 112 | newDueDate.setDate(newDueDate.getDate() + nextDuration); 113 | newDueDate.setHours(nextDueHour, 0, 0, 0); 114 | } 115 | 116 | return { 117 | startDate: newStartDate, 118 | dueDate: newDueDate 119 | }; 120 | }; 121 | 122 | let dates = calcTime( 123 | participantData.start_date ? new Date(participantData.start_date) : intervalStart, 124 | startImmediately 125 | ); 126 | 127 | // loop until the due date is in the future to get valid dates 128 | while (dates.dueDate < now) { 129 | dates = calcTime(dates.startDate); 130 | } 131 | return dates; 132 | } 133 | 134 | private calculateStateValues( 135 | currentParticipant: ParticipantEntry, 136 | triggerValues: StateChangeTrigger 137 | ) { 138 | // get default values 139 | const shortInterval = COMPASSConfig.getDefaultShortInterval(); 140 | const shortDuration = COMPASSConfig.getDefaultShortDuration(); 141 | const shortStartHour = COMPASSConfig.getDefaultShortStartHour(); 142 | const shortDueHour = COMPASSConfig.getDefaultShortDueHour(); 143 | 144 | const regularInterval = COMPASSConfig.getDefaultInterval(); 145 | const regularDuration = COMPASSConfig.getDefaultDuration(); 146 | const regularStartHour = COMPASSConfig.getDefaultStartHour(); 147 | const regularDueHour = COMPASSConfig.getDefaultDueHour(); 148 | 149 | const initialQuestionnaireId = COMPASSConfig.getInitialQuestionnaireId(); 150 | const defaultQuestionnaireId = COMPASSConfig.getDefaultQuestionnaireId(); 151 | const shortQuestionnaireId = COMPASSConfig.getDefaultShortQuestionnaireId(); 152 | const shortLimitedQuestionnaireId = COMPASSConfig.getDefaultShortLimitedQuestionnaireId(); 153 | 154 | const iterationCount = COMPASSConfig.getDefaultIterationCount(); 155 | 156 | if ( 157 | currentParticipant.additional_iterations_left > 0 && 158 | currentParticipant.current_questionnaire_id === shortLimitedQuestionnaireId 159 | ) { 160 | // Study participant is on short track with limited interval and has iterations left 161 | const nextDuration = 162 | currentParticipant.current_interval === shortInterval 163 | ? shortDuration 164 | : regularDuration; 165 | const enableShortMode = currentParticipant.current_interval === regularInterval; 166 | const nextStartHour = enableShortMode ? shortStartHour : regularStartHour; 167 | const nextDueHour = enableShortMode ? shortDueHour : regularDueHour; 168 | const startImmediately = false; 169 | const additionalIterationsLeft = currentParticipant.additional_iterations_left; 170 | 171 | return { 172 | nextInterval: currentParticipant.current_interval, 173 | nextDuration: nextDuration, 174 | nextQuestionnaireId: currentParticipant.current_questionnaire_id, 175 | nextStartHour: nextStartHour, 176 | nextDueHour: nextDueHour, 177 | startImmediately: startImmediately, 178 | additionalIterationsLeft: additionalIterationsLeft 179 | }; 180 | } else { 181 | // determine next questionnaire that will be delivered to the study participant 182 | let nextQuestionnaireId: string; 183 | if (triggerValues.specialTrigger && triggerValues.specialTrigger === true) { 184 | nextQuestionnaireId = shortLimitedQuestionnaireId; 185 | } else if (triggerValues.basicTrigger && triggerValues.basicTrigger === true) { 186 | nextQuestionnaireId = shortQuestionnaireId; 187 | } else if (!currentParticipant.due_date) { 188 | nextQuestionnaireId = initialQuestionnaireId; 189 | } else { 190 | nextQuestionnaireId = defaultQuestionnaireId; 191 | } 192 | 193 | // determine other values 194 | const switchToShortInterval = 195 | triggerValues.basicTrigger || triggerValues.specialTrigger; 196 | 197 | const nextInterval = switchToShortInterval ? shortInterval : regularInterval; 198 | const nextDuration = switchToShortInterval ? shortDuration : regularDuration; 199 | const nextStartHour = switchToShortInterval ? shortStartHour : regularStartHour; 200 | const nextDueHour = switchToShortInterval ? shortDueHour : regularDueHour; 201 | const startImmediately = switchToShortInterval; 202 | const additionalIterationsLeft = triggerValues.specialTrigger ? iterationCount : 1; 203 | 204 | return { 205 | nextInterval: nextInterval, 206 | nextDuration: nextDuration, 207 | nextQuestionnaireId: nextQuestionnaireId, 208 | nextStartHour: nextStartHour, 209 | nextDueHour: nextDueHour, 210 | startImmediately: startImmediately, 211 | additionalIterationsLeft: additionalIterationsLeft 212 | }; 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/models/ParticipantModel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { Pool } from 'pg'; 5 | 6 | import logger from 'jet-logger'; 7 | 8 | import { StateChangeTrigger, ParticipationStatus, ParticipantEntry } from '../types'; 9 | import { DB } from '../server/DB'; 10 | import { ExampleStateModel } from './ExampleStateModel'; 11 | import { StateModel } from './StateModel'; 12 | export class ParticipantModel { 13 | // the model that determines which questionnaire to send - replace this with you custom model 14 | private stateModel: StateModel = new ExampleStateModel(); 15 | 16 | /** 17 | * Update the participants current questionnaire, the start and due date and short interval usage. 18 | * 19 | * @param subjectId The participant id 20 | * @param parameters Parameters as json 21 | */ 22 | public async updateParticipant( 23 | subjectId: string, 24 | parameters = '{}' 25 | ): Promise { 26 | const pool: Pool = DB.getPool(); 27 | 28 | try { 29 | // retrieve participant from db 30 | const query_result = await pool.query( 31 | 'select * from studyparticipant where subject_id = $1', 32 | [subjectId] 33 | ); 34 | 35 | if (query_result.rows.length !== 1) { 36 | throw new Error('subject_id_not_found'); 37 | } 38 | const participant = query_result.rows[0] as ParticipantEntry; 39 | 40 | // calculate new state values 41 | const triggerValues: StateChangeTrigger = JSON.parse(parameters); 42 | const updatedParticipant = this.stateModel.calculateUpdatedData( 43 | participant, 44 | triggerValues 45 | ); 46 | 47 | // persist changes 48 | await pool.query( 49 | `update 50 | studyparticipant 51 | set 52 | current_questionnaire_id = $1, 53 | start_date = $2, 54 | due_date = $3, 55 | current_instance_id = $4, 56 | current_interval = $5, 57 | additional_iterations_left = $6, 58 | status = $7 59 | where 60 | subject_id = $8 61 | `, 62 | [ 63 | updatedParticipant.current_questionnaire_id, 64 | updatedParticipant.start_date, 65 | updatedParticipant.due_date, 66 | updatedParticipant.current_instance_id, 67 | updatedParticipant.current_interval, 68 | updatedParticipant.additional_iterations_left, 69 | updatedParticipant.status, 70 | updatedParticipant.subject_id 71 | ] 72 | ); 73 | return updatedParticipant; 74 | } catch (err) { 75 | logger.err(err); 76 | throw err; 77 | } 78 | } 79 | 80 | /** 81 | * Retrieve the participant from the database and eventually update the participants data in case due_date is outdated, start_date is not set, or study end dates are outdated. 82 | * 83 | * @param subjectID The participant id 84 | */ 85 | public async getAndUpdateParticipantBySubjectID(subjectID: string): Promise { 86 | const pool: Pool = DB.getPool(); 87 | 88 | try { 89 | const res = await pool.query('select * from studyparticipant where subject_id = $1', [ 90 | subjectID 91 | ]); 92 | if (res.rows.length !== 1) { 93 | throw new Error('subject_id_not_found'); 94 | } 95 | 96 | let participant = res.rows[0] as ParticipantEntry; 97 | if ( 98 | !participant.start_date || 99 | (participant.due_date && participant.due_date < new Date()) || 100 | (participant.status == ParticipationStatus['OnStudy'] && 101 | (participant.personal_study_end_date < new Date() || 102 | participant.general_study_end_date < new Date())) 103 | ) { 104 | // TODO rewrite updateParticipant to take an existing participant object and not reload from the db 105 | participant = await this.updateParticipant(participant.subject_id); 106 | } 107 | return participant; 108 | } catch (err) { 109 | logger.err(err); 110 | throw err; 111 | } 112 | } 113 | 114 | /** 115 | * Update the last action field of the participant 116 | * @param subjectID The participant id 117 | */ 118 | public async updateLastAction(subjectID: string): Promise { 119 | try { 120 | const pool: Pool = DB.getPool(); 121 | await pool.query( 122 | 'update studyparticipant set last_action = $1 where subject_id = $2;', 123 | [new Date(), subjectID] 124 | ); 125 | return; 126 | } catch (err) { 127 | logger.err(err); 128 | throw err; 129 | } 130 | } 131 | 132 | /** 133 | * Check if the participant exists in the database. 134 | * @param subjectID The participant id 135 | */ 136 | public async checkLogin(subjectID: string): Promise { 137 | try { 138 | const pool: Pool = DB.getPool(); 139 | const res = await pool.query( 140 | 'select subject_id from studyparticipant where subject_id = $1', 141 | [subjectID] 142 | ); 143 | if (res.rows.length !== 1) { 144 | return false; 145 | } else { 146 | return true; 147 | } 148 | } catch (err) { 149 | logger.err(err); 150 | throw err; 151 | } 152 | } 153 | 154 | /** 155 | * Retrieve all device tokens for which a questionnaire is available for download. 156 | * 157 | * @param referenceDate The reference date used to determine matching participant ids 158 | */ 159 | public async getParticipantsWithAvailableQuestionnairs(referenceDate: Date): Promise { 160 | // conditions - Start_Date and Due_Date in study_participant is set && Due_Date is not reached && no entry in History table present && subject is on-study 161 | try { 162 | const pool: Pool = DB.getPool(); 163 | const dateParam = this.convertDateToQueryString(referenceDate); 164 | const res = await pool.query( 165 | `select 166 | s.registration_token 167 | from 168 | studyparticipant s 169 | left join questionnairehistory q on 170 | s.subject_id = q.subject_id 171 | and s.current_questionnaire_id = q.questionnaire_id 172 | and s.current_instance_id = q.instance_id 173 | where 174 | q.id is null 175 | and s.start_date <= $1 176 | and s.due_date >= $1 177 | and s.status = $2 178 | `, 179 | [dateParam, ParticipationStatus['OnStudy']] 180 | ); 181 | return res.rows.map((participant) => participant.registration_token); 182 | } catch (err) { 183 | logger.err(err); 184 | throw err; 185 | } 186 | } 187 | 188 | /** 189 | * Retrieve all device tokens for which a questionnaire is available for download. 190 | * 191 | * @param referenceDate The reference date used to determine matching participant ids 192 | */ 193 | public async getParticipantsWithPendingUploads(referenceDate: Date): Promise { 194 | // conditions - Start_Date and Due_Date in study_participant is set && Due_Date is not reached && one entry in History table with date_sent == null is present && subject is on-study 195 | try { 196 | const pool: Pool = DB.getPool(); 197 | const dateParam = this.convertDateToQueryString(referenceDate); 198 | const res = await pool.query( 199 | `select 200 | s.registration_token 201 | from 202 | studyparticipant s, 203 | questionnairehistory q 204 | where 205 | s.start_date <= $1 206 | and s.due_date >= $1 207 | and q.subject_id = s.subject_id 208 | and q.questionnaire_id = s.current_questionnaire_id 209 | and q.instance_id = s.current_instance_id 210 | and q.date_sent is null 211 | and s.status = $2 212 | `, 213 | [dateParam, ParticipationStatus['OnStudy']] 214 | ); 215 | return res.rows.map((participant) => participant.registration_token); 216 | } catch (err) { 217 | logger.err(err); 218 | throw err; 219 | } 220 | } 221 | 222 | /** 223 | * Store the device registration token for the given participant. 224 | * 225 | * @param {string} subjectID The ID of the participant. 226 | * @param {*} token The device token to store. 227 | */ 228 | public async updateDeviceToken(subjectID: string, token: string): Promise { 229 | try { 230 | const pool: Pool = DB.getPool(); 231 | await pool.query( 232 | 'update studyparticipant set registration_token = $1 where subject_id = $2;', 233 | [token, subjectID] 234 | ); 235 | return; 236 | } catch (err) { 237 | logger.err(err); 238 | throw err; 239 | } 240 | } 241 | 242 | /** 243 | * Store the language code for the given participant. 244 | * 245 | * @param {string} language The preferred language of the participant. 246 | */ 247 | public async updateLanguageCode(subjectID: string, language: string): Promise { 248 | try { 249 | const pool: Pool = DB.getPool(); 250 | await pool.query( 251 | 'update studyparticipant set language_code = $1 where subject_id = $2;', 252 | [language, subjectID] 253 | ); 254 | return; 255 | } catch (err) { 256 | logger.err(err); 257 | throw err; 258 | } 259 | } 260 | 261 | /** 262 | * Converts a Javascript Date to Postgres-acceptable format. 263 | * 264 | * @param date The Date object 265 | */ 266 | private convertDateToQueryString(date: Date): string { 267 | const convertedDate = date.toISOString().replace('T', ' ').replace('Z', ''); 268 | logger.imp('Converted [' + date + '] to [' + convertedDate + ']'); 269 | return convertedDate; 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/models/QuestionnaireModel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | import logger from 'jet-logger'; 6 | 7 | import { COMPASSConfig } from '../config/COMPASSConfig'; 8 | import { ParticipantModel } from '../models/ParticipantModel'; 9 | import { DB } from '../server/DB'; 10 | import { IdHelper } from '../services/IdHelper'; 11 | 12 | /** 13 | * Model class that bundles the logic for access to the questionnaire related tables. 14 | * 15 | * @export 16 | * @class QuestionnaireModel 17 | */ 18 | export class QuestionnaireModel { 19 | private participantModel: ParticipantModel = new ParticipantModel(); 20 | 21 | /** 22 | * Retrieve the questionnaire with the requested ID and create a log entry in the questionnairehistory table. 23 | * 24 | * @param {string} subjectID 25 | * @param {string} questionnaireId 26 | * @return {*} {Promise} 27 | * @memberof QuestionnaireModel 28 | */ 29 | public async getQuestionnaire( 30 | subjectID: string, 31 | questionnaireId: string, 32 | language: string 33 | ): Promise { 34 | // note: we don't try/catch this because if connecting throws an exception 35 | // we don't need to dispose the client (it will be undefined) 36 | const dbClient = await DB.getPool().connect(); 37 | 38 | const url = questionnaireId.split('|')[0]; 39 | 40 | try { 41 | const participant = 42 | await this.participantModel.getAndUpdateParticipantBySubjectID(subjectID); 43 | 44 | const res = await dbClient.query( 45 | 'SELECT body, language_code FROM questionnaires WHERE id = $1 AND language_code = coalesce ((select language_code from questionnaires where language_code=$2 limit 1), $3);', 46 | [url, language, COMPASSConfig.getDefaultLanguageCode()] 47 | ); 48 | 49 | if (res.rows.length !== 1) { 50 | throw new Error('questionnaire_not_found'); 51 | } else { 52 | if (language != res.rows[0].language_code) { 53 | logger.info( 54 | `User language '${language}' not available, using fallback language '${res.rows[0].language_code}'` 55 | ); 56 | } 57 | const dbId = 58 | questionnaireId + 59 | '-' + 60 | subjectID + 61 | '-' + 62 | (participant.current_instance_id || COMPASSConfig.getInitialQuestionnaireId()); 63 | await dbClient.query( 64 | 'INSERT INTO questionnairehistory(id, subject_id, questionnaire_id, language_code, date_received, date_sent, instance_id) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING;', 65 | [ 66 | dbId, 67 | subjectID, 68 | questionnaireId, 69 | res.rows[0].language_code, 70 | new Date(), 71 | null, 72 | participant.current_instance_id || COMPASSConfig.getInitialQuestionnaireId() 73 | ] 74 | ); 75 | return res.rows[0].body; 76 | } 77 | } catch (e) { 78 | logger.err('!!! DB might be inconsistent. Check DB !!!'); 79 | logger.err(e); 80 | throw e; 81 | } finally { 82 | dbClient.release(); 83 | } 84 | } 85 | 86 | /** 87 | * Add the questionnaire with the given id to the list of questionnaires and to the version list of all questionnaires 88 | * 89 | * @param {string} url the url of the questionnaire as defined in its metadata 90 | * @param {string} version the version of the questionnaire as defined in its metadata 91 | * @param {string} name the name of the questionnaire which also identifies it as configure in the COMPASSConfig 92 | * @param {object} questionnaire the questionnaire itself as json 93 | * @return {*} {Promise} 94 | * @memberof QuestionnaireModel 95 | */ 96 | public async addQuestionnaire( 97 | url: string, 98 | version: string, 99 | name: string, 100 | questionnaire: string, 101 | languageCode?: string 102 | ): Promise { 103 | const dbClient = await DB.getPool().connect(); 104 | try { 105 | // Make sure there isn't a questionnaire yet with the given id 106 | const res = await dbClient.query( 107 | 'SELECT * FROM questionnaires WHERE id = $1 AND ($2::text is NULL OR language_code = $2::text)', 108 | [name] 109 | ); 110 | if (res.rows.length !== 0) { 111 | throw { code: 409 }; 112 | } 113 | await dbClient.query('INSERT INTO questionnaires VALUES($1, $2, $3)', [ 114 | name, 115 | questionnaire, 116 | languageCode 117 | ]); 118 | dbClient.query( 119 | 'INSERT INTO questionnaire_version_history VALUES($1, $2, $3, $4, $5, $6)', 120 | [IdHelper.createID(), url, version, name, questionnaire, languageCode] 121 | ); 122 | } catch (error) { 123 | logger.err(error); 124 | throw error; 125 | } finally { 126 | dbClient.release(); 127 | } 128 | } 129 | 130 | /** 131 | * Update an existing questionnaire and add the new version to the version history. 132 | * 133 | * @param {string} url the url of the questionnaire as defined in its metadata 134 | * @param {string} version the version of the questionnaire as defined in its metadata 135 | * @param {string} name the name of the questionnaire which also identifies it as configure in the COMPASSConfig 136 | * @param {object} questionnaire the questionnaire itself as json 137 | * @return {*} {Promise} 138 | * @memberof QuestionnaireModel 139 | */ 140 | public async updateQuestionnaire( 141 | url: string, 142 | version: string, 143 | name: string, 144 | questionnaire: string, 145 | languageCode?: string 146 | ): Promise { 147 | const dbClient = await DB.getPool().connect(); 148 | try { 149 | // Make sure there is no questionnaire present with the given url and version 150 | const res1 = await dbClient.query( 151 | 'SELECT * FROM questionnaire_version_history WHERE url = $1 AND version = $2 AND ($3::text is NULL or language_code = $3::text)', 152 | [url, version, languageCode] 153 | ); 154 | if (res1.rows.length === 1) { 155 | throw { code: 409 }; 156 | } 157 | // Make sure there is a questionnaire with the given url and name 158 | const res2 = await dbClient.query( 159 | 'SELECT * FROM questionnaire_version_history WHERE url = $1 AND name = $2 AND ($3::text is NULL or language_code = $3::text)', 160 | [url, name] 161 | ); 162 | if (res2.rows.length === 0) { 163 | throw { code: 404 }; 164 | } 165 | await dbClient.query( 166 | 'INSERT INTO questionnaire_version_history VALUES($1, $2, $3, $4, $5, $6)', 167 | [IdHelper.createID(), url, version, name, questionnaire, languageCode] 168 | ); 169 | dbClient.query( 170 | 'UPDATE questionnaires SET body = $1 WHERE id = $2 AND ($3::text is NULL or language_code = $3::text) RETURNING id', 171 | [questionnaire, name, languageCode] 172 | ); 173 | } catch (error) { 174 | logger.err(error); 175 | throw error; 176 | } finally { 177 | dbClient.release(); 178 | } 179 | } 180 | 181 | /** 182 | * Get a questionnaire identified by url and version 183 | * 184 | * @param {string} url 185 | * @param {string} version 186 | * @returns{object} the questionnaire object 187 | */ 188 | public async getQuestionnaireByUrlAndVersion( 189 | url: string, 190 | version: string, 191 | languageCode?: string 192 | ): Promise { 193 | const dbClient = await DB.getPool().connect(); 194 | try { 195 | const res = await dbClient.query( 196 | 'SELECT * FROM questionnaire_version_history WHERE url = $1 AND version = $2 AND ($3::text is null or language_code = $3::text)', 197 | [url, version, languageCode] 198 | ); 199 | return res.rows; 200 | } catch (error) { 201 | logger.err(error); 202 | throw error; 203 | } finally { 204 | dbClient.release(); 205 | } 206 | } 207 | 208 | /** 209 | * Get available questionnaire languages 210 | * @returns{string[]} list of available languages 211 | */ 212 | public async getQuestionnaireLanguages(): Promise { 213 | const dbClient = await DB.getPool().connect(); 214 | try { 215 | const res = await dbClient.query('SELECT DISTINCT language_code FROM questionnaires'); 216 | const responseArray = []; 217 | for (const row of res.rows) { 218 | responseArray.push(row.language_code); 219 | } 220 | return responseArray; 221 | } catch (error) { 222 | logger.err(error); 223 | throw error; 224 | } finally { 225 | dbClient.release(); 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/models/QueueModel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { Pool } from 'pg'; 5 | 6 | import { Request } from 'express'; 7 | import logger from 'jet-logger'; 8 | 9 | import { QueueEntry } from '../types/QueueEntry'; 10 | import { COMPASSConfig } from '../config/COMPASSConfig'; 11 | import { ParticipantModel } from '../models/ParticipantModel'; 12 | import { DB } from '../server/DB'; 13 | import { IdHelper } from '../services/IdHelper'; 14 | 15 | /** 16 | * Model class that bundles the logic for access to the "queue" table. 17 | * 18 | * @export 19 | * @class QueueModel 20 | */ 21 | export class QueueModel { 22 | private participantModel: ParticipantModel = new ParticipantModel(); 23 | 24 | /** 25 | * Retrieve study data from the queue. 26 | * 27 | * @param {number} limit Number of entries to fetch 28 | * @param {number} page Page to start the from fetching the data 29 | * @return {*} 30 | * @memberof QueueModel 31 | */ 32 | public async getAvailableQueueData(limit: number, page: number): Promise { 33 | try { 34 | const pool: Pool = DB.getPool(); 35 | const res = await pool.query( 36 | 'SELECT * FROM queue WHERE downloaded=false ORDER BY date_sent ASC LIMIT $1 OFFSET $2', 37 | [limit, (page - 1) * limit] 38 | ); 39 | return res.rows as QueueEntry[]; 40 | } catch (err) { 41 | logger.err(err); 42 | throw err; 43 | } 44 | } 45 | 46 | /** 47 | * Retrieve the number of entries in the queue. 48 | * 49 | * @return {*} 50 | * @memberof QueueModel 51 | */ 52 | public async countAvailableQueueData(): Promise { 53 | try { 54 | const pool: Pool = DB.getPool(); 55 | const res = await pool.query('SELECT COUNT(*) AS count_queue_data FROM queue'); 56 | // tslint:disable-next-line: no-string-literal 57 | return res.rows[0]['count_queue_data']; 58 | } catch (err) { 59 | logger.err(err); 60 | throw err; 61 | } 62 | } 63 | 64 | /** 65 | * Mark data as downloaded 66 | * 67 | * @param {string[]} idArray The ids of the entries which shall be marked as 'downloaded'. 68 | * @return {*} The number of updated entries. 69 | * @memberof QueueModel 70 | */ 71 | public async markAsDownloaded(idArray: string[]): Promise { 72 | try { 73 | const pool: Pool = DB.getPool(); 74 | const res = await pool.query('UPDATE queue SET downloaded=true WHERE id = ANY($1)', [ 75 | idArray 76 | ]); 77 | return res.rowCount; 78 | } catch (err) { 79 | logger.err(err); 80 | throw err; 81 | } 82 | } 83 | 84 | /** 85 | * Add study data to the queue. 86 | * 87 | * @param {QueueEntry} queueEntry 88 | * @param {Request} req 89 | * @return {*} 90 | * @memberof QueueModel 91 | */ 92 | public async addDataToQueue(queueEntry: QueueEntry, req: Request): Promise { 93 | // note: we don't try/catch this because if connecting throws an exception 94 | // we don't need to dispose of the client (it will be undefined) 95 | const dbClient = await DB.getPool().connect(); 96 | 97 | try { 98 | if (req.query.type === COMPASSConfig.getQuestionnaireResponseType()) { 99 | // a questionnaire response is send from the client 100 | const dbID = 101 | req.query.surveyId + 102 | '-' + 103 | req.query.subjectId + 104 | '-' + 105 | (req.query.instanceId || COMPASSConfig.getInitialQuestionnaireId()); 106 | const res = await dbClient.query( 107 | 'SELECT * FROM questionnairehistory WHERE id = $1', 108 | [dbID] 109 | ); 110 | 111 | if (res.rows.length === 0 || res.rows[0].date_sent !== null) { 112 | logger.err('!!! Already sent !!!'); 113 | return false; 114 | } else { 115 | await dbClient.query( 116 | 'INSERT INTO queue(id, subject_id, encrypted_resp, date_sent, date_received, questionnaire_id, version) VALUES ($1, $2, $3, $4, $5, $6, $7)', 117 | [ 118 | IdHelper.createID(), 119 | queueEntry.subject_id, 120 | queueEntry.encrypted_resp, 121 | queueEntry.date_sent, 122 | res.rows[0].date_received, 123 | queueEntry.questionnaire_id, 124 | queueEntry.version 125 | ] 126 | ); 127 | 128 | await dbClient.query( 129 | 'UPDATE questionnairehistory SET date_sent = $1 WHERE id = $2;', 130 | [queueEntry.date_sent, dbID] 131 | ); 132 | 133 | await this.participantModel.updateParticipant( 134 | queueEntry.subject_id, 135 | req.query.updateValues as string 136 | ); 137 | return true; 138 | } 139 | } else { 140 | // a report is send from the client 141 | await dbClient.query( 142 | 'INSERT INTO queue(id, subject_id, encrypted_resp, date_sent, date_received, questionnaire_id) VALUES ($1, $2, $3, $4, $5, $6)', 143 | [ 144 | IdHelper.createID(), 145 | queueEntry.subject_id, 146 | queueEntry.encrypted_resp, 147 | queueEntry.date_sent, 148 | queueEntry.date_received, 149 | queueEntry.questionnaire_id 150 | ] 151 | ); 152 | 153 | await this.participantModel.updateParticipant( 154 | queueEntry.subject_id, 155 | req.query.updateValues as string 156 | ); 157 | return true; 158 | } 159 | } catch (e) { 160 | logger.err('!!! DB might be inconsistent. Check DB !!!'); 161 | logger.err(e); 162 | throw e; 163 | } finally { 164 | dbClient.release(); 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/models/StateModel.ts: -------------------------------------------------------------------------------- 1 | import { StateChangeTrigger, ParticipantEntry } from '../types'; 2 | 3 | /** 4 | * This interface defines all methods, that need to be implemented by a custom state model. 5 | * 6 | * @export 7 | * @interface StateModel 8 | */ 9 | export interface StateModel { 10 | /** 11 | * Determine new state relevant data for the given user 12 | * 13 | * @param {ParticipantEntry} user 14 | * @param {string} parameters 15 | * @return {*} {ParticipantEntry} 16 | * @memberof StateModel 17 | */ 18 | calculateUpdatedData(user: ParticipantEntry, parameters: StateChangeTrigger): ParticipantEntry; 19 | } 20 | -------------------------------------------------------------------------------- /src/models/SubjectIdentitiesModel.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { Pool } from 'pg'; 5 | import logger from 'jet-logger'; 6 | import { DB } from '../server/DB'; 7 | 8 | export class SubjectIdentitiesModel { 9 | /** 10 | * Verify if participant exists in database. 11 | * @param subjectID The participant id 12 | */ 13 | public async getSubjectIdentityExistence(subjectID: string): Promise { 14 | try { 15 | const pool: Pool = DB.getPool(); 16 | const res = await pool.query('select * from studyparticipant where subject_id = $1', [ 17 | subjectID 18 | ]); 19 | 20 | if (res.rows.length !== 1) { 21 | return false; 22 | } 23 | return true; 24 | } catch (err) { 25 | logger.err(err); 26 | throw err; 27 | } 28 | } 29 | 30 | /** 31 | * Add a new participant 32 | * @param subjectID The participant id 33 | */ 34 | public async addNewSubjectIdentity(subjectID: string): Promise { 35 | try { 36 | const pool: Pool = DB.getPool(); 37 | await pool.query('INSERT INTO studyparticipant(subject_id) VALUES ($1);', [subjectID]); 38 | } catch (err) { 39 | logger.err(err); 40 | throw err; 41 | } 42 | return; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/server/CustomRoutes.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { Route } from './Route'; 5 | 6 | /** 7 | * Simple data holder for the express router. 8 | * 9 | * @export 10 | * @class CustomRoutes 11 | */ 12 | export class CustomRoutes { 13 | private static customRoutes: Route[] = []; 14 | 15 | public static addRoute(method: string, route: string): void { 16 | CustomRoutes.customRoutes.push(new Route(method, route)); 17 | } 18 | 19 | public static getRoutes(): Route[] { 20 | return CustomRoutes.customRoutes; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/server/DB.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { Pool } from 'pg'; 5 | import { ConnectionOptions } from 'tls'; 6 | 7 | import logger from 'jet-logger'; 8 | 9 | import { DBCredentials } from '../config/DBCredentials'; 10 | 11 | /** 12 | * Database driver related logic. 13 | * The class includes Postgres specific code. 14 | * 15 | * @class DB 16 | */ 17 | export class DB { 18 | // Singleton 19 | private static poolInstance: Pool; 20 | 21 | // eslint-disable-next-line @typescript-eslint/no-empty-function 22 | private constructor() {} 23 | 24 | /** 25 | * Get the db pool instance to query the database. 26 | * 27 | * @static 28 | * @return {*} {Pool} 29 | * @memberof DB 30 | */ 31 | public static getPool(): Pool { 32 | return DB.poolInstance; 33 | } 34 | 35 | /** 36 | * Initialize the db connection. This needs to be called once during startup. 37 | * 38 | * @static 39 | * @param {(label: string) => void} callbackSuccess 40 | * @param {(label: string, err: Error) => void} callbackError 41 | * @memberof DB 42 | */ 43 | public static initPool( 44 | callbackSuccess: (label: string) => void, 45 | callbackError: (label: string, err: Error) => void 46 | ): void { 47 | if (DB.poolInstance) DB.poolInstance.end(); 48 | 49 | const sslConnOptions: ConnectionOptions = {}; 50 | if (DBCredentials.getUseSSL()) { 51 | sslConnOptions.rejectUnauthorized = true; 52 | try { 53 | sslConnOptions.ca = Buffer.from(DBCredentials.getSSLCA(), 'base64').toString(); 54 | } catch (err) { 55 | logger.warn( 56 | "Cannot get CA from environment variable DB_SSL_CA. Self-signed certificates in DB connection won't work!" 57 | ); 58 | } 59 | } 60 | 61 | const pool = new Pool({ 62 | user: DBCredentials.getUser(), 63 | host: DBCredentials.getHost(), 64 | database: DBCredentials.getDB(), 65 | password: DBCredentials.getPassword(), 66 | port: DBCredentials.getPort(), 67 | ssl: DBCredentials.getUseSSL() ? sslConnOptions : false 68 | }); 69 | logger.info( 70 | `Created pool. Currently: ${pool.totalCount} Clients (${pool.idleCount} idle, ${pool.waitingCount} busy)` 71 | ); 72 | pool.connect((err, client, release) => { 73 | logger.info('Trying query...'); 74 | if (err) { 75 | return callbackError('PostgreSQL', err); 76 | } 77 | client.query('SELECT NOW()', (nowErr, result) => { 78 | release(); 79 | if (nowErr) { 80 | return callbackError('PostgreSQL', nowErr); 81 | } 82 | logger.info(`SELECT NOW() => ${JSON.stringify(result.rows)}`); 83 | DB.poolInstance = pool; 84 | return callbackSuccess('PostgreSQL'); 85 | }); 86 | }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/server/ExpressServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import * as dotenv from 'dotenv'; 5 | import express, { Request, Response } from 'express'; 6 | 7 | import { HealthChecker, HealthEndpoint } from '@cloudnative/health-connect'; 8 | import { Server } from '@overnightjs/core'; 9 | import logger from 'jet-logger'; 10 | 11 | import { Environment } from '../config/Environment'; 12 | import * as controllers from '../controllers'; 13 | import { CustomRoutes } from './CustomRoutes'; 14 | import { DB } from './DB'; 15 | import { Route } from './Route'; 16 | import { SwaggerUI } from './SwaggerUI'; 17 | 18 | /** 19 | * This class has all the logic to setup express to our needs. 20 | * 21 | * @class ExpressServer 22 | * @extends {Server} 23 | */ 24 | class ExpressServer extends Server { 25 | constructor() { 26 | super(Environment.isLocal()); // setting showLogs to true for development 27 | 28 | if (Environment.isLocal()) { 29 | logger.imp('Server starting in environment: local/development'); 30 | const result = dotenv.config({ path: './.env' }); 31 | if (result.error) { 32 | logger.err(result.error); 33 | throw result.error; 34 | } 35 | } 36 | 37 | if (Environment.isProd()) { 38 | logger.imp('Server starting in environment: production'); 39 | logger.info('Server not local. Setting up proxy ...'); 40 | this.app.enable('trust proxy'); 41 | } 42 | 43 | this.app.use(express.json({ limit: '1mb' })); 44 | this.app.use(express.urlencoded({ extended: true })); 45 | 46 | this.setupControllers(); 47 | } 48 | 49 | private setupControllers(): void { 50 | const ctrlInstances = []; 51 | for (const name in controllers) { 52 | // eslint-disable-next-line no-prototype-builtins 53 | if (controllers.hasOwnProperty(name)) { 54 | const controller = controllers[name]; 55 | ctrlInstances.push(new controller()); 56 | } 57 | } 58 | super.addControllers(ctrlInstances); 59 | } 60 | 61 | /** 62 | * The start methods. It performs following steps 63 | * - Startup express server 64 | * - Open a DB connection 65 | * - Create a health endpoint for probing 66 | * - Start Swagger UI for API documentation 67 | * 68 | * In case of issues, like failing DB connection, this method exits the running process. 69 | * 70 | * @param {number} port The port number to use. 71 | * @memberof ExpressServer 72 | */ 73 | public start(port: number): void { 74 | DB.initPool( 75 | async (conn: string) => { 76 | logger.info(`${conn} connection successful.`); 77 | 78 | // initialize swagger ui 79 | const swaggerUI: SwaggerUI = new SwaggerUI(this.app); 80 | await swaggerUI.start(); 81 | 82 | // setup cloud health endpoints 83 | const healthCheck = new HealthChecker(); 84 | this.app.use('/health', HealthEndpoint(healthCheck)); 85 | CustomRoutes.addRoute('GET', '/health'); 86 | 87 | this.logRegisteredRoutes(); 88 | this.startExpressApp(port); 89 | }, 90 | (conn: string, err: Error) => { 91 | logger.err(`Failed to establish ${conn} connection. Did the network just go down?`); 92 | logger.err(err); 93 | return process.exit(1); 94 | } 95 | ); 96 | } 97 | 98 | private startExpressApp(port: number): void { 99 | this.app.use('/api/*', this.deactivateCaching); 100 | 101 | // catch 404 and forward to error handler 102 | this.app.use((req: Request, res: Response) => { 103 | res.status(404).json({ 104 | error: "Route '" + req.url + "' not found." 105 | }); 106 | }); 107 | 108 | this.app.listen(port, () => { 109 | logger.imp(`Express server started on port: ${port}`); 110 | }); 111 | } 112 | 113 | private deactivateCaching(req: Request, res: Response, next: VoidFunction): void { 114 | if (req.method === 'GET') { 115 | res.header('Cache-Control', 'private, max-age=1'); 116 | } else { 117 | res.header('Cache-Control', 'no-cache, no-store, must-revalidate'); 118 | } 119 | next(); 120 | } 121 | 122 | private logRegisteredRoutes(): void { 123 | function getBase(regexp) { 124 | const match = regexp 125 | .toString() 126 | .replace('\\/?', '') 127 | .replace('(?=\\/|$)', '$') 128 | .match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//); 129 | return match[1].replace(/\\(.)/g, '$1'); 130 | } 131 | 132 | function recForEach(path: string, _router) { 133 | _router.stack.forEach((r) => { 134 | if (r.route) { 135 | const method = r.route.stack[0].method.toUpperCase(); 136 | const route = path.concat(r.route.path); 137 | logger.info(`### ${method.padEnd(8, ' ')}${route.padEnd(50, ' ')}###`); 138 | } else if (r.name === 'router') { 139 | recForEach(path + getBase(r.regexp), r.handle); 140 | } 141 | }); 142 | } 143 | 144 | function customRoutesForEach() { 145 | CustomRoutes.getRoutes().forEach((entry: Route) => { 146 | logger.info( 147 | '### ' + entry.method.padEnd(8, ' ') + entry.route.padEnd(50, ' ') + '###' 148 | ); 149 | }); 150 | } 151 | 152 | logger.info('#################################################################'); 153 | logger.info('### DISPLAYING REGISTERED ROUTES: ###'); 154 | logger.info('### ###'); 155 | recForEach('', this.app._router); 156 | customRoutesForEach(); 157 | logger.info('### ###'); 158 | logger.info('#################################################################'); 159 | } 160 | } 161 | 162 | export default ExpressServer; 163 | -------------------------------------------------------------------------------- /src/server/Route.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /** 6 | * Simple data class. 7 | * 8 | * @export 9 | * @class Route 10 | */ 11 | export class Route { 12 | constructor( 13 | readonly method: string, 14 | readonly route: string 15 | ) {} 16 | } 17 | -------------------------------------------------------------------------------- /src/server/SwaggerUI.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { Application } from 'express'; 5 | import * as fs from 'fs'; 6 | import * as jsyaml from 'js-yaml'; 7 | import * as path from 'path'; 8 | import * as swaggerUi from 'swagger-ui-express'; 9 | 10 | import { CustomRoutes } from './CustomRoutes'; 11 | 12 | /** 13 | * This class has all the logic to expose a Swagger UI with the OpenApi documentation of this application. 14 | * 15 | * @export 16 | * @class SwaggerUI 17 | */ 18 | export class SwaggerUI { 19 | constructor(private app: Application) {} 20 | 21 | /** 22 | * Expose a route with the OpenApi documentation of this application. 23 | * 24 | * @memberof SwaggerUI 25 | */ 26 | public async start(): Promise { 27 | // The Swagger document (require it, build it programmatically, fetch it from a URL, ...) 28 | const spec = fs.readFileSync(path.join(__dirname, '../assets/openapi.yaml'), 'utf8'); 29 | const swaggerDoc = jsyaml.load(spec); 30 | this.app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDoc)); 31 | CustomRoutes.addRoute('GET', '/docs'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/services/IdHelper.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import { v4 as uuidv4 } from 'uuid'; 5 | 6 | export class IdHelper { 7 | /** 8 | * Create an RFC version 4 (random) UUID. 9 | */ 10 | public static createID(): string { 11 | return uuidv4(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/services/PerformanceLogger.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | import logger from 'jet-logger'; 6 | 7 | import { MeasurementObject } from '../types/MeasurementObject'; 8 | import { IdHelper } from './IdHelper'; 9 | 10 | /** 11 | * A simple logger that can be used to measure execution times. 12 | * 13 | * @export 14 | * @class PerformanceLogger 15 | */ 16 | export class PerformanceLogger { 17 | private static performanceMap: MeasurementObject[] = []; 18 | 19 | /** 20 | * Start execution measurement. 21 | * 22 | * @static 23 | * @param {string} className 24 | * @param {string} methodName 25 | * @return {*} {string} 26 | * @memberof PerformanceLogger 27 | */ 28 | public static startMeasurement(className: string, methodName: string): string { 29 | const uuid: string = IdHelper.createID(); 30 | const timeStart = new Date(); 31 | this.performanceMap[uuid] = { className, methodName, timeStart }; 32 | return uuid; 33 | } 34 | 35 | /** 36 | * End execution measurement and print the execution duration. 37 | * 38 | * @static 39 | * @param {string} uuid 40 | * @memberof PerformanceLogger 41 | */ 42 | public static endMeasurement(uuid: string): void { 43 | const timeEnd = new Date(); 44 | 45 | if (this.performanceMap[uuid] === undefined) { 46 | logger.err(`[PerformanceLogger] uuid ${uuid} does not exist. No Measurement.`); 47 | } else { 48 | const measurementObject = this.performanceMap[uuid]; 49 | const timeStart = measurementObject.timeStart; 50 | const diff = Math.abs(timeStart.getTime() - timeEnd.getTime()) / 1000; 51 | logger.info( 52 | `[PerformanceLogger]${measurementObject.className}.${measurementObject.methodName} took ${diff}s.` 53 | ); 54 | delete this.performanceMap[uuid]; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/services/PushService.ts: -------------------------------------------------------------------------------- 1 | import { PushServiceConfig } from '../config/PushServiceConfig'; 2 | /* 3 | * Copyright (c) 2021, IBM Deutschland GmbH 4 | */ 5 | import * as admin from 'firebase-admin'; 6 | 7 | import logger from 'jet-logger'; 8 | 9 | /** 10 | * Adapter for the Firebase Cloud Messaging service. 11 | * 12 | * @export 13 | * @class PushService 14 | */ 15 | export class PushService { 16 | private static fcmInstance: admin.app.App; 17 | 18 | public constructor() { 19 | if (!PushService.fcmInstance) { 20 | // Initialize the SDK 21 | logger.info('Initializing Firebase Cloud Messaging'); 22 | PushService.fcmInstance = admin.initializeApp({ 23 | credential: admin.credential.applicationDefault() 24 | }); 25 | } 26 | } 27 | 28 | /** 29 | * Trigger sending a push message to the given participants. 30 | * 31 | * @static 32 | * @param {string} msg The message to send. 33 | * @param {string[]} deviceToken The device token to address. 34 | * @return {*} {Promise} 35 | * @memberof PushService 36 | */ 37 | public async send(msg: string, deviceToken: string[]): Promise { 38 | logger.info(' --> Entering Send'); 39 | 40 | if (msg === null) { 41 | logger.warn('No message provided. Skip sending push notifications.'); 42 | return; 43 | } 44 | if (deviceToken === null || deviceToken.length < 1) { 45 | logger.warn('No participantid provided. Skip sending push notifications.'); 46 | return; 47 | } 48 | if (PushServiceConfig.getCredentialFile().length === 0) { 49 | logger.err( 50 | 'Credential file for push service not set. Skip sending push notifications.' 51 | ); 52 | return; 53 | } 54 | 55 | logger.imp(`Sending message [${msg}] to [${deviceToken.length}] recipients.`); 56 | 57 | const android: admin.messaging.AndroidConfig = { 58 | collapseKey: 'Accept', 59 | priority: 'normal', 60 | ttl: 86400 // time to live is 1d as it will be superseded by the next msg 61 | }; 62 | 63 | // TODO Adjust as needed for a new iOS app 64 | const apns: admin.messaging.ApnsConfig = { 65 | payload: { 66 | aps: { 67 | category: 'Accept', 68 | sound: { 69 | name: 'Default' 70 | } 71 | } 72 | } 73 | }; 74 | 75 | let successCount = 0; 76 | const failedTokens = []; 77 | 78 | const queries: Promise<{ success: boolean; token: string | undefined }>[] = []; 79 | 80 | deviceToken.forEach((registrationToken) => { 81 | // build message 82 | const message: admin.messaging.Message = { 83 | notification: { 84 | title: msg, 85 | body: 'Zum Öffnen der App tippen' 86 | }, 87 | token: registrationToken, 88 | android, 89 | apns 90 | }; 91 | 92 | // Send a message to the devices corresponding to the provided 93 | // registration tokens. 94 | 95 | queries.push( 96 | admin 97 | .messaging() 98 | .send(message) 99 | .then((response) => { 100 | if (response) { 101 | return { success: true, token: undefined }; 102 | } else { 103 | return { success: false, token: registrationToken }; 104 | } 105 | }) 106 | .catch((error) => { 107 | logger.err('Error sending message: ' + error); 108 | return { success: false, token: registrationToken }; 109 | }) 110 | ); 111 | }); 112 | 113 | const results = await Promise.allSettled(queries); 114 | 115 | results.forEach((result) => { 116 | if (result.status === 'fulfilled') { 117 | if (result.value.success) { 118 | successCount++; 119 | } else { 120 | failedTokens.push(result.value.token); 121 | } 122 | } 123 | }); 124 | 125 | logger.info(successCount + ' messages were sent successfully'); 126 | logger.err('List of tokens that caused failures: ' + failedTokens); 127 | 128 | logger.info(' <-- Leaving Send'); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/services/SecurityService.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | import * as crypto from 'crypto'; 5 | import * as fs from 'fs'; 6 | import * as jws from 'jws'; 7 | 8 | import logger from 'jet-logger'; 9 | 10 | import { COMPASSConfig } from '../config/COMPASSConfig'; 11 | import { PerformanceLogger } from './PerformanceLogger'; 12 | 13 | /** 14 | * Encryption related logic. 15 | * 16 | * @export 17 | * @class SecurityService 18 | */ 19 | export class SecurityService { 20 | /** 21 | * Retrieve the servers public key. 22 | * The key will be loaded from the process environment or as fallback from the filesystem. 23 | * 24 | * @static 25 | * @return {*} {string} 26 | * @memberof SecurityService 27 | */ 28 | public static getServerPublicKey(): string { 29 | const pubKey = COMPASSConfig.getIBMPublicKey(); 30 | if (pubKey === 'false') { 31 | logger.err('Attention: Using public key from file'); 32 | return fs.readFileSync('./public_key.pem', 'utf8'); 33 | } else { 34 | return pubKey; 35 | } 36 | } 37 | 38 | /** 39 | * Retrieve the servers private key. 40 | * The key will be loaded from the process environment or as fallback from the filesystem. 41 | * 42 | * @static 43 | * @return {*} {string} 44 | * @memberof SecurityService 45 | */ 46 | public static getServerSecretKey(): string { 47 | const privKey = COMPASSConfig.getIBMPrivateKey(); 48 | if (privKey === 'false') { 49 | logger.err('Attention: Using private key from file'); 50 | return fs.readFileSync('./private_key.pem', 'utf8'); 51 | } else { 52 | return privKey; 53 | } 54 | } 55 | 56 | /** 57 | * 58 | * Sign a string with server's private key with RSA-SHA256. 59 | * The certificate used for the API Login is used here as well. 60 | * The result is a string in JWS format. 61 | * 62 | * @param toSign The data to be signed 63 | */ 64 | public static sign(toSign: unknown): string { 65 | const perfLog = PerformanceLogger.startMeasurement('SecurityService', 'sign'); 66 | let jwsObj: string; 67 | try { 68 | /* RSASSA using SHA-256 hash algorithm */ 69 | const header: jws.Header = { alg: 'RS256' }; 70 | const privateKey = this.getServerSecretKey(); 71 | jwsObj = jws.sign({ 72 | header, 73 | payload: toSign, 74 | privateKey 75 | }); 76 | } catch (err) { 77 | logger.err('[SecurityService.sign] ' + JSON.stringify(err)); 78 | throw new Error('signature_creation_failed'); 79 | } 80 | PerformanceLogger.endMeasurement(perfLog); 81 | return jwsObj; 82 | } 83 | 84 | /** 85 | * Method to verify a string against a signature (RSA-SHA256) 86 | * 87 | * @param toVerify The data to be verified (string in JWS format) 88 | */ 89 | public static verifyJWS(toVerify: string): void { 90 | try { 91 | const verifyResult = jws.verify(toVerify, 'RS256', this.getServerPublicKey()); 92 | if (!verifyResult) { 93 | throw new Error('validation_result_false'); 94 | } 95 | } catch (err) { 96 | logger.err('[SecurityService.sign] ' + JSON.stringify(err)); 97 | throw new Error('signature_validation_failed'); 98 | } 99 | } 100 | 101 | /** 102 | * Decrypt a ciphertext from AES-256-CBC after decrypting the corresponding key from RSA-PKCS1 103 | * 104 | * @param ciphertext The data to be decrypted (base64 encoded string) 105 | * @param encryptedKey The rsa-encrypted key (base64 encoded string) 106 | * @param nonce The nonce for ciphertext decryption (base64 encoded string) 107 | */ 108 | public static decryptLogin(ciphertext: string, encryptedKey: string, nonce: string): string { 109 | try { 110 | let key: Buffer; 111 | try { 112 | key = crypto.privateDecrypt( 113 | { 114 | key: this.getServerSecretKey(), 115 | padding: crypto.constants.RSA_PKCS1_PADDING 116 | }, 117 | Buffer.from(encryptedKey, 'base64') 118 | ); 119 | } catch (err) { 120 | logger.err( 121 | '[SecurityService.decryptLogin][key_decryption_failed] ' + JSON.stringify(err) 122 | ); 123 | throw new Error('key_decryption_failed'); 124 | } 125 | 126 | const decipher = crypto.createDecipheriv( 127 | 'aes-256-cbc', 128 | key, 129 | Buffer.from(nonce, 'base64') 130 | ); 131 | const decrypted = 132 | decipher.update(ciphertext, 'base64', 'utf8') + decipher.final('utf8'); 133 | 134 | return decrypted; 135 | } catch (err) { 136 | if (err !== 'key_decryption_failed') { 137 | logger.err( 138 | '[SecurityService.decryptLogin][decryption_failed] ' + JSON.stringify(err) 139 | ); 140 | } 141 | throw new Error('decryption_failed'); 142 | } 143 | } 144 | 145 | /** 146 | * Creates a hash for a user password with given salt or randomly created salt 147 | * @param password The users password 148 | * @param salt The salt 149 | */ 150 | public static createPasswordHash( 151 | password: string, 152 | salt?: string 153 | ): { 154 | salt: string; 155 | passwordHash: string; 156 | } { 157 | if (salt === undefined) { 158 | salt = crypto 159 | .randomBytes(Math.ceil(16 / 2)) 160 | .toString('hex') 161 | .slice(0, 16); 162 | } 163 | const hmac: crypto.Hmac = crypto.createHmac('sha512', salt); /** Hashing algorithm sha512 */ 164 | hmac.update(password); 165 | const passwordHash = hmac.digest('hex'); 166 | 167 | return { 168 | salt, 169 | passwordHash 170 | }; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/types/ApiUserEntry.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /** 6 | * Represents an entry in the "apiuser" table. 7 | */ 8 | export interface ApiUserEntry { 9 | api_id: string; 10 | api_key: Date; 11 | api_key_salt: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/CTransfer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /** 6 | * Data class for the export functionality. 7 | */ 8 | export interface CTransfer { 9 | UUID: string; 10 | SubjectId: string; 11 | QuestionnaireId: string; 12 | Version: string; 13 | JSON: string; 14 | AbsendeDatum: Date; 15 | ErhaltenDatum: Date; 16 | } 17 | -------------------------------------------------------------------------------- /src/types/MeasurementObject.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /** 6 | * Helper interface for the {@link PerformanceLogger}. 7 | */ 8 | export interface MeasurementObject { 9 | className: string; 10 | methodName: string; 11 | timeStart: Date; 12 | } 13 | -------------------------------------------------------------------------------- /src/types/ParticipantEntry.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /** 6 | * Represents an entry in the "studyparticipant" table. 7 | */ 8 | export enum ParticipationStatus { 9 | OnStudy = 'on-study', 10 | OffStudy = 'off-study' 11 | } 12 | export interface ParticipantEntry { 13 | subject_id: string; 14 | last_action: Date; 15 | current_questionnaire_id: string; 16 | start_date: Date; 17 | due_date: Date; 18 | current_instance_id: string; 19 | current_interval: number; 20 | additional_iterations_left: number; 21 | status: ParticipationStatus; 22 | general_study_end_date: Date; 23 | personal_study_end_date: Date; 24 | language_code: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/types/QueueEntry.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | 5 | /** 6 | * Represents an entry in the queue table. 7 | */ 8 | export interface QueueEntry { 9 | id: string; 10 | subject_id: string; 11 | questionnaire_id: string; 12 | version: string; 13 | encrypted_resp: string; 14 | date_sent: Date; 15 | date_received: Date; 16 | } 17 | -------------------------------------------------------------------------------- /src/types/StateChangeTrigger.ts: -------------------------------------------------------------------------------- 1 | // TODO add reference to the rules config, once the logic is implemented 2 | /** 3 | * Logic trigger that are defined in the rules config. 4 | * 5 | * 6 | * @export 7 | * @interface StateTrigger 8 | */ 9 | export interface StateChangeTrigger { 10 | /** 11 | * Trigger for ? 12 | * 13 | * @type {boolean} 14 | * @memberof StateTrigger 15 | */ 16 | basicTrigger?: boolean; 17 | 18 | /** 19 | * Trigger for ? 20 | * 21 | * @type {boolean} 22 | * @memberof StateTrigger 23 | */ 24 | specialTrigger?: boolean; 25 | } 26 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021, IBM Deutschland GmbH 3 | */ 4 | export * from './ApiUserEntry'; 5 | export * from './CTransfer'; 6 | export * from './MeasurementObject'; 7 | export * from './QueueEntry'; 8 | export * from './ParticipantEntry'; 9 | export * from './StateChangeTrigger'; 10 | -------------------------------------------------------------------------------- /tests/models/ExampleStateModel.test.ts: -------------------------------------------------------------------------------- 1 | import { ExampleStateModel } from './../../src/models/ExampleStateModel'; 2 | import * as dotenv from 'dotenv'; 3 | import { COMPASSConfig } from '../../src/config/COMPASSConfig'; 4 | 5 | import { StateChangeTrigger, ParticipantEntry, ParticipationStatus } from '../../src/types'; 6 | 7 | describe('signing', () => { 8 | dotenv.config({ path: './.env' }); 9 | const sut = new ExampleStateModel(); 10 | let realDateNow; 11 | const initialDate = new Date(1572393600000); 12 | 13 | beforeAll(() => { 14 | realDateNow = Date.now.bind(global.Date); 15 | global.Date.now = jest.fn(() => 1572393600000); // 2019-10-30T00:00Z0 (GMT) 16 | }); 17 | 18 | afterAll(() => { 19 | global.Date.now = realDateNow; 20 | }); 21 | 22 | it('mustGoToInitialState', () => { 23 | // given 24 | const user: ParticipantEntry = { 25 | subject_id: '1', 26 | last_action: null, 27 | current_questionnaire_id: '', 28 | start_date: null, 29 | due_date: null, 30 | current_instance_id: null, 31 | current_interval: null, 32 | additional_iterations_left: null, 33 | status: ParticipationStatus.OnStudy, 34 | general_study_end_date: undefined, 35 | personal_study_end_date: undefined, 36 | language_code: 'de' 37 | }; 38 | const parameters: StateChangeTrigger = {}; 39 | 40 | // when 41 | const result = sut.calculateUpdatedData(user, parameters); 42 | 43 | // then 44 | 45 | //set up expected values 46 | const expectedStartDate = setUpExpectedStartDate( 47 | initialDate, 48 | COMPASSConfig.getDefaultStartHour() 49 | ); 50 | const expectedDueDate = setUpExpectedDueDate( 51 | expectedStartDate, 52 | COMPASSConfig.getDefaultDuration(), 53 | COMPASSConfig.getDefaultDueHour() 54 | ); 55 | 56 | expect(result.subject_id).toBe('1'); 57 | expect(result.last_action).toBe(null); 58 | expect(result.current_questionnaire_id).toBe(COMPASSConfig.getInitialQuestionnaireId()); 59 | expect(result.start_date.toISOString()).toBe(expectedStartDate.toISOString()); 60 | expect(result.due_date.toISOString()).toBe(expectedDueDate.toISOString()); 61 | expect(result.current_interval).toBe(COMPASSConfig.getDefaultInterval()); 62 | expect(result.additional_iterations_left).toBe(0); 63 | }); 64 | 65 | it('mustGoToDefaultState', () => { 66 | // given 67 | const user: ParticipantEntry = { 68 | subject_id: '1', 69 | last_action: null, 70 | current_questionnaire_id: COMPASSConfig.getInitialQuestionnaireId(), 71 | start_date: null, 72 | due_date: new Date(Date.now()), 73 | current_instance_id: null, 74 | current_interval: 1, 75 | additional_iterations_left: 0, 76 | status: ParticipationStatus.OnStudy, 77 | general_study_end_date: new Date(), 78 | personal_study_end_date: new Date(), 79 | language_code: COMPASSConfig.getDefaultLanguageCode() 80 | }; 81 | const parameters: StateChangeTrigger = {}; 82 | 83 | // when 84 | const result = sut.calculateUpdatedData(user, parameters); 85 | 86 | // then 87 | //set up expected values 88 | const expectedStartDate = setUpExpectedStartDate( 89 | initialDate, 90 | COMPASSConfig.getDefaultStartHour() 91 | ); 92 | const expectedDueDate = setUpExpectedDueDate( 93 | expectedStartDate, 94 | COMPASSConfig.getDefaultDuration(), 95 | COMPASSConfig.getDefaultDueHour() 96 | ); 97 | 98 | expect(result.subject_id).toBe('1'); 99 | expect(result.last_action).toBe(null); 100 | expect(result.current_questionnaire_id).toBe(COMPASSConfig.getDefaultQuestionnaireId()); 101 | expect(result.start_date.toISOString()).toBe(expectedStartDate.toISOString()); 102 | expect(result.due_date.toISOString()).toBe(expectedDueDate.toISOString()); 103 | expect(result.current_instance_id).toBeTruthy(); 104 | expect(result.current_interval).toBe(COMPASSConfig.getDefaultInterval()); 105 | expect(result.additional_iterations_left).toBe(0); 106 | }); 107 | 108 | it('mustGoToShortTrackState', () => { 109 | // given 110 | const user: ParticipantEntry = { 111 | subject_id: '1', 112 | last_action: null, 113 | current_questionnaire_id: COMPASSConfig.getInitialQuestionnaireId(), 114 | start_date: null, 115 | due_date: new Date(Date.now()), 116 | current_instance_id: null, 117 | current_interval: 1, 118 | additional_iterations_left: 0, 119 | status: ParticipationStatus.OnStudy, 120 | general_study_end_date: new Date(), 121 | personal_study_end_date: new Date(), 122 | language_code: COMPASSConfig.getDefaultLanguageCode() 123 | }; 124 | const parameters: StateChangeTrigger = { basicTrigger: true }; 125 | 126 | // when 127 | const result = sut.calculateUpdatedData(user, parameters); 128 | 129 | // then 130 | //set up expected values 131 | const expectedStartDate = setUpExpectedStartDate( 132 | initialDate, 133 | COMPASSConfig.getDefaultShortStartHour() 134 | ); 135 | const expectedDueDate = setUpExpectedDueDate( 136 | expectedStartDate, 137 | COMPASSConfig.getDefaultShortDuration(), 138 | COMPASSConfig.getDefaultShortDueHour() 139 | ); 140 | 141 | expect(result.subject_id).toBe('1'); 142 | expect(result.last_action).toBe(null); 143 | expect(result.current_questionnaire_id).toBe( 144 | COMPASSConfig.getDefaultShortQuestionnaireId() 145 | ); 146 | expect(result.start_date.toISOString()).toBe(expectedStartDate.toISOString()); 147 | expect(result.due_date.toISOString()).toBe(expectedDueDate.toISOString()); 148 | expect(result.current_instance_id).toBeTruthy(); 149 | expect(result.current_interval).toBe(COMPASSConfig.getDefaultShortInterval()); 150 | expect(result.additional_iterations_left).toBe(0); 151 | }); 152 | 153 | it('mustGoToShortTrackState', () => { 154 | // given 155 | const user: ParticipantEntry = { 156 | subject_id: '1', 157 | last_action: null, 158 | current_questionnaire_id: COMPASSConfig.getInitialQuestionnaireId(), 159 | start_date: null, 160 | due_date: new Date(Date.now()), 161 | current_instance_id: null, 162 | current_interval: 1, 163 | additional_iterations_left: 0, 164 | status: ParticipationStatus.OnStudy, 165 | general_study_end_date: new Date(), 166 | personal_study_end_date: new Date(), 167 | language_code: COMPASSConfig.getDefaultLanguageCode() 168 | }; 169 | const parameters: StateChangeTrigger = { specialTrigger: true }; 170 | 171 | // when 172 | const result = sut.calculateUpdatedData(user, parameters); 173 | 174 | // then 175 | //set up expected values 176 | const expectedStartDate = setUpExpectedStartDate( 177 | initialDate, 178 | COMPASSConfig.getDefaultShortStartHour() 179 | ); 180 | const expectedDueDate = setUpExpectedDueDate( 181 | expectedStartDate, 182 | COMPASSConfig.getDefaultShortDuration(), 183 | COMPASSConfig.getDefaultShortDueHour() 184 | ); 185 | expect(result.subject_id).toBe('1'); 186 | expect(result.last_action).toBe(null); 187 | expect(result.current_questionnaire_id).toBe( 188 | COMPASSConfig.getDefaultShortLimitedQuestionnaireId() 189 | ); 190 | expect(result.start_date.toISOString()).toBe(expectedStartDate.toISOString()); 191 | expect(result.due_date.toISOString()).toBe(expectedDueDate.toISOString()); 192 | expect(result.current_instance_id).toBeTruthy(); 193 | expect(result.current_interval).toBe(COMPASSConfig.getDefaultShortInterval()); 194 | expect(result.additional_iterations_left).toBe( 195 | COMPASSConfig.getDefaultIterationCount() - 1 196 | ); 197 | }); 198 | }); 199 | 200 | /** 201 | * calculate expected start date based on given initial date 202 | * 203 | * @param {Date} initialDate the initial date 204 | */ 205 | const setUpExpectedStartDate = (initialDate: Date, startHour: number) => { 206 | const expectedStartDate = new Date(initialDate); 207 | expectedStartDate.setDate(initialDate.getDate() + COMPASSConfig.getDefaultIntervalStartIndex()); 208 | expectedStartDate.setHours(startHour); 209 | return expectedStartDate; 210 | }; 211 | /** 212 | * calculate expected due date based on given start date and duration 213 | * 214 | * @param {Date} startDate the given start date 215 | * @param {number} duration the duration of the current interval 216 | */ 217 | const setUpExpectedDueDate = (startDate: Date, duration: number, dueHour: number) => { 218 | const expectedDueDate = new Date(startDate); 219 | expectedDueDate.setDate(startDate.getDate() + duration); 220 | expectedDueDate.setHours(dueHour); 221 | return expectedDueDate; 222 | }; 223 | -------------------------------------------------------------------------------- /tests/services/IdHelper.test.ts: -------------------------------------------------------------------------------- 1 | import { validate } from 'uuid'; 2 | 3 | import { IdHelper } from '../../src/services/IdHelper'; 4 | 5 | describe('IdHelper', () => { 6 | it('createId', () => { 7 | const uuid = IdHelper.createID(); 8 | expect(validate(uuid)).toBe(true); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /tests/services/SecurityService.test.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv'; 2 | 3 | import { SecurityService } from '../../src/services/SecurityService'; 4 | 5 | describe('signing', () => { 6 | dotenv.config({ path: './.env' }); 7 | 8 | // disable console output of the PerformanceLogger 9 | beforeEach(() => { 10 | // eslint-disable-next-line @typescript-eslint/no-empty-function 11 | jest.spyOn(console, 'log').mockImplementation(() => jest.fn()); 12 | }); 13 | 14 | it('signAndVerify', () => { 15 | let result: boolean; 16 | let newSign: string; 17 | const value = { name: 'KEY', sound: 'VALUE' }; 18 | try { 19 | newSign = SecurityService.sign({ key: value }); 20 | SecurityService.verifyJWS(newSign); 21 | result = true; 22 | } catch (err) { 23 | result = false; 24 | } 25 | expect(result).toBe(true); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "target": "es2017", 6 | "sourceMap": true, 7 | "experimentalDecorators": true, 8 | "emitDecoratorMetadata": true, 9 | "outDir": "./build", 10 | "esModuleInterop": true 11 | }, 12 | "files": [ 13 | "./src/server/Route.ts" 14 | ], 15 | "include": [ 16 | "./src/*" 17 | ], 18 | "exclude": [ 19 | "node_modules" 20 | ] 21 | } 22 | --------------------------------------------------------------------------------