diff --git a/.github/workflows/lint_pr.yml b/.github/workflows/lint_pr.yml new file mode 100644 index 000000000..2f9229737 --- /dev/null +++ b/.github/workflows/lint_pr.yml @@ -0,0 +1,72 @@ +name: "gradido lint pull request CI" + +on: + pull_request: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Configure which types are allowed (newline delimited). + # Default: https://github.com/commitizen/conventional-commit-types + #types: | + # fix + # feat + # Configure which scopes are allowed (newline delimited). + scopes: | + backend + frontend + admin + database + release + other + # Configure that a scope must always be provided. + requireScope: true + # Configure which scopes (newline delimited) are disallowed in PR + # titles. For instance by setting # the value below, `chore(release): + # ...` and `ci(e2e,release): ...` will be rejected. + #disallowScopes: | + # release + # Configure additional validation for the subject based on a regex. + # This example ensures the subject doesn't start with an uppercase character. + subjectPattern: ^(?![A-Z]).+$ + # If `subjectPattern` is configured, you can use this property to override + # the default error message that is shown when the pattern doesn't match. + # The variables `subject` and `title` can be used within the message. + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + doesn't start with an uppercase character. + # If you use GitHub Enterprise, you can set this to the URL of your server + #githubBaseUrl: https://github.myorg.com/api/v3 + # If the PR contains one of these labels (newline delimited), the + # validation is skipped. + # If you want to rerun the validation when labels change, you might want + # to use the `labeled` and `unlabeled` event triggers in your workflow. + #ignoreLabels: | + # bot + # ignore-semantic-pull-request + # If you're using a format for the PR title that differs from the traditional Conventional + # Commits spec, you can use these options to customize the parsing of the type, scope and + # subject. The `headerPattern` should contain a regex where the capturing groups in parentheses + # correspond to the parts listed in `headerPatternCorrespondence`. + # See: https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-commits-parser#headerpattern + headerPattern: '^(\w*)(?:\(([\w$.\-*/ ]*)\))?: (.*)$' + headerPatternCorrespondence: type, scope, subject + # For work-in-progress PRs you can typically use draft pull requests + # from GitHub. However, private repositories on the free plan don't have + # this option and therefore this action allows you to opt-in to using the + # special "[WIP]" prefix to indicate this state. This will avoid the + # validation of the PR title and the pull request checks remain pending. + # Note that a second check will be reported if this is enabled. + wip: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7000100e..c3238507a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,6 @@ name: gradido test CI -on: [push] +on: push jobs: ############################################################################## @@ -15,7 +15,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # FRONTEND ############################################################### ########################################################################## @@ -24,7 +24,7 @@ jobs: docker build --target test -t "gradido/frontend:test" frontend/ docker save "gradido/frontend:test" > /tmp/frontend.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-frontend-test path: /tmp/frontend.tar @@ -41,7 +41,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # ADMIN INTERFACE ######################################################## ########################################################################## @@ -50,7 +50,7 @@ jobs: docker build --target test -t "gradido/admin:test" admin/ --build-arg NODE_ENV="test" docker save "gradido/admin:test" > /tmp/admin.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-admin-test path: /tmp/admin.tar @@ -67,7 +67,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # BACKEND ################################################################ ########################################################################## @@ -76,7 +76,7 @@ jobs: docker build -f ./backend/Dockerfile --target test -t "gradido/backend:test" . docker save "gradido/backend:test" > /tmp/backend.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-backend-test path: /tmp/backend.tar @@ -93,7 +93,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DATABASE UP ############################################################ ########################################################################## @@ -102,7 +102,7 @@ jobs: docker build --target test_up -t "gradido/database:test_up" database/ docker save "gradido/database:test_up" > /tmp/database_up.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-database-test_up path: /tmp/database_up.tar @@ -119,7 +119,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # BUILD MARIADB DOCKER IMAGE ############################################# ########################################################################## @@ -128,7 +128,7 @@ jobs: docker build --target mariadb_server -t "gradido/mariadb:test" -f ./mariadb/Dockerfile ./ docker save "gradido/mariadb:test" > /tmp/mariadb.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-mariadb-test path: /tmp/mariadb.tar @@ -139,13 +139,13 @@ jobs: build_test_nginx: name: Docker Build Test - Nginx runs-on: ubuntu-latest - #needs: [nothing] + needs: [build_test_backend, build_test_admin, build_test_frontend] steps: ########################################################################## # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # BUILD NGINX DOCKER IMAGE ############################################### ########################################################################## @@ -154,7 +154,7 @@ jobs: docker build -t "gradido/nginx:test" nginx/ docker save "gradido/nginx:test" > /tmp/nginx.tar - name: Upload Artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: docker-nginx-test path: /tmp/nginx.tar @@ -171,12 +171,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Frontend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-frontend-test path: /tmp @@ -200,12 +200,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Frontend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-frontend-test path: /tmp @@ -229,12 +229,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Frontend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-frontend-test path: /tmp @@ -258,12 +258,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Admin Interface) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-admin-test path: /tmp @@ -287,12 +287,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Admin Interface) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-admin-test path: /tmp @@ -308,7 +308,7 @@ jobs: # JOB: LOCALES ADMIN ######################################################### ############################################################################## locales_admin: - name: Locales - Admin + name: Locales - Admin Interface runs-on: ubuntu-latest needs: [build_test_admin] steps: @@ -316,12 +316,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Admin Interface) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-admin-test path: /tmp @@ -345,12 +345,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Backend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-backend-test path: /tmp @@ -374,12 +374,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGE ################################################## ########################################################################## - name: Download Docker Image (Backend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-database-test_up path: /tmp @@ -403,12 +403,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGES ################################################# ########################################################################## - name: Download Docker Image (Frontend) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-frontend-test path: /tmp @@ -453,12 +453,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGES ################################################# ########################################################################## - name: Download Docker Image (Admin Interface) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-admin-test path: /tmp @@ -495,12 +495,12 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOWNLOAD DOCKER IMAGES ################################################# ########################################################################## - name: Download Docker Image (Mariadb) - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: docker-mariadb-test path: /tmp @@ -528,7 +528,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 68 + min_coverage: 74 token: ${{ github.token }} ########################################################################## @@ -543,7 +543,7 @@ jobs: # CHECKOUT CODE ########################################################## ########################################################################## - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 ########################################################################## # DOCKER COMPOSE DATABASE UP + RESET ##################################### ########################################################################## @@ -553,3 +553,108 @@ jobs: run: docker-compose -f docker-compose.yml run -T database yarn up - name: database | reset run: docker-compose -f docker-compose.yml run -T database yarn reset + + ############################################################################## + # JOB: END-TO-END TESTS ##################################################### + ############################################################################## + end-to-end-tests: + name: End-to-End Tests + runs-on: ubuntu-latest + needs: [build_test_mariadb, build_test_database_up, build_test_backend, build_test_admin, build_test_frontend, build_test_nginx] + steps: + ########################################################################## + # CHECKOUT CODE ########################################################## + ########################################################################## + - name: Checkout code + uses: actions/checkout@v3 + ########################################################################## + # DOWNLOAD DOCKER IMAGES ################################################# + ########################################################################## + - name: Download Docker Image (Mariadb) + uses: actions/download-artifact@v3 + with: + name: docker-mariadb-test + path: /tmp + - name: Load Docker Image (Mariadb) + run: docker load < /tmp/mariadb.tar + - name: Download Docker Image (Database Up) + uses: actions/download-artifact@v3 + with: + name: docker-database-test_up + path: /tmp + - name: Load Docker Image (Database Up) + run: docker load < /tmp/database_up.tar + - name: Download Docker Image (Backend) + uses: actions/download-artifact@v3 + with: + name: docker-backend-test + path: /tmp + - name: Load Docker Image (Backend) + run: docker load < /tmp/backend.tar + - name: Download Docker Image (Frontend) + uses: actions/download-artifact@v3 + with: + name: docker-frontend-test + path: /tmp + - name: Load Docker Image (Frontend) + run: docker load < /tmp/frontend.tar + - name: Download Docker Image (Admin Interface) + uses: actions/download-artifact@v3 + with: + name: docker-admin-test + path: /tmp + - name: Load Docker Image (Admin Interface) + run: docker load < /tmp/admin.tar + - name: Download Docker Image (Nginx) + uses: actions/download-artifact@v3 + with: + name: docker-nginx-test + path: /tmp + - name: Load Docker Image (Nginx) + run: docker load < /tmp/nginx.tar + + ########################################################################## + # BOOT UP THE TEST SYSTEM ################################################ + ########################################################################## + - name: Boot up test system | docker-compose mariadb + run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb + + - name: Boot up test system | docker-compose database + run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database + + - name: Boot up test system | docker-compose backend + run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend + + - name: Sleep for 10 seconds + run: sleep 10s + + - name: Boot up test system | seed backend + run: | + sudo chown runner:docker -R * + cd database + yarn && yarn dev_reset + cd ../backend + yarn && yarn seed + cd .. + + - name: Boot up test system | docker-compose frontends + run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps frontend admin nginx + + - name: Sleep for 15 seconds + run: sleep 15s + + ########################################################################## + # END-TO-END TESTS ####################################################### + ########################################################################## + - name: End-to-end tests | run tests + id: e2e-tests + run: | + cd e2e-tests/cypress/tests/ + yarn + yarn run cypress run --spec cypress/e2e/User.Authentication.feature + - name: End-to-end tests | if tests failed, upload screenshots + if: steps.e2e-tests.outcome == 'failure' + uses: actions/upload-artifact@v3 + with: + name: cypress-screenshots + path: /home/runner/work/gradido/gradido/e2e-tests/cypress/tests/cypress/screenshots/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a704739..754566658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,83 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [1.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3) + +- 2294 contribution links on its own page [`#2312`](https://github.com/gradido/gradido/pull/2312) +- fix: Change Orange Color [`#2302`](https://github.com/gradido/gradido/pull/2302) +- fix: Release Statistic Query Runner [`#2320`](https://github.com/gradido/gradido/pull/2320) +- bug: 2295 remove horizontal scrollbar in admin overview [`#2311`](https://github.com/gradido/gradido/pull/2311) +- 2292 community information contact [`#2313`](https://github.com/gradido/gradido/pull/2313) +- bug: 2315 Contribution Month and TEST FAIL in MASTER [`#2316`](https://github.com/gradido/gradido/pull/2316) +- 2291 add button for close contribution messages box [`#2314`](https://github.com/gradido/gradido/pull/2314) + +#### [1.13.2](https://github.com/gradido/gradido/compare/1.13.1...1.13.2) + +> 28 October 2022 + +- release: Version 1.13.2 [`#2307`](https://github.com/gradido/gradido/pull/2307) +- fix: 🍰 Links In Contribution Messages Target Blank [`#2306`](https://github.com/gradido/gradido/pull/2306) +- fix: Link in Contribution Messages [`#2305`](https://github.com/gradido/gradido/pull/2305) +- Refactor: 🍰 Change the query so that we only look on the ``contributions`` table. [`#2217`](https://github.com/gradido/gradido/pull/2217) +- Refactor: Admin Resolver Events and Logging [`#2244`](https://github.com/gradido/gradido/pull/2244) +- contibution messages, links are recognised [`#2248`](https://github.com/gradido/gradido/pull/2248) +- fix: Include Deleted Email Contacts in User Search [`#2281`](https://github.com/gradido/gradido/pull/2281) +- fix: Pagination Contributions jumps to wrong Page [`#2284`](https://github.com/gradido/gradido/pull/2284) +- fix: Changed some texts in E-Mails and Frontend [`#2276`](https://github.com/gradido/gradido/pull/2276) +- Feat: 🍰 Add `deletedBy` To Contributions And Admin Can Not Delete Own User Contribution [`#2236`](https://github.com/gradido/gradido/pull/2236) +- deleted contributions are displayed to the user [`#2277`](https://github.com/gradido/gradido/pull/2277) + +#### [1.13.1](https://github.com/gradido/gradido/compare/1.13.0...1.13.1) + +> 20 October 2022 + +- release: Version 1.13.1 [`#2279`](https://github.com/gradido/gradido/pull/2279) +- Fix: correctly evaluate to EMAIL_TEST_MODE to false [`#2273`](https://github.com/gradido/gradido/pull/2273) +- Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231) + +#### [1.13.0](https://github.com/gradido/gradido/compare/1.12.1...1.13.0) + +> 18 October 2022 + +- release: Version 1.13.0 [`#2269`](https://github.com/gradido/gradido/pull/2269) +- fix: Linked User Email in Transaction List [`#2268`](https://github.com/gradido/gradido/pull/2268) +- concept capturing alias [`#2148`](https://github.com/gradido/gradido/pull/2148) +- fix: 🍰 Daily Redeem Of Contribution Link [`#2265`](https://github.com/gradido/gradido/pull/2265) +- fix: 🐛 Prevent Loosing Redeem Code When Changing Between Register and Login in Auth Navbar [`#2260`](https://github.com/gradido/gradido/pull/2260) +- fix: Disable Change of Month on Update Contribution [`#2264`](https://github.com/gradido/gradido/pull/2264) +- feat: 🍰 Global Jest Extension For Decimal Equal [`#2261`](https://github.com/gradido/gradido/pull/2261) +- feat: 🍰 Daily Rule For Contribution Links In Admin Interface [`#2262`](https://github.com/gradido/gradido/pull/2262) +- feat: 🍰 Do Not Show Expired Contribution Links In Wallet [`#2257`](https://github.com/gradido/gradido/pull/2257) +- fix: 🍰 Disable Change Of Month For Update Contribution (wallet and admin) [`#2258`](https://github.com/gradido/gradido/pull/2258) +- refactor: 🍰 Login And Logout To Mutations [`#2232`](https://github.com/gradido/gradido/pull/2232) +- fix: 🐛 Verify Token Before Redeeming A Link [`#2254`](https://github.com/gradido/gradido/pull/2254) +- Refactor: Add all events to documentation table [`#2240`](https://github.com/gradido/gradido/pull/2240) +- reconfig log4js with rollover feature and userid in logevent-message [`#2221`](https://github.com/gradido/gradido/pull/2221) +- refactor: 🍰 Refactoring Components Of `CotributionMessagesListItem` [`#2251`](https://github.com/gradido/gradido/pull/2251) +- style: add border-radius on send form [`#2233`](https://github.com/gradido/gradido/pull/2233) +- 2198 adminarea more dates on created transaction [`#2212`](https://github.com/gradido/gradido/pull/2212) +- Bug: delete contribution link [`#2213`](https://github.com/gradido/gradido/pull/2213) +- chore: 🍰 Fix Cypress Tests Unreliability [`#2245`](https://github.com/gradido/gradido/pull/2245) +- docs: 🍰 Refine Deployment Documentation [`#2209`](https://github.com/gradido/gradido/pull/2209) +- End-to-end test setup [`#2047`](https://github.com/gradido/gradido/pull/2047) +- config testmodus flag for sending emails to test or team account instead of user account [`#2216`](https://github.com/gradido/gradido/pull/2216) +- GradidoID 1: adapt and migrate database schema [`#2058`](https://github.com/gradido/gradido/pull/2058) +- feat: Add Client Request Time to Context [`#2206`](https://github.com/gradido/gradido/pull/2206) +- 2219 feature rework eventprotocol [`#2234`](https://github.com/gradido/gradido/pull/2234) +- Refactor: Test register with redeem code [`#2214`](https://github.com/gradido/gradido/pull/2214) +- 2203 delete query modal when redeeming the redeem link [`#2211`](https://github.com/gradido/gradido/pull/2211) +- Refactor: 🍰 Change email templates [`#2228`](https://github.com/gradido/gradido/pull/2228) +- Refactor: Events and logs completed in User Resolver [`#2204`](https://github.com/gradido/gradido/pull/2204) +- change support mail [`#2210`](https://github.com/gradido/gradido/pull/2210) +- feat: 🍰 Send email when contribution is confirmed [`#2193`](https://github.com/gradido/gradido/pull/2193) +- feat: 🍰 Send email when admin writes message to contribution [`#2187`](https://github.com/gradido/gradido/pull/2187) +- feat: 🍰 Send Email To Transaction Link Sender After Receiver Redeemed It [`#2063`](https://github.com/gradido/gradido/pull/2063) + #### [1.12.1](https://github.com/gradido/gradido/compare/1.12.0...1.12.1) +> 13 September 2022 + +- release: Version 1.12.1 [`#2196`](https://github.com/gradido/gradido/pull/2196) - fix: 🍰 Show Not Icons In `allContribution` List [`#2195`](https://github.com/gradido/gradido/pull/2195) #### [1.12.0](https://github.com/gradido/gradido/compare/1.11.0...1.12.0) diff --git a/admin/Dockerfile b/admin/Dockerfile index 41f986f87..ed0623a63 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -20,10 +20,10 @@ ENV PORT="8080" # Labels LABEL org.label-schema.build-date="${BUILD_DATE}" LABEL org.label-schema.name="gradido:admin" -LABEL org.label-schema.description="Gradido Vue Admin Interface" -LABEL org.label-schema.usage="https://github.com/gradido/gradido/admin/README.md" +LABEL org.label-schema.description="Gradido Admin Interface" +LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md" LABEL org.label-schema.url="https://gradido.net" -LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/backend" +LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/admin" LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}" LABEL org.label-schema.vendor="gradido Community" LABEL org.label-schema.version="${BUILD_VERSION}" diff --git a/admin/package.json b/admin/package.json index f56ae0a87..82a2413de 100644 --- a/admin/package.json +++ b/admin/package.json @@ -3,7 +3,7 @@ "description": "Administraion Interface for Gradido", "main": "index.js", "author": "Moriz Wahl", - "version": "1.12.1", + "version": "1.13.3", "license": "Apache-2.0", "private": false, "scripts": { @@ -53,6 +53,7 @@ "vuex-persistedstate": "^4.1.0" }, "devDependencies": { + "@apollo/client": "^3.7.1", "@babel/eslint-parser": "^7.15.8", "@intlify/eslint-plugin-vue-i18n": "^1.4.0", "@vue/cli-plugin-babel": "~4.5.0", @@ -71,6 +72,7 @@ "eslint-plugin-prettier": "3.3.1", "eslint-plugin-promise": "^5.1.1", "eslint-plugin-vue": "^7.20.0", + "mock-apollo-client": "^1.2.1", "postcss": "^8.4.8", "postcss-html": "^1.3.0", "postcss-scss": "^4.0.3", diff --git a/admin/src/components/ContentFooter.vue b/admin/src/components/ContentFooter.vue index bab3f5d12..a875100f6 100644 --- a/admin/src/components/ContentFooter.vue +++ b/admin/src/components/ContentFooter.vue @@ -1,7 +1,7 @@ diff --git a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.spec.js b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.spec.js index b8aaba502..1b4f963e8 100644 --- a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.spec.js +++ b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.spec.js @@ -3,12 +3,16 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue' const localVue = global.localVue +const dateMock = jest.fn((d) => d) +const numberMock = jest.fn((n) => n) + describe('ContributionMessagesListItem', () => { let wrapper const mocks = { $t: jest.fn((t) => t), - $d: jest.fn((d) => d), + $d: dateMock, + $n: numberMock, } describe('if message author has moderator role', () => { @@ -125,4 +129,128 @@ describe('ContributionMessagesListItem', () => { }) }) }) + + describe('links in contribtion message', () => { + const propsData = { + message: { + id: 111, + message: 'Lorem ipsum?', + createdAt: '2022-08-29T12:23:27.000Z', + updatedAt: null, + type: 'DIALOG', + userFirstName: 'Peter', + userLastName: 'Lustig', + userId: 107, + __typename: 'ContributionMessage', + }, + } + + const ModeratorItemWrapper = () => { + return mount(ContributionMessagesListItem, { + localVue, + mocks, + propsData, + }) + } + + let messageField + + describe('message of only one link', () => { + beforeEach(() => { + propsData.message.message = 'https://gradido.net/de/' + wrapper = ModeratorItemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)') + }) + + it('contains the link as text', () => { + expect(messageField.text()).toBe('https://gradido.net/de/') + }) + + it('contains a link to the given address', () => { + expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/') + }) + }) + + describe('message with text and two links', () => { + beforeEach(() => { + propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/ +and here is the link to the repository: https://github.com/gradido/gradido` + wrapper = ModeratorItemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)') + }) + + it('contains the whole text', () => { + expect(messageField.text()) + .toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/ +and here is the link to the repository: https://github.com/gradido/gradido`) + }) + + it('contains the two links', () => { + expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/') + expect(messageField.findAll('a').at(1).attributes('href')).toBe( + 'https://github.com/gradido/gradido', + ) + }) + }) + }) + + describe('contribution message type HISTORY', () => { + const propsData = { + message: { + id: 111, + message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time) +--- +This message also contains a link: https://gradido.net/de/ +--- +350.00`, + createdAt: '2022-08-29T12:23:27.000Z', + updatedAt: null, + type: 'HISTORY', + userFirstName: 'Peter', + userLastName: 'Lustig', + userId: 107, + __typename: 'ContributionMessage', + }, + } + + const itemWrapper = () => { + return mount(ContributionMessagesListItem, { + localVue, + mocks, + propsData, + }) + } + + let messageField + + describe('render HISTORY message', () => { + beforeEach(() => { + jest.clearAllMocks() + wrapper = itemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)') + }) + + it('renders the date', () => { + expect(dateMock).toBeCalledWith( + new Date('Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time'), + 'short', + ) + }) + + it('renders the amount', () => { + expect(numberMock).toBeCalledWith(350, 'decimal') + expect(messageField.text()).toContain('350 GDD') + }) + + it('contains the link as text', () => { + expect(messageField.text()).toContain( + 'This message also contains a link: https://gradido.net/de/', + ) + }) + + it('contains a link to the given address', () => { + expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/') + }) + }) + }) }) diff --git a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue index 796ff5f30..53006cff5 100644 --- a/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue +++ b/admin/src/components/ContributionMessages/slots/ContributionMessagesListItem.vue @@ -1,23 +1,28 @@ diff --git a/admin/src/pages/CreationConfirm.spec.js b/admin/src/pages/CreationConfirm.spec.js index 0ea1aeba2..13fa24f5f 100644 --- a/admin/src/pages/CreationConfirm.spec.js +++ b/admin/src/pages/CreationConfirm.spec.js @@ -1,42 +1,22 @@ import { mount } from '@vue/test-utils' import CreationConfirm from './CreationConfirm.vue' import { adminDeleteContribution } from '../graphql/adminDeleteContribution' +import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions' import { confirmContribution } from '../graphql/confirmContribution' import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' +import VueApollo from 'vue-apollo' +import { createMockClient } from 'mock-apollo-client' + +const mockClient = createMockClient() +const apolloProvider = new VueApollo({ + defaultClient: mockClient, +}) const localVue = global.localVue -const storeCommitMock = jest.fn() -const apolloQueryMock = jest.fn().mockResolvedValue({ - data: { - listUnconfirmedContributions: [ - { - id: 1, - firstName: 'Bibi', - lastName: 'Bloxberg', - userId: 99, - email: 'bibi@bloxberg.de', - amount: 500, - memo: 'Danke für alles', - date: new Date(), - moderator: 1, - }, - { - id: 2, - firstName: 'Räuber', - lastName: 'Hotzenplotz', - userId: 100, - email: 'raeuber@hotzenplotz.de', - amount: 1000000, - memo: 'Gut Ergattert', - date: new Date(), - moderator: 1, - }, - ], - }, -}) +localVue.use(VueApollo) -const apolloMutateMock = jest.fn().mockResolvedValue({}) +const storeCommitMock = jest.fn() const mocks = { $t: jest.fn((t) => t), @@ -53,17 +33,69 @@ const mocks = { }, }, }, - $apollo: { - query: apolloQueryMock, - mutate: apolloMutateMock, - }, +} + +const defaultData = () => { + return { + listUnconfirmedContributions: [ + { + id: 1, + firstName: 'Bibi', + lastName: 'Bloxberg', + userId: 99, + email: 'bibi@bloxberg.de', + amount: 500, + memo: 'Danke für alles', + date: new Date(), + moderator: 1, + state: 'PENDING', + creation: [500, 500, 500], + messageCount: 0, + }, + { + id: 2, + firstName: 'Räuber', + lastName: 'Hotzenplotz', + userId: 100, + email: 'raeuber@hotzenplotz.de', + amount: 1000000, + memo: 'Gut Ergattert', + date: new Date(), + moderator: 1, + state: 'PENDING', + creation: [500, 500, 500], + messageCount: 0, + }, + ], + } } describe('CreationConfirm', () => { let wrapper + const listUnconfirmedContributionsMock = jest.fn() + const adminDeleteContributionMock = jest.fn() + const confirmContributionMock = jest.fn() + + mockClient.setRequestHandler( + listUnconfirmedContributions, + listUnconfirmedContributionsMock + .mockRejectedValueOnce({ message: 'Ouch!' }) + .mockResolvedValue({ data: defaultData() }), + ) + + mockClient.setRequestHandler( + adminDeleteContribution, + adminDeleteContributionMock.mockResolvedValue({ data: { adminDeleteContribution: true } }), + ) + + mockClient.setRequestHandler( + confirmContribution, + confirmContributionMock.mockResolvedValue({ data: { confirmContribution: true } }), + ) + const Wrapper = () => { - return mount(CreationConfirm, { localVue, mocks }) + return mount(CreationConfirm, { localVue, mocks, apolloProvider }) } describe('mount', () => { @@ -72,12 +104,20 @@ describe('CreationConfirm', () => { wrapper = Wrapper() }) - it('has a DIV element with the class.creation-confirm', () => { - expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy() + describe('server response for get pending creations is error', () => { + it('toast an error message', () => { + expect(toastErrorSpy).toBeCalledWith('Ouch!') + }) }) - it('has two pending creations', () => { - expect(wrapper.vm.pendingCreations).toHaveLength(2) + describe('server response is succes', () => { + it('has a DIV element with the class.creation-confirm', () => { + expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy() + }) + + it('has two pending creations', () => { + expect(wrapper.vm.pendingCreations).toHaveLength(2) + }) }) describe('store', () => { @@ -105,10 +145,7 @@ describe('CreationConfirm', () => { }) it('calls the adminDeleteContribution mutation', () => { - expect(apolloMutateMock).toBeCalledWith({ - mutation: adminDeleteContribution, - variables: { id: 1 }, - }) + expect(adminDeleteContributionMock).toBeCalledWith({ id: 1 }) }) it('commits openCreationsMinus to store', () => { @@ -128,7 +165,7 @@ describe('CreationConfirm', () => { }) it('does not call the adminDeleteContribution mutation', () => { - expect(apolloMutateMock).not.toBeCalled() + expect(adminDeleteContributionMock).not.toBeCalled() }) }) }) @@ -139,7 +176,7 @@ describe('CreationConfirm', () => { beforeEach(async () => { spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm') spy.mockImplementation(() => Promise.resolve('some value')) - apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' }) + adminDeleteContributionMock.mockRejectedValue({ message: 'Ouchhh!' }) await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click') }) @@ -150,7 +187,6 @@ describe('CreationConfirm', () => { describe('confirm creation with success', () => { beforeEach(async () => { - apolloMutateMock.mockResolvedValue({}) await wrapper.findAll('tr').at(2).findAll('button').at(2).trigger('click') }) @@ -179,10 +215,7 @@ describe('CreationConfirm', () => { }) it('calls the confirmContribution mutation', () => { - expect(apolloMutateMock).toBeCalledWith({ - mutation: confirmContribution, - variables: { id: 2 }, - }) + expect(confirmContributionMock).toBeCalledWith({ id: 2 }) }) it('commits openCreationsMinus to store', () => { @@ -200,7 +233,7 @@ describe('CreationConfirm', () => { describe('confirm creation with error', () => { beforeEach(async () => { - apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' }) + confirmContributionMock.mockRejectedValue({ message: 'Ouchhh!' }) await wrapper.find('#overlay').findAll('button').at(1).trigger('click') }) @@ -210,19 +243,5 @@ describe('CreationConfirm', () => { }) }) }) - - describe('server response for get pending creations is error', () => { - beforeEach(() => { - jest.clearAllMocks() - apolloQueryMock.mockRejectedValue({ - message: 'Ouch!', - }) - wrapper = Wrapper() - }) - - it('toast an error message', () => { - expect(toastErrorSpy).toBeCalledWith('Ouch!') - }) - }) }) }) diff --git a/admin/src/pages/CreationConfirm.vue b/admin/src/pages/CreationConfirm.vue index 1f82fafc4..311c898a5 100644 --- a/admin/src/pages/CreationConfirm.vue +++ b/admin/src/pages/CreationConfirm.vue @@ -10,6 +10,7 @@ @remove-creation="removeCreation" @show-overlay="showOverlay" @update-state="updateState" + @update-contributions="$apollo.queries.PendingContributions.refetch()" /> @@ -71,21 +72,6 @@ export default { this.toastError(error.message) }) }, - getPendingCreations() { - this.$apollo - .query({ - query: listUnconfirmedContributions, - fetchPolicy: 'network-only', - }) - .then((result) => { - this.$store.commit('resetOpenCreations') - this.pendingCreations = result.data.listUnconfirmedContributions - this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length) - }) - .catch((error) => { - this.toastError(error.message) - }) - }, updatePendingCreations(id) { this.pendingCreations = this.pendingCreations.filter((obj) => obj.id !== id) this.$store.commit('openCreationsMinus', 1) @@ -127,8 +113,24 @@ export default { ] }, }, - async created() { - await this.getPendingCreations() + apollo: { + PendingContributions: { + query() { + return listUnconfirmedContributions + }, + variables() { + // may be at some point we need a pagination here + return {} + }, + update({ listUnconfirmedContributions }) { + this.$store.commit('resetOpenCreations') + this.pendingCreations = listUnconfirmedContributions + this.$store.commit('setOpenCreations', listUnconfirmedContributions.length) + }, + error({ message }) { + this.toastError(message) + }, + }, }, } diff --git a/admin/src/pages/Overview.spec.js b/admin/src/pages/Overview.spec.js index e0063a446..affd018a7 100644 --- a/admin/src/pages/Overview.spec.js +++ b/admin/src/pages/Overview.spec.js @@ -1,6 +1,5 @@ import { mount } from '@vue/test-utils' import Overview from './Overview.vue' -import { listContributionLinks } from '@/graphql/listContributionLinks.js' import { communityStatistics } from '@/graphql/communityStatistics.js' import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js' @@ -36,27 +35,6 @@ const apolloQueryMock = jest }, }, }) - .mockResolvedValueOnce({ - data: { - listContributionLinks: { - links: [ - { - id: 1, - name: 'Meditation', - memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l', - amount: '200', - validFrom: '2022-04-01', - validTo: '2022-08-01', - cycle: 'täglich', - maxPerCycle: '3', - maxAmountPerMonth: 0, - link: 'https://localhost/redeem/CL-1a2345678', - }, - ], - count: 1, - }, - }, - }) .mockResolvedValue({ data: { listUnconfirmedContributions: [ @@ -78,6 +56,7 @@ const storeCommitMock = jest.fn() const mocks = { $t: jest.fn((t) => t), $n: jest.fn((n) => n), + $d: jest.fn((d) => d), $apollo: { query: apolloQueryMock, }, @@ -117,14 +96,6 @@ describe('Overview', () => { ) }) - it('calls listContributionLinks', () => { - expect(apolloQueryMock).toBeCalledWith( - expect.objectContaining({ - query: listContributionLinks, - }), - ) - }) - it('commits three pending creations to store', () => { expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3) }) diff --git a/admin/src/pages/Overview.vue b/admin/src/pages/Overview.vue index cfa247b8e..57bf7ff8c 100644 --- a/admin/src/pages/Overview.vue +++ b/admin/src/pages/Overview.vue @@ -28,31 +28,21 @@ - diff --git a/admin/src/router/router.test.js b/admin/src/router/router.test.js index eb9b646cb..22273c15b 100644 --- a/admin/src/router/router.test.js +++ b/admin/src/router/router.test.js @@ -45,7 +45,7 @@ describe('router', () => { describe('routes', () => { it('has seven routes defined', () => { - expect(routes).toHaveLength(7) + expect(routes).toHaveLength(8) }) it('has "/overview" as default', async () => { @@ -81,6 +81,13 @@ describe('router', () => { }) }) + describe('contribution-links', () => { + it('loads the "ContributionLinks" component', async () => { + const component = await routes.find((r) => r.path === '/contribution-links').component() + expect(component.default.name).toBe('ContributionLinks') + }) + }) + describe('not found page', () => { it('renders the "NotFound" component', async () => { const component = await routes.find((r) => r.path === '*').component() diff --git a/admin/src/router/routes.js b/admin/src/router/routes.js index 72e7b1ac5..ee82f128e 100644 --- a/admin/src/router/routes.js +++ b/admin/src/router/routes.js @@ -23,6 +23,10 @@ const routes = [ path: '/creation-confirm', component: () => import('@/pages/CreationConfirm.vue'), }, + { + path: '/contribution-links', + component: () => import('@/pages/ContributionLinks.vue'), + }, { path: '*', component: () => import('@/components/NotFoundPage.vue'), diff --git a/admin/yarn.lock b/admin/yarn.lock index 09b543354..7507f2559 100644 --- a/admin/yarn.lock +++ b/admin/yarn.lock @@ -2,6 +2,25 @@ # yarn lockfile v1 +"@apollo/client@^3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.7.1.tgz#86ce47c18a0714e229231148b0306562550c2248" + integrity sha512-xu5M/l7p9gT9Fx7nF3AQivp0XukjB7TM7tOd5wifIpI8RskYveL4I+rpTijzWrnqCPZabkbzJKH7WEAKdctt9w== + dependencies: + "@graphql-typed-document-node/core" "^3.1.1" + "@wry/context" "^0.7.0" + "@wry/equality" "^0.5.0" + "@wry/trie" "^0.3.0" + graphql-tag "^2.12.6" + hoist-non-react-statics "^3.3.2" + optimism "^0.16.1" + prop-types "^15.7.2" + response-iterator "^0.2.6" + symbol-observable "^4.0.0" + ts-invariant "^0.10.3" + tslib "^2.3.0" + zen-observable-ts "^1.2.5" + "@babel/code-frame@7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" @@ -1030,6 +1049,11 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@graphql-typed-document-node/core@^3.1.1": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052" + integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg== + "@hapi/address@2.x.x": version "2.1.4" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" @@ -2419,6 +2443,20 @@ "@types/node" ">=6" tslib "^1.9.3" +"@wry/context@^0.6.0": + version "0.6.1" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.6.1.tgz#c3c29c0ad622adb00f6a53303c4f965ee06ebeb2" + integrity sha512-LOmVnY1iTU2D8tv4Xf6MVMZZ+juIJ87Kt/plMijjN20NMAXGmH4u8bS1t0uT74cZ5gwpocYueV58YwyI8y+GKw== + dependencies: + tslib "^2.3.0" + +"@wry/context@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.7.0.tgz#be88e22c0ddf62aeb0ae9f95c3d90932c619a5c8" + integrity sha512-LcDAiYWRtwAoSOArfk7cuYvFXytxfVrdX7yxoUmK7pPITLk5jYh2F8knCwS7LjgYL8u1eidPlKKV6Ikqq0ODqQ== + dependencies: + tslib "^2.3.0" + "@wry/equality@^0.1.2": version "0.1.11" resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" @@ -2426,6 +2464,20 @@ dependencies: tslib "^1.9.3" +"@wry/equality@^0.5.0": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.5.3.tgz#fafebc69561aa2d40340da89fa7dc4b1f6fb7831" + integrity sha512-avR+UXdSrsF2v8vIqIgmeTY0UR91UT+IyablCyKe/uk22uOJ8fusKZnH9JH9e1/EtLeNJBtagNmL3eJdnOV53g== + dependencies: + tslib "^2.3.0" + +"@wry/trie@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@wry/trie/-/trie-0.3.2.tgz#a06f235dc184bd26396ba456711f69f8c35097e6" + integrity sha512-yRTyhWSls2OY/pYLfwff867r8ekooZ4UI+/gxot5Wj8EFwSf2rG+n+Mo/6LoLQm1TKA4GRj2+LCpbfS937dClQ== + dependencies: + tslib "^2.3.0" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -6704,6 +6756,13 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== +graphql-tag@^2.12.6: + version "2.12.6" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1" + integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg== + dependencies: + tslib "^2.1.0" + graphql-tag@^2.4.2: version "2.12.5" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.5.tgz#5cff974a67b417747d05c8d9f5f3cb4495d0db8f" @@ -6880,6 +6939,13 @@ hmac-drbg@^1.0.1: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" +hoist-non-react-statics@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + homedir-polyfill@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" @@ -9174,7 +9240,7 @@ lolex@^5.0.0: dependencies: "@sinonjs/commons" "^1.7.0" -loose-envify@^1.0.0: +loose-envify@^1.0.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -9498,6 +9564,11 @@ mkdirp@0.x, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1: dependencies: minimist "^1.2.5" +mock-apollo-client@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/mock-apollo-client/-/mock-apollo-client-1.2.1.tgz#e3bfdc3ff73b1fea28fa7e91ec82e43ba8cbfa39" + integrity sha512-QYQ6Hxo+t7hard1bcHHbsHxlNQYTQsaMNsm2Psh/NbwLMi2R4tGzplJKt97MUWuARHMq3GHB4PTLj/gxej4Caw== + moo-color@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" @@ -9987,6 +10058,14 @@ optimism@^0.10.0: dependencies: "@wry/context" "^0.4.0" +optimism@^0.16.1: + version "0.16.1" + resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.16.1.tgz#7c8efc1f3179f18307b887e18c15c5b7133f6e7d" + integrity sha512-64i+Uw3otrndfq5kaoGNoY7pvOhSsjFEN4bdEFh80MWVk/dbgJfMv7VFDeCT8LxNAlEVhQmdVEbfE7X2nWNIIg== + dependencies: + "@wry/context" "^0.6.0" + "@wry/trie" "^0.3.0" + optionator@^0.8.1: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -10901,6 +10980,15 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" +prop-types@^15.7.2: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -11080,7 +11168,7 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" -react-is@^16.8.4: +react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.4: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -11423,6 +11511,11 @@ resolve@1.x, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.14.2, is-core-module "^2.2.0" path-parse "^1.0.6" +response-iterator@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/response-iterator/-/response-iterator-0.2.6.tgz#249005fb14d2e4eeb478a3f735a28fd8b4c9f3da" + integrity sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw== + restore-cursor@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" @@ -12446,6 +12539,11 @@ symbol-observable@^1.0.2: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== +symbol-observable@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205" + integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ== + symbol-tree@^3.2.2, symbol-tree@^3.2.4: version "3.2.4" resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" @@ -12727,6 +12825,13 @@ tryer@^1.0.1: resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== +ts-invariant@^0.10.3: + version "0.10.3" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.10.3.tgz#3e048ff96e91459ffca01304dbc7f61c1f642f6c" + integrity sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ== + dependencies: + tslib "^2.1.0" + ts-invariant@^0.4.0: version "0.4.4" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" @@ -12785,6 +12890,11 @@ tslib@^2.1.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== +tslib@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" + integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -13844,7 +13954,14 @@ zen-observable-ts@^0.8.21: tslib "^1.9.3" zen-observable "^0.8.0" -zen-observable@^0.8.0: +zen-observable-ts@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz#6c6d9ea3d3a842812c6e9519209365a122ba8b58" + integrity sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg== + dependencies: + zen-observable "0.8.15" + +zen-observable@0.8.15, zen-observable@^0.8.0: version "0.8.15" resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== diff --git a/backend/.env.dist b/backend/.env.dist index 648a2054c..3b6fe2ce4 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -1,4 +1,4 @@ -CONFIG_VERSION=v10.2022-09-20 +CONFIG_VERSION=v11.2022-10-27 # Server PORT=4000 @@ -60,3 +60,8 @@ EVENT_PROTOCOL_DISABLED=false # SET LOG LEVEL AS NEEDED IN YOUR .ENV # POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal # LOG_LEVEL=info + +# DHT +# if you set this value, the DHT hyperswarm will start to announce and listen +# on an hash created from this tpoic +# DHT_TOPIC=GRADIDO_HUB diff --git a/backend/.env.template b/backend/.env.template index dddf845dc..d009d08ff 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -55,3 +55,6 @@ WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET # EventProtocol EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED + +# DHT +DHT_TOPIC=$DHT_TOPIC diff --git a/backend/jest.config.js b/backend/jest.config.js index 6ab44002c..a472df316 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -5,6 +5,7 @@ module.exports = { collectCoverage: true, collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'], setupFiles: ['/test/testSetup.ts'], + setupFilesAfterEnv: ['/test/extensions.ts'], modulePathIgnorePatterns: ['/build/'], moduleNameMapper: { '@/(.*)': '/src/$1', diff --git a/backend/package.json b/backend/package.json index 3e15a095a..80db9ff9d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "gradido-backend", - "version": "1.12.1", + "version": "1.13.3", "description": "Gradido unified backend providing an API-Service for Gradido Transactions", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/backend", @@ -18,6 +18,7 @@ "klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/klicktipp.ts" }, "dependencies": { + "@hyperswarm/dht": "^6.2.0", "@types/email-templates": "^10.0.1", "@types/i18n": "^0.13.4", "@types/jest": "^27.0.2", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 3e6bafd9f..e7139033b 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,14 +10,14 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0049-add_user_contacts_table', + DB_VERSION: '0052-add_updated_at_to_contributions', DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 LOG4JS_CONFIG: 'log4js-config.json', // default log level on production should be info LOG_LEVEL: process.env.LOG_LEVEL || 'info', CONFIG_VERSION: { DEFAULT: 'DEFAULT', - EXPECTED: 'v10.2022-09-20', + EXPECTED: 'v11.2022-10-27', CURRENT: '', }, } @@ -67,7 +67,7 @@ const loginServer = { const email = { EMAIL: process.env.EMAIL === 'true' || false, - EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' || 'false', + EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' || false, EMAIL_TEST_RECEIVER: process.env.EMAIL_TEST_RECEIVER || 'stage1@gradido.net', EMAIL_USERNAME: process.env.EMAIL_USERNAME || 'gradido_email', EMAIL_SENDER: process.env.EMAIL_SENDER || 'info@gradido.net', @@ -116,6 +116,10 @@ if ( ) } +const federation = { + DHT_TOPIC: process.env.DHT_TOPIC || null, +} + const CONFIG = { ...constants, ...server, @@ -126,6 +130,7 @@ const CONFIG = { ...loginServer, ...webhook, ...eventProtocol, + ...federation, } export default CONFIG diff --git a/backend/src/event/Event.ts b/backend/src/event/Event.ts index 85fba896d..09a31d4e0 100644 --- a/backend/src/event/Event.ts +++ b/backend/src/event/Event.ts @@ -11,50 +11,80 @@ export class EventBasicUserId extends EventBasic { } export class EventBasicTx extends EventBasicUserId { - xUserId: number - xCommunityId: number transactionId: number amount: decimal } +export class EventBasicTxX extends EventBasicTx { + xUserId: number + xCommunityId: number +} + export class EventBasicCt extends EventBasicUserId { contributionId: number amount: decimal } +export class EventBasicCtX extends EventBasicCt { + xUserId: number + xCommunityId: number +} + export class EventBasicRedeem extends EventBasicUserId { transactionId?: number contributionId?: number } +export class EventBasicCtMsg extends EventBasicCt { + messageId: number +} + export class EventVisitGradido extends EventBasic {} export class EventRegister extends EventBasicUserId {} export class EventRedeemRegister extends EventBasicRedeem {} +export class EventVerifyRedeem extends EventBasicRedeem {} export class EventInactiveAccount extends EventBasicUserId {} export class EventSendConfirmationEmail extends EventBasicUserId {} export class EventSendAccountMultiRegistrationEmail extends EventBasicUserId {} +export class EventSendForgotPasswordEmail extends EventBasicUserId {} +export class EventSendTransactionSendEmail extends EventBasicTxX {} +export class EventSendTransactionReceiveEmail extends EventBasicTxX {} +export class EventSendTransactionLinkRedeemEmail extends EventBasicTxX {} +export class EventSendAddedContributionEmail extends EventBasicCt {} +export class EventSendContributionConfirmEmail extends EventBasicCt {} export class EventConfirmationEmail extends EventBasicUserId {} export class EventRegisterEmailKlicktipp extends EventBasicUserId {} export class EventLogin extends EventBasicUserId {} +export class EventLogout extends EventBasicUserId {} export class EventRedeemLogin extends EventBasicRedeem {} export class EventActivateAccount extends EventBasicUserId {} export class EventPasswordChange extends EventBasicUserId {} -export class EventTransactionSend extends EventBasicTx {} -export class EventTransactionSendRedeem extends EventBasicTx {} -export class EventTransactionRepeateRedeem extends EventBasicTx {} -export class EventTransactionCreation extends EventBasicUserId { - transactionId: number - amount: decimal -} -export class EventTransactionReceive extends EventBasicTx {} -export class EventTransactionReceiveRedeem extends EventBasicTx {} +export class EventTransactionSend extends EventBasicTxX {} +export class EventTransactionSendRedeem extends EventBasicTxX {} +export class EventTransactionRepeateRedeem extends EventBasicTxX {} +export class EventTransactionCreation extends EventBasicTx {} +export class EventTransactionReceive extends EventBasicTxX {} +export class EventTransactionReceiveRedeem extends EventBasicTxX {} export class EventContributionCreate extends EventBasicCt {} -export class EventContributionConfirm extends EventBasicCt { - xUserId: number - xCommunityId: number -} +export class EventAdminContributionCreate extends EventBasicCt {} +export class EventAdminContributionDelete extends EventBasicCt {} +export class EventAdminContributionUpdate extends EventBasicCt {} +export class EventUserCreateContributionMessage extends EventBasicCtMsg {} +export class EventAdminCreateContributionMessage extends EventBasicCtMsg {} +export class EventContributionDelete extends EventBasicCt {} +export class EventContributionUpdate extends EventBasicCt {} +export class EventContributionConfirm extends EventBasicCtX {} +export class EventContributionDeny extends EventBasicCtX {} export class EventContributionLinkDefine extends EventBasicCt {} export class EventContributionLinkActivateRedeem extends EventBasicCt {} +export class EventDeleteUser extends EventBasicUserId {} +export class EventUndeleteUser extends EventBasicUserId {} +export class EventChangeUserRole extends EventBasicUserId {} +export class EventAdminUpdateContribution extends EventBasicCt {} +export class EventAdminDeleteContribution extends EventBasicCt {} +export class EventCreateContributionLink extends EventBasicCt {} +export class EventDeleteContributionLink extends EventBasicCt {} +export class EventUpdateContributionLink extends EventBasicCt {} export class Event { constructor() @@ -100,6 +130,13 @@ export class Event { return this } + public setEventVerifyRedeem(ev: EventVerifyRedeem): Event { + this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId) + this.type = EventProtocolType.VERIFY_REDEEM + + return this + } + public setEventInactiveAccount(ev: EventInactiveAccount): Event { this.setByBasicUser(ev.userId) this.type = EventProtocolType.INACTIVE_ACCOUNT @@ -118,7 +155,49 @@ export class Event { ev: EventSendAccountMultiRegistrationEmail, ): Event { this.setByBasicUser(ev.userId) - this.type = EventProtocolType.SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL + this.type = EventProtocolType.SEND_ACCOUNT_MULTIREGISTRATION_EMAIL + + return this + } + + public setEventSendForgotPasswordEmail(ev: EventSendForgotPasswordEmail): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.SEND_FORGOT_PASSWORD_EMAIL + + return this + } + + public setEventSendTransactionSendEmail(ev: EventSendTransactionSendEmail): Event { + this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId) + this.type = EventProtocolType.SEND_TRANSACTION_SEND_EMAIL + + return this + } + + public setEventSendTransactionReceiveEmail(ev: EventSendTransactionReceiveEmail): Event { + this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId) + this.type = EventProtocolType.SEND_TRANSACTION_RECEIVE_EMAIL + + return this + } + + public setEventSendTransactionLinkRedeemEmail(ev: EventSendTransactionLinkRedeemEmail): Event { + this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId) + this.type = EventProtocolType.SEND_TRANSACTION_LINK_REDEEM_EMAIL + + return this + } + + public setEventSendAddedContributionEmail(ev: EventSendAddedContributionEmail): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.SEND_ADDED_CONTRIBUTION_EMAIL + + return this + } + + public setEventSendContributionConfirmEmail(ev: EventSendContributionConfirmEmail): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.SEND_CONTRIBUTION_CONFIRM_EMAIL return this } @@ -144,6 +223,13 @@ export class Event { return this } + public setEventLogout(ev: EventLogout): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.LOGOUT + + return this + } + public setEventRedeemLogin(ev: EventRedeemLogin): Event { this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId) this.type = EventProtocolType.REDEEM_LOGIN @@ -166,44 +252,42 @@ export class Event { } public setEventTransactionSend(ev: EventTransactionSend): Event { - this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) + this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId) this.type = EventProtocolType.TRANSACTION_SEND return this } public setEventTransactionSendRedeem(ev: EventTransactionSendRedeem): Event { - this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) + this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId) this.type = EventProtocolType.TRANSACTION_SEND_REDEEM return this } public setEventTransactionRepeateRedeem(ev: EventTransactionRepeateRedeem): Event { - this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) + this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId) this.type = EventProtocolType.TRANSACTION_REPEATE_REDEEM return this } public setEventTransactionCreation(ev: EventTransactionCreation): Event { - this.setByBasicUser(ev.userId) - if (ev.transactionId) this.transactionId = ev.transactionId - if (ev.amount) this.amount = ev.amount + this.setByBasicTx(ev.userId, ev.transactionId, ev.amount) this.type = EventProtocolType.TRANSACTION_CREATION return this } public setEventTransactionReceive(ev: EventTransactionReceive): Event { - this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) + this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId) this.type = EventProtocolType.TRANSACTION_RECEIVE return this } public setEventTransactionReceiveRedeem(ev: EventTransactionReceiveRedeem): Event { - this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount) + this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId) this.type = EventProtocolType.TRANSACTION_RECEIVE_REDEEM return this @@ -216,15 +300,69 @@ export class Event { return this } - public setEventContributionConfirm(ev: EventContributionConfirm): Event { + public setEventAdminContributionCreate(ev: EventAdminContributionCreate): Event { this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) - if (ev.xUserId) this.xUserId = ev.xUserId - if (ev.xCommunityId) this.xCommunityId = ev.xCommunityId + this.type = EventProtocolType.ADMIN_CONTRIBUTION_CREATE + + return this + } + + public setEventAdminContributionDelete(ev: EventAdminContributionDelete): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_CONTRIBUTION_DELETE + + return this + } + + public setEventAdminContributionUpdate(ev: EventAdminContributionUpdate): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_CONTRIBUTION_UPDATE + + return this + } + + public setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event { + this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId) + this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE + + return this + } + + public setEventAdminCreateContributionMessage(ev: EventAdminCreateContributionMessage): Event { + this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId) + this.type = EventProtocolType.ADMIN_CREATE_CONTRIBUTION_MESSAGE + + return this + } + + public setEventContributionDelete(ev: EventContributionDelete): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.CONTRIBUTION_DELETE + + return this + } + + public setEventContributionUpdate(ev: EventContributionUpdate): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.CONTRIBUTION_UPDATE + + return this + } + + public setEventContributionConfirm(ev: EventContributionConfirm): Event { + this.setByBasicCtX(ev.userId, ev.contributionId, ev.amount, ev.xUserId, ev.xCommunityId) this.type = EventProtocolType.CONTRIBUTION_CONFIRM return this } + public setEventContributionDeny(ev: EventContributionDeny): Event { + this.setByBasicCtX(ev.userId, ev.contributionId, ev.amount, ev.xUserId, ev.xCommunityId) + this.type = EventProtocolType.CONTRIBUTION_DENY + + return this + } + public setEventContributionLinkDefine(ev: EventContributionLinkDefine): Event { this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) this.type = EventProtocolType.CONTRIBUTION_LINK_DEFINE @@ -239,6 +377,62 @@ export class Event { return this } + public setEventDeleteUser(ev: EventDeleteUser): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.DELETE_USER + + return this + } + + public setEventUndeleteUser(ev: EventUndeleteUser): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.UNDELETE_USER + + return this + } + + public setEventChangeUserRole(ev: EventChangeUserRole): Event { + this.setByBasicUser(ev.userId) + this.type = EventProtocolType.CHANGE_USER_ROLE + + return this + } + + public setEventAdminUpdateContribution(ev: EventAdminUpdateContribution): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_UPDATE_CONTRIBUTION + + return this + } + + public setEventAdminDeleteContribution(ev: EventAdminDeleteContribution): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.ADMIN_DELETE_CONTRIBUTION + + return this + } + + public setEventCreateContributionLink(ev: EventCreateContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.CREATE_CONTRIBUTION_LINK + + return this + } + + public setEventDeleteContributionLink(ev: EventDeleteContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.DELETE_CONTRIBUTION_LINK + + return this + } + + public setEventUpdateContributionLink(ev: EventUpdateContributionLink): Event { + this.setByBasicCt(ev.userId, ev.contributionId, ev.amount) + this.type = EventProtocolType.UPDATE_CONTRIBUTION_LINK + + return this + } + setByBasicUser(userId: number): Event { this.setEventBasic() this.userId = userId @@ -246,26 +440,58 @@ export class Event { return this } - setByBasicTx( - userId: number, - xUserId?: number, - xCommunityId?: number, - transactionId?: number, - amount?: decimal, - ): Event { + setByBasicTx(userId: number, transactionId: number, amount: decimal): Event { this.setByBasicUser(userId) - if (xUserId) this.xUserId = xUserId - if (xCommunityId) this.xCommunityId = xCommunityId - if (transactionId) this.transactionId = transactionId - if (amount) this.amount = amount + this.transactionId = transactionId + this.amount = amount return this } - setByBasicCt(userId: number, contributionId: number, amount?: decimal): Event { + setByBasicTxX( + userId: number, + transactionId: number, + amount: decimal, + xUserId: number, + xCommunityId: number, + ): Event { + this.setByBasicTx(userId, transactionId, amount) + this.xUserId = xUserId + this.xCommunityId = xCommunityId + + return this + } + + setByBasicCt(userId: number, contributionId: number, amount: decimal): Event { this.setByBasicUser(userId) - if (contributionId) this.contributionId = contributionId - if (amount) this.amount = amount + this.contributionId = contributionId + this.amount = amount + + return this + } + + setByBasicCtMsg( + userId: number, + contributionId: number, + amount: decimal, + messageId: number, + ): Event { + this.setByBasicCt(userId, contributionId, amount) + this.messageId = messageId + + return this + } + + setByBasicCtX( + userId: number, + contributionId: number, + amount: decimal, + xUserId: number, + xCommunityId: number, + ): Event { + this.setByBasicCt(userId, contributionId, amount) + this.xUserId = xUserId + this.xCommunityId = xCommunityId return this } @@ -278,27 +504,6 @@ export class Event { return this } - setByEventTransactionCreation(event: EventTransactionCreation): Event { - this.type = event.type - this.createdAt = event.createdAt - this.userId = event.userId - this.transactionId = event.transactionId - this.amount = event.amount - - return this - } - - setByEventContributionConfirm(event: EventContributionConfirm): Event { - this.type = event.type - this.createdAt = event.createdAt - this.userId = event.userId - this.xUserId = event.xUserId - this.xCommunityId = event.xCommunityId - this.amount = event.amount - - return this - } - id: number type: string createdAt: Date @@ -308,4 +513,5 @@ export class Event { transactionId?: number contributionId?: number amount?: decimal + messageId?: number } diff --git a/backend/src/event/EventProtocolType.ts b/backend/src/event/EventProtocolType.ts index 52bcf8349..b7c2f0151 100644 --- a/backend/src/event/EventProtocolType.ts +++ b/backend/src/event/EventProtocolType.ts @@ -3,23 +3,47 @@ export enum EventProtocolType { VISIT_GRADIDO = 'VISIT_GRADIDO', REGISTER = 'REGISTER', REDEEM_REGISTER = 'REDEEM_REGISTER', + VERIFY_REDEEM = 'VERIFY_REDEEM', INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT', SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL', - SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL', + SEND_ACCOUNT_MULTIREGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTIREGISTRATION_EMAIL', CONFIRM_EMAIL = 'CONFIRM_EMAIL', REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP', LOGIN = 'LOGIN', + LOGOUT = 'LOGOUT', REDEEM_LOGIN = 'REDEEM_LOGIN', ACTIVATE_ACCOUNT = 'ACTIVATE_ACCOUNT', + SEND_FORGOT_PASSWORD_EMAIL = 'SEND_FORGOT_PASSWORD_EMAIL', PASSWORD_CHANGE = 'PASSWORD_CHANGE', + SEND_TRANSACTION_SEND_EMAIL = 'SEND_TRANSACTION_SEND_EMAIL', + SEND_TRANSACTION_RECEIVE_EMAIL = 'SEND_TRANSACTION_RECEIVE_EMAIL', TRANSACTION_SEND = 'TRANSACTION_SEND', TRANSACTION_SEND_REDEEM = 'TRANSACTION_SEND_REDEEM', TRANSACTION_REPEATE_REDEEM = 'TRANSACTION_REPEATE_REDEEM', TRANSACTION_CREATION = 'TRANSACTION_CREATION', TRANSACTION_RECEIVE = 'TRANSACTION_RECEIVE', TRANSACTION_RECEIVE_REDEEM = 'TRANSACTION_RECEIVE_REDEEM', + SEND_TRANSACTION_LINK_REDEEM_EMAIL = 'SEND_TRANSACTION_LINK_REDEEM_EMAIL', + SEND_ADDED_CONTRIBUTION_EMAIL = 'SEND_ADDED_CONTRIBUTION_EMAIL', + SEND_CONTRIBUTION_CONFIRM_EMAIL = 'SEND_CONTRIBUTION_CONFIRM_EMAIL', CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE', CONTRIBUTION_CONFIRM = 'CONTRIBUTION_CONFIRM', + CONTRIBUTION_DENY = 'CONTRIBUTION_DENY', CONTRIBUTION_LINK_DEFINE = 'CONTRIBUTION_LINK_DEFINE', CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM', + CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE', + CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE', + ADMIN_CONTRIBUTION_CREATE = 'ADMIN_CONTRIBUTION_CREATE', + ADMIN_CONTRIBUTION_DELETE = 'ADMIN_CONTRIBUTION_DELETE', + ADMIN_CONTRIBUTION_UPDATE = 'ADMIN_CONTRIBUTION_UPDATE', + USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE', + ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE', + DELETE_USER = 'DELETE_USER', + UNDELETE_USER = 'UNDELETE_USER', + CHANGE_USER_ROLE = 'CHANGE_USER_ROLE', + ADMIN_UPDATE_CONTRIBUTION = 'ADMIN_UPDATE_CONTRIBUTION', + ADMIN_DELETE_CONTRIBUTION = 'ADMIN_DELETE_CONTRIBUTION', + CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK', + DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK', + UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK', } diff --git a/backend/src/federation/@types/@hyperswarm__dht/index.d.ts b/backend/src/federation/@types/@hyperswarm__dht/index.d.ts new file mode 100644 index 000000000..efb9ad438 --- /dev/null +++ b/backend/src/federation/@types/@hyperswarm__dht/index.d.ts @@ -0,0 +1 @@ +declare module '@hyperswarm/dht' diff --git a/backend/src/federation/index.ts b/backend/src/federation/index.ts new file mode 100644 index 000000000..2ca58b432 --- /dev/null +++ b/backend/src/federation/index.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import DHT from '@hyperswarm/dht' +// import { Connection } from '@dbTools/typeorm' +import { backendLogger as logger } from '@/server/logger' + +function between(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min) +} + +const POLLTIME = 20000 +const SUCCESSTIME = 120000 +const ERRORTIME = 240000 +const ANNOUNCETIME = 30000 +const nodeRand = between(1, 99) +const nodeURL = `https://test${nodeRand}.org` +const nodeAPI = { + API_1_00: `${nodeURL}/api/1_00/`, + API_1_01: `${nodeURL}/api/1_01/`, + API_2_00: `${nodeURL}/graphql/2_00/`, +} + +export const startDHT = async ( + // connection: Connection, + topic: string, +): Promise => { + try { + const TOPIC = DHT.hash(Buffer.from(topic)) + + const keyPair = DHT.keyPair() + + const node = new DHT({ keyPair }) + + const server = node.createServer() + + server.on('connection', function (socket: any) { + // noiseSocket is E2E between you and the other peer + // pipe it somewhere like any duplex stream + logger.info(`Remote public key: ${socket.remotePublicKey.toString('hex')}`) + // console.log("Local public key", noiseSocket.publicKey.toString("hex")); // same as keyPair.publicKey + + socket.on('data', (data: Buffer) => logger.info(`data: ${data.toString('ascii')}`)) + + // process.stdin.pipe(noiseSocket).pipe(process.stdout); + }) + + await server.listen() + + setInterval(async () => { + logger.info(`Announcing on topic: ${TOPIC.toString('hex')}`) + await node.announce(TOPIC, keyPair).finished() + }, ANNOUNCETIME) + + let successfulRequests: string[] = [] + let errorfulRequests: string[] = [] + + setInterval(async () => { + logger.info('Refreshing successful nodes') + successfulRequests = [] + }, SUCCESSTIME) + + setInterval(async () => { + logger.info('Refreshing errorful nodes') + errorfulRequests = [] + }, ERRORTIME) + + setInterval(async () => { + const result = await node.lookup(TOPIC) + + const collectedPubKeys: string[] = [] + + for await (const data of result) { + data.peers.forEach((peer: any) => { + const pubKey = peer.publicKey.toString('hex') + if ( + pubKey !== keyPair.publicKey.toString('hex') && + !successfulRequests.includes(pubKey) && + !errorfulRequests.includes(pubKey) && + !collectedPubKeys.includes(pubKey) + ) { + collectedPubKeys.push(peer.publicKey.toString('hex')) + } + }) + } + + logger.info(`Found new peers: ${collectedPubKeys}`) + + collectedPubKeys.forEach((remotePubKey) => { + // publicKey here is keyPair.publicKey from above + const socket = node.connect(Buffer.from(remotePubKey, 'hex')) + + // socket.once("connect", function () { + // console.log("client side emitted connect"); + // }); + + // socket.once("end", function () { + // console.log("client side ended"); + // }); + + socket.once('error', (err: any) => { + errorfulRequests.push(remotePubKey) + logger.error(`error on peer ${remotePubKey}: ${err.message}`) + }) + + socket.on('open', function () { + // noiseSocket fully open with the other peer + // console.log("writing to socket"); + socket.write(Buffer.from(`${nodeRand}`)) + socket.write(Buffer.from(JSON.stringify(nodeAPI))) + successfulRequests.push(remotePubKey) + }) + // pipe it somewhere like any duplex stream + // process.stdin.pipe(noiseSocket).pipe(process.stdout) + }) + }, POLLTIME) + } catch (err) { + logger.error(err) + } +} diff --git a/backend/src/graphql/enum/ContributionCycleType.ts b/backend/src/graphql/enum/ContributionCycleType.ts index 5fe494a02..a3c55aa68 100644 --- a/backend/src/graphql/enum/ContributionCycleType.ts +++ b/backend/src/graphql/enum/ContributionCycleType.ts @@ -1,13 +1,14 @@ import { registerEnumType } from 'type-graphql' +// lowercase values are not implemented yet export enum ContributionCycleType { - ONCE = 'once', + ONCE = 'ONCE', HOUR = 'hour', TWO_HOURS = 'two_hours', FOUR_HOURS = 'four_hours', EIGHT_HOURS = 'eight_hours', HALF_DAY = 'half_day', - DAY = 'day', + DAILY = 'DAILY', TWO_DAYS = 'two_days', THREE_DAYS = 'three_days', FOUR_DAYS = 'four_days', diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts index b1b4e469e..f26fce3d8 100644 --- a/backend/src/graphql/resolver/AdminResolver.test.ts +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -13,9 +13,11 @@ import { peterLustig } from '@/seeds/users/peter-lustig' import { stephenHawking } from '@/seeds/users/stephen-hawking' import { garrickOllivander } from '@/seeds/users/garrick-ollivander' import { + login, setUserRole, deleteUser, unDeleteUser, + createContribution, adminCreateContribution, adminCreateContributions, adminUpdateContribution, @@ -27,7 +29,6 @@ import { } from '@/seeds/graphql/mutations' import { listUnconfirmedContributions, - login, searchUsers, listTransactionLinksAdmin, listContributionLinks, @@ -41,6 +42,9 @@ import { Contribution } from '@entity/Contribution' import { Transaction as DbTransaction } from '@entity/Transaction' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { EventProtocol } from '@entity/EventProtocol' +import { EventProtocolType } from '@/event/EventProtocolType' +import { logger } from '@test/testSetup' // mock account activation email to avoid console spam jest.mock('@/mailer/sendAccountActivationEmail', () => { @@ -77,6 +81,7 @@ afterAll(async () => { let admin: User let user: User let creation: Contribution | void +let result: any describe('AdminResolver', () => { describe('set user role', () => { @@ -96,8 +101,8 @@ describe('AdminResolver', () => { describe('without admin rights', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -121,8 +126,8 @@ describe('AdminResolver', () => { describe('with admin rights', () => { beforeAll(async () => { admin = await userFactory(testEnv, peterLustig) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) @@ -134,6 +139,7 @@ describe('AdminResolver', () => { describe('user to get a new role does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }), ).resolves.toEqual( @@ -142,6 +148,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('change role with success', () => { @@ -186,6 +196,7 @@ describe('AdminResolver', () => { describe('change role with error', () => { describe('is own role', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }), ).resolves.toEqual( @@ -194,11 +205,15 @@ describe('AdminResolver', () => { }), ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Administrator can not change his own role!') + }) }) describe('user has already role to be set', () => { describe('to admin', () => { it('throws an error', async () => { + jest.clearAllMocks() await mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true }, @@ -211,10 +226,15 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already admin!') + }) }) describe('to usual user', () => { it('throws an error', async () => { + jest.clearAllMocks() await mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false }, @@ -227,6 +247,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is already a usual user!') + }) }) }) }) @@ -249,8 +273,8 @@ describe('AdminResolver', () => { describe('without admin rights', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -274,8 +298,8 @@ describe('AdminResolver', () => { describe('with admin rights', () => { beforeAll(async () => { admin = await userFactory(testEnv, peterLustig) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) @@ -287,6 +311,7 @@ describe('AdminResolver', () => { describe('user to be deleted does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }), ).resolves.toEqual( @@ -295,10 +320,15 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('delete self', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: deleteUser, variables: { userId: admin.id } }), ).resolves.toEqual( @@ -307,6 +337,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not delete his own account!') + }) }) describe('delete with success', () => { @@ -328,6 +362,7 @@ describe('AdminResolver', () => { describe('delete deleted user', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: deleteUser, variables: { userId: user.id } }), ).resolves.toEqual( @@ -336,6 +371,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`) + }) }) }) }) @@ -357,8 +396,8 @@ describe('AdminResolver', () => { describe('without admin rights', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -382,8 +421,8 @@ describe('AdminResolver', () => { describe('with admin rights', () => { beforeAll(async () => { admin = await userFactory(testEnv, peterLustig) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) @@ -395,6 +434,7 @@ describe('AdminResolver', () => { describe('user to be undelete does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }), ).resolves.toEqual( @@ -403,6 +443,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`) + }) }) describe('user to undelete is not deleted', () => { @@ -411,6 +455,7 @@ describe('AdminResolver', () => { }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), ).resolves.toEqual( @@ -420,6 +465,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User is not deleted') + }) + describe('undelete deleted user', () => { beforeAll(async () => { await mutate({ mutation: deleteUser, variables: { userId: user.id } }) @@ -469,8 +518,8 @@ describe('AdminResolver', () => { describe('without admin rights', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -514,8 +563,8 @@ describe('AdminResolver', () => { beforeAll(async () => { admin = await userFactory(testEnv, peterLustig) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) @@ -766,8 +815,8 @@ describe('AdminResolver', () => { describe('without admin rights', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -875,8 +924,8 @@ describe('AdminResolver', () => { describe('with admin rights', () => { beforeAll(async () => { admin = await userFactory(testEnv, peterLustig) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) @@ -899,6 +948,7 @@ describe('AdminResolver', () => { describe('user to create for does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -907,6 +957,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find user with email: bibi@bloxberg.de', + ) + }) }) describe('user to create for is deleted', () => { @@ -916,6 +972,7 @@ describe('AdminResolver', () => { }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -926,6 +983,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'This user was deleted. Cannot create a contribution.', + ) + }) }) describe('user to create for has email not confirmed', () => { @@ -935,6 +998,7 @@ describe('AdminResolver', () => { }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -945,6 +1009,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Contribution could not be saved, Email is not activated', + ) + }) }) describe('valid user to create for', () => { @@ -955,6 +1025,7 @@ describe('AdminResolver', () => { describe('date of creation is not a date string', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminCreateContribution, variables }), ).resolves.toEqual( @@ -965,10 +1036,18 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + 'Invalid Date', + ) + }) }) describe('date of creation is four months ago', () => { it('throws an error', async () => { + jest.clearAllMocks() const now = new Date() variables.creationDate = new Date( now.getFullYear(), @@ -985,10 +1064,18 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + variables.creationDate, + ) + }) }) describe('date of creation is in the future', () => { it('throws an error', async () => { + jest.clearAllMocks() const now = new Date() variables.creationDate = new Date( now.getFullYear(), @@ -1005,10 +1092,18 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + variables.creationDate, + ) + }) }) describe('amount of creation is too high', () => { it('throws an error', async () => { + jest.clearAllMocks() variables.creationDate = new Date().toString() await expect( mutate({ mutation: adminCreateContribution, variables }), @@ -1022,6 +1117,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) }) describe('creation is valid', () => { @@ -1037,6 +1138,15 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the admin create contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE, + userId: admin.id, + }), + ) + }) }) describe('second creation surpasses the available amount ', () => { @@ -1054,6 +1164,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', + ) + }) }) }) }) @@ -1113,6 +1229,7 @@ describe('AdminResolver', () => { describe('user for creation to update does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1132,10 +1249,17 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Could not find UserContact with email: bob@baumeister.de', + ) + }) }) describe('user for creation to update is deleted', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1153,10 +1277,15 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)') + }) }) describe('creation does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1174,10 +1303,15 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('No contribution found to given id.') + }) }) describe('user email does not match creation user', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1186,7 +1320,9 @@ describe('AdminResolver', () => { email: 'bibi@bloxberg.de', amount: new Decimal(300), memo: 'Danke Bibi!', - creationDate: new Date().toString(), + creationDate: creation + ? creation.contributionDate.toString() + : new Date().toString(), }, }), ).resolves.toEqual( @@ -1199,10 +1335,18 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'user of the pending contribution and send user does not correspond', + ) + }) }) describe('creation update is not valid', () => { + // as this test has not clearly defined that date, it is a false positive it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -1211,22 +1355,31 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(1900), memo: 'Danke Peter!', - creationDate: new Date().toString(), + creationDate: creation + ? creation.contributionDate.toString() + : new Date().toString(), }, }), ).resolves.toEqual( expect.objectContaining({ errors: [ new GraphQLError( - 'The amount (1900 GDD) to be created exceeds the amount (500 GDD) still available for this month.', + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', ), ], }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) }) - describe('creation update is successful changing month', () => { + describe.skip('creation update is successful changing month', () => { + // skipped as changing the month is currently disable it('returns update creation object', async () => { await expect( mutate({ @@ -1236,7 +1389,9 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(300), memo: 'Danke Peter!', - creationDate: new Date().toString(), + creationDate: creation + ? creation.contributionDate.toString() + : new Date().toString(), }, }), ).resolves.toEqual( @@ -1246,15 +1401,25 @@ describe('AdminResolver', () => { date: expect.any(String), memo: 'Danke Peter!', amount: '300', - creation: ['1000', '1000', '200'], + creation: ['1000', '700', '500'], }, }, }), ) }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) }) describe('creation update is successful without changing month', () => { + // actually this mutation IS changing the month it('returns update creation object', async () => { await expect( mutate({ @@ -1264,7 +1429,9 @@ describe('AdminResolver', () => { email: 'peter@lustig.de', amount: new Decimal(200), memo: 'Das war leider zu Viel!', - creationDate: new Date().toString(), + creationDate: creation + ? creation.contributionDate.toString() + : new Date().toString(), }, }), ).resolves.toEqual( @@ -1274,12 +1441,21 @@ describe('AdminResolver', () => { date: expect.any(String), memo: 'Das war leider zu Viel!', amount: '200', - creation: ['1000', '1000', '300'], + creation: ['1000', '800', '500'], }, }, }), ) }) + + it('stores the admin update contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE, + userId: admin.id, + }), + ) + }) }) }) @@ -1302,7 +1478,7 @@ describe('AdminResolver', () => { memo: 'Das war leider zu Viel!', amount: '200', moderator: admin.id, - creation: ['1000', '1000', '300'], + creation: ['1000', '800', '500'], }, { id: expect.any(Number), @@ -1313,7 +1489,7 @@ describe('AdminResolver', () => { memo: 'Grundeinkommen', amount: '500', moderator: admin.id, - creation: ['1000', '1000', '300'], + creation: ['1000', '800', '500'], }, { id: expect.any(Number), @@ -1347,6 +1523,7 @@ describe('AdminResolver', () => { describe('adminDeleteContribution', () => { describe('creation id does not exist', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminDeleteContribution, @@ -1360,6 +1537,43 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) + }) + + describe('admin deletes own user contribution', () => { + beforeAll(async () => { + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + result = await mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Test env contribution', + creationDate: new Date().toString(), + }, + }) + }) + + it('throws an error', async () => { + jest.clearAllMocks() + await expect( + mutate({ + mutation: adminDeleteContribution, + variables: { + id: result.data.createContribution.id, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Own contribution can not be deleted as admin')], + }), + ) + }) }) describe('creation id does exist', () => { @@ -1377,12 +1591,22 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the admin delete contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.ADMIN_CONTRIBUTION_DELETE, + userId: admin.id, + }), + ) + }) }) }) describe('confirmContribution', () => { describe('creation does not exits', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: confirmContribution, @@ -1396,6 +1620,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id: -1') + }) }) describe('confirm own creation', () => { @@ -1423,6 +1651,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution') + }) }) describe('confirm creation for other user', () => { @@ -1451,6 +1683,14 @@ describe('AdminResolver', () => { ) }) + it('stores the contribution confirm event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.CONTRIBUTION_CONFIRM, + }), + ) + }) + it('creates a transaction', async () => { const transaction = await DbTransaction.find() expect(transaction[0].amount.toString()).toBe('450') @@ -1475,6 +1715,14 @@ describe('AdminResolver', () => { }), ) }) + + it('stores the send confirmation email event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.SEND_CONFIRMATION_EMAIL, + }), + ) + }) }) describe('confirm two creations one after the other quickly', () => { @@ -1556,8 +1804,8 @@ describe('AdminResolver', () => { describe('without admin rights', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -1602,8 +1850,8 @@ describe('AdminResolver', () => { } // admin: only now log in - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) @@ -1792,13 +2040,14 @@ describe('AdminResolver', () => { }) describe('Contribution Links', () => { + const now = new Date() const variables = { amount: new Decimal(200), name: 'Dokumenta 2022', memo: 'Danke für deine Teilnahme an der Dokumenta 2022', cycle: 'once', validFrom: new Date(2022, 5, 18).toISOString(), - validTo: new Date(2022, 7, 14).toISOString(), + validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(), maxAmountPerMonth: new Decimal(200), maxPerCycle: 1, } @@ -1862,8 +2111,8 @@ describe('AdminResolver', () => { describe('without admin rights', () => { beforeAll(async () => { user = await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -1936,8 +2185,8 @@ describe('AdminResolver', () => { describe('with admin rights', () => { beforeAll(async () => { user = await userFactory(testEnv, peterLustig) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) @@ -1980,7 +2229,7 @@ describe('AdminResolver', () => { name: 'Dokumenta 2022', memo: 'Danke für deine Teilnahme an der Dokumenta 2022', validFrom: new Date('2022-06-18T00:00:00.000Z'), - validTo: new Date('2022-08-14T00:00:00.000Z'), + validTo: expect.any(Date), cycle: 'once', maxPerCycle: 1, totalMaxCountOfContribution: null, @@ -1990,8 +2239,8 @@ describe('AdminResolver', () => { deletedAt: null, code: expect.stringMatching(/^[0-9a-f]{24,24}$/), linkEnabled: true, - // amount: '200', - // maxAmountPerMonth: '200', + amount: expect.decimalEqual(200), + maxAmountPerMonth: expect.decimalEqual(200), }), ) }) @@ -2014,6 +2263,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'Start-Date is not initialized. A Start-Date must be set!', + ) + }) + it('returns an error if missing endDate', async () => { await expect( mutate({ @@ -2030,6 +2285,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'End-Date is not initialized. An End-Date must be set!', + ) + }) + it('returns an error if endDate is before startDate', async () => { await expect( mutate({ @@ -2049,6 +2310,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of validFrom must before or equals the validTo!`, + ) + }) + it('returns an error if name is an empty string', async () => { await expect( mutate({ @@ -2065,6 +2332,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The name must be initialized!') + }) + it('returns an error if name is shorter than 5 characters', async () => { await expect( mutate({ @@ -2085,6 +2356,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + it('returns an error if name is longer than 100 characters', async () => { await expect( mutate({ @@ -2105,6 +2382,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`, + ) + }) + it('returns an error if memo is an empty string', async () => { await expect( mutate({ @@ -2121,6 +2404,10 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('The memo must be initialized!') + }) + it('returns an error if memo is shorter than 5 characters', async () => { await expect( mutate({ @@ -2141,6 +2428,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + it('returns an error if memo is longer than 255 characters', async () => { await expect( mutate({ @@ -2161,6 +2454,12 @@ describe('AdminResolver', () => { ) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`, + ) + }) + it('returns an error if amount is not positive', async () => { await expect( mutate({ @@ -2178,6 +2477,12 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + 'The amount=0 must be initialized with a positiv value!', + ) + }) }) describe('listContributionLinks', () => { @@ -2233,6 +2538,10 @@ describe('AdminResolver', () => { }) }) + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) + describe('valid id', () => { let linkId: number beforeAll(async () => { @@ -2280,7 +2589,7 @@ describe('AdminResolver', () => { id: linkId, name: 'Dokumenta 2023', memo: 'Danke für deine Teilnahme an der Dokumenta 2023', - // amount: '400', + amount: expect.decimalEqual(400), }), ) }) @@ -2298,6 +2607,10 @@ describe('AdminResolver', () => { }), ) }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1') + }) }) describe('valid id', () => { diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 3435edb94..479d020ea 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -63,7 +63,17 @@ import ContributionMessageArgs from '@arg/ContributionMessageArgs' import { ContributionMessageType } from '@enum/MessageType' import { ContributionMessage } from '@model/ContributionMessage' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' +import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail' import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' +import { eventProtocol } from '@/event/EventProtocolEmitter' +import { + Event, + EventAdminContributionCreate, + EventAdminContributionDelete, + EventAdminContributionUpdate, + EventContributionConfirm, + EventSendConfirmationEmail, +} from '@/event/Event' import { ContributionListResult } from '../model/Contribution' // const EMAIL_OPT_IN_REGISTER = 1 @@ -145,11 +155,13 @@ export class AdminResolver { const user = await dbUser.findOne({ id: userId }) // user exists ? if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } // administrator user changes own role? const moderatorUser = getUser(context) if (moderatorUser.id === userId) { + logger.error('Administrator can not change his own role!') throw new Error('Administrator can not change his own role!') } // change isAdmin @@ -158,6 +170,7 @@ export class AdminResolver { if (isAdmin === true) { user.isAdmin = new Date() } else { + logger.error('User is already a usual user!') throw new Error('User is already a usual user!') } break @@ -165,6 +178,7 @@ export class AdminResolver { if (isAdmin === false) { user.isAdmin = null } else { + logger.error('User is already admin!') throw new Error('User is already admin!') } break @@ -183,11 +197,13 @@ export class AdminResolver { const user = await dbUser.findOne({ id: userId }) // user exists ? if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } // moderator user disabled own account? const moderatorUser = getUser(context) if (moderatorUser.id === userId) { + logger.error('Moderator can not delete his own account!') throw new Error('Moderator can not delete his own account!') } // soft-delete user @@ -201,9 +217,11 @@ export class AdminResolver { async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { const user = await dbUser.findOne({ id: userId }, { withDeleted: true }) if (!user) { + logger.error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`) } if (!user.deletedAt) { + logger.error('User is not deleted') throw new Error('User is not deleted') } await user.recover() @@ -240,6 +258,8 @@ export class AdminResolver { logger.error('Contribution could not be saved, Email is not activated') throw new Error('Contribution could not be saved, Email is not activated') } + + const event = new Event() const moderator = getUser(context) logger.trace('moderator: ', moderator.id) const creations = await getUserCreation(emailContact.userId) @@ -258,7 +278,17 @@ export class AdminResolver { contribution.contributionStatus = ContributionStatus.PENDING logger.trace('contribution to save', contribution) + await DbContribution.save(contribution) + + const eventAdminCreateContribution = new EventAdminContributionCreate() + eventAdminCreateContribution.userId = moderator.id + eventAdminCreateContribution.amount = amount + eventAdminCreateContribution.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionCreate(eventAdminCreateContribution), + ) + return getUserCreation(emailContact.userId) } @@ -319,7 +349,6 @@ export class AdminResolver { const contributionToUpdate = await DbContribution.findOne({ where: { id, confirmedAt: IsNull() }, }) - if (!contributionToUpdate) { logger.error('No contribution found to given id.') throw new Error('No contribution found to given id.') @@ -337,8 +366,12 @@ export class AdminResolver { const creationDateObj = new Date(creationDate) let creations = await getUserCreation(user.id) + if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { creations = updateCreations(creations, contributionToUpdate) + } else { + logger.error('Currently the month of the contribution cannot change.') + throw new Error('Currently the month of the contribution cannot change.') } // all possible cases not to be true are thrown in this function @@ -350,6 +383,7 @@ export class AdminResolver { contributionToUpdate.contributionStatus = ContributionStatus.PENDING await DbContribution.save(contributionToUpdate) + const result = new AdminUpdateContribution() result.amount = amount result.memo = contributionToUpdate.memo @@ -357,6 +391,15 @@ export class AdminResolver { result.creation = await getUserCreation(user.id) + const event = new Event() + const eventAdminContributionUpdate = new EventAdminContributionUpdate() + eventAdminContributionUpdate.userId = user.id + eventAdminContributionUpdate.amount = amount + eventAdminContributionUpdate.contributionId = contributionToUpdate.id + await eventProtocol.writeEvent( + event.setEventAdminContributionUpdate(eventAdminContributionUpdate), + ) + return result } @@ -397,15 +440,50 @@ export class AdminResolver { @Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION]) @Mutation(() => Boolean) - async adminDeleteContribution(@Arg('id', () => Int) id: number): Promise { + async adminDeleteContribution( + @Arg('id', () => Int) id: number, + @Ctx() context: Context, + ): Promise { const contribution = await DbContribution.findOne(id) if (!contribution) { logger.error(`Contribution not found for given id: ${id}`) throw new Error('Contribution not found for given id.') } + const moderator = getUser(context) + if ( + contribution.contributionType === ContributionType.USER && + contribution.userId === moderator.id + ) { + throw new Error('Own contribution can not be deleted as admin') + } + const user = await dbUser.findOneOrFail( + { id: contribution.userId }, + { relations: ['emailContact'] }, + ) contribution.contributionStatus = ContributionStatus.DELETED + contribution.deletedBy = moderator.id await contribution.save() const res = await contribution.softRemove() + + const event = new Event() + const eventAdminContributionDelete = new EventAdminContributionDelete() + eventAdminContributionDelete.userId = contribution.userId + eventAdminContributionDelete.amount = contribution.amount + eventAdminContributionDelete.contributionId = contribution.id + await eventProtocol.writeEvent( + event.setEventAdminContributionDelete(eventAdminContributionDelete), + ) + sendContributionRejectedEmail({ + senderFirstName: moderator.firstName, + senderLastName: moderator.lastName, + recipientEmail: user.emailContact.email, + recipientFirstName: user.firstName, + recipientLastName: user.lastName, + contributionMemo: contribution.memo, + contributionAmount: contribution.amount, + overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, + }) + return !!res } @@ -501,6 +579,13 @@ export class AdminResolver { } finally { await queryRunner.release() } + + const event = new Event() + const eventContributionConfirm = new EventContributionConfirm() + eventContributionConfirm.userId = user.id + eventContributionConfirm.amount = contribution.amount + eventContributionConfirm.contributionId = contribution.id + await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm)) return true } @@ -562,6 +647,13 @@ export class AdminResolver { // In case EMails are disabled log the activation link for the user if (!emailSent) { logger.info(`Account confirmation link: ${activationLink}`) + } else { + const event = new Event() + const eventSendConfirmationEmail = new EventSendConfirmationEmail() + eventSendConfirmationEmail.userId = user.id + await eventProtocol.writeEvent( + event.setEventSendConfirmationEmail(eventSendConfirmationEmail), + ) } return true @@ -675,6 +767,7 @@ export class AdminResolver { { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, ): Promise { const [links, count] = await DbContributionLink.findAndCount({ + where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }], order: { createdAt: order }, skip: (currentPage - 1) * pageSize, take: pageSize, @@ -753,9 +846,11 @@ export class AdminResolver { relations: ['user'], }) if (!contribution) { + logger.error('Contribution not found') throw new Error('Contribution not found') } if (contribution.userId === user.id) { + logger.error('Admin can not answer on own contribution') throw new Error('Admin can not answer on own contribution') } if (!contribution.user.emailContact) { diff --git a/backend/src/graphql/resolver/ContributionMessageResolver.test.ts b/backend/src/graphql/resolver/ContributionMessageResolver.test.ts index 40e9e2ace..612c2d20b 100644 --- a/backend/src/graphql/resolver/ContributionMessageResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionMessageResolver.test.ts @@ -7,8 +7,9 @@ import { adminCreateContributionMessage, createContribution, createContributionMessage, + login, } from '@/seeds/graphql/mutations' -import { listContributionMessages, login } from '@/seeds/graphql/queries' +import { listContributionMessages } from '@/seeds/graphql/queries' import { userFactory } from '@/seeds/factory/user' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { peterLustig } from '@/seeds/users/peter-lustig' @@ -21,14 +22,13 @@ jest.mock('@/mailer/sendAddedContributionMessageEmail', () => { } }) -let mutate: any, query: any, con: any +let mutate: any, con: any let testEnv: any let result: any beforeAll(async () => { testEnv = await testEnvironment() mutate = testEnv.mutate - query = testEnv.query con = testEnv.con await cleanDB() }) @@ -59,8 +59,8 @@ describe('ContributionMessageResolver', () => { beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) await userFactory(testEnv, peterLustig) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) result = await mutate({ @@ -71,8 +71,8 @@ describe('ContributionMessageResolver', () => { creationDate: new Date().toString(), }, }) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) @@ -103,8 +103,8 @@ describe('ContributionMessageResolver', () => { }) it('throws error when contribution.userId equals user.id', async () => { - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) const result2 = await mutate({ @@ -195,8 +195,8 @@ describe('ContributionMessageResolver', () => { describe('authenticated', () => { beforeAll(async () => { - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -227,8 +227,8 @@ describe('ContributionMessageResolver', () => { }) it('throws error when other user tries to send createContributionMessage', async () => { - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) await expect( @@ -253,8 +253,8 @@ describe('ContributionMessageResolver', () => { describe('valid input', () => { beforeAll(async () => { - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -304,8 +304,8 @@ describe('ContributionMessageResolver', () => { describe('authenticated', () => { beforeAll(async () => { - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) diff --git a/backend/src/graphql/resolver/ContributionResolver.test.ts b/backend/src/graphql/resolver/ContributionResolver.test.ts index 20f11ff9a..e512961e7 100644 --- a/backend/src/graphql/resolver/ContributionResolver.test.ts +++ b/backend/src/graphql/resolver/ContributionResolver.test.ts @@ -8,14 +8,18 @@ import { createContribution, deleteContribution, updateContribution, + login, } from '@/seeds/graphql/mutations' -import { listAllContributions, listContributions, login } from '@/seeds/graphql/queries' +import { listAllContributions, listContributions } from '@/seeds/graphql/queries' import { cleanDB, resetToken, testEnvironment } from '@test/helpers' import { GraphQLError } from 'graphql' import { userFactory } from '@/seeds/factory/user' import { creationFactory } from '@/seeds/factory/creation' import { creations } from '@/seeds/creation/index' import { peterLustig } from '@/seeds/users/peter-lustig' +import { EventProtocol } from '@entity/EventProtocol' +import { EventProtocolType } from '@/event/EventProtocolType' +import { logger } from '@test/testSetup' let mutate: any, query: any, con: any let testEnv: any @@ -35,6 +39,8 @@ afterAll(async () => { }) describe('ContributionResolver', () => { + let bibi: any + describe('createContribution', () => { describe('unauthenticated', () => { it('returns an error', async () => { @@ -54,8 +60,9 @@ describe('ContributionResolver', () => { describe('authenticated with valid user', () => { beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + + bibi = await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) @@ -67,6 +74,7 @@ describe('ContributionResolver', () => { describe('input not valid', () => { it('throws error when memo length smaller than 5 chars', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -84,7 +92,12 @@ describe('ContributionResolver', () => { ) }) + it('logs the error found', () => { + expect(logger.error).toBeCalledWith(`memo text is too short: memo.length=4 < 5`) + }) + it('throws error when memo length greater than 255 chars', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -102,7 +115,12 @@ describe('ContributionResolver', () => { ) }) + it('logs the error found', () => { + expect(logger.error).toBeCalledWith(`memo text is too long: memo.length=259 > 255`) + }) + it('throws error when creationDate not-valid', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: createContribution, @@ -121,7 +139,15 @@ describe('ContributionResolver', () => { ) }) + it('logs the error found', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + 'Invalid Date', + ) + }) + it('throws error when creationDate 3 month behind', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -140,20 +166,31 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + 'Invalid Date', + ) + }) }) describe('valid input', () => { + let contribution: any + + beforeAll(async () => { + contribution = await mutate({ + mutation: createContribution, + variables: { + amount: 100.0, + memo: 'Test env contribution', + creationDate: new Date().toString(), + }, + }) + }) + it('creates contribution', async () => { - await expect( - mutate({ - mutation: createContribution, - variables: { - amount: 100.0, - memo: 'Test env contribution', - creationDate: new Date().toString(), - }, - }), - ).resolves.toEqual( + expect(contribution).toEqual( expect.objectContaining({ data: { createContribution: { @@ -165,6 +202,17 @@ describe('ContributionResolver', () => { }), ) }) + + it('stores the create contribution event in the database', async () => { + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.CONTRIBUTION_CREATE, + amount: expect.decimalEqual(100), + contributionId: contribution.data.createContribution.id, + userId: bibi.data.login.id, + }), + ) + }) }) }) }) @@ -197,8 +245,8 @@ describe('ContributionResolver', () => { const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await creationFactory(testEnv, bibisCreation!) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) await mutate({ @@ -310,8 +358,8 @@ describe('ContributionResolver', () => { beforeAll(async () => { await userFactory(testEnv, peterLustig) await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) result = await mutate({ @@ -331,6 +379,7 @@ describe('ContributionResolver', () => { describe('wrong contribution id', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateContribution, @@ -347,10 +396,15 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith('No contribution found to given id') + }) }) describe('Memo length smaller than 5 chars', () => { it('throws error', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -368,10 +422,15 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < 5') + }) }) describe('Memo length greater than 255 chars', () => { it('throws error', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -389,17 +448,22 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith('memo text is too long: memo.length=259 > 255') + }) }) describe('wrong user tries to update the contribution', () => { beforeAll(async () => { - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateContribution, @@ -420,10 +484,17 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith( + 'user of the pending contribution and send user does not correspond', + ) + }) }) describe('admin tries to update a user contribution', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: adminUpdateContribution, @@ -441,17 +512,20 @@ describe('ContributionResolver', () => { }), ) }) + + // TODO check that the error is logged (need to modify AdminResolver, avoid conflicts) }) describe('update too much so that the limit is exceeded', () => { beforeAll(async () => { - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) }) it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateContribution, @@ -472,10 +546,17 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith( + 'The amount (1019 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ) + }) }) describe('update creation to a date that is older than 3 months', () => { it('throws an error', async () => { + jest.clearAllMocks() const date = new Date() await expect( mutate({ @@ -489,12 +570,17 @@ describe('ContributionResolver', () => { }), ).resolves.toEqual( expect.objectContaining({ - errors: [ - new GraphQLError('No information for available creations for the given date'), - ], + errors: [new GraphQLError('Currently the month of the contribution cannot change.')], }), ) }) + + it.skip('logs the error found', () => { + expect(logger.error).toBeCalledWith( + 'No information for available creations with the given creationDate=', + 'Invalid Date', + ) + }) }) describe('valid input', () => { @@ -521,6 +607,22 @@ describe('ContributionResolver', () => { }), ) }) + + it('stores the update contribution event in the database', async () => { + bibi = await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.CONTRIBUTION_UPDATE, + amount: expect.decimalEqual(10), + contributionId: result.data.createContribution.id, + userId: bibi.data.login.id, + }), + ) + }) }) }) }) @@ -553,8 +655,8 @@ describe('ContributionResolver', () => { const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de') // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await creationFactory(testEnv, bibisCreation!) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) await mutate({ @@ -627,11 +729,13 @@ describe('ContributionResolver', () => { }) describe('authenticated', () => { + let peter: any beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - await userFactory(testEnv, peterLustig) - await query({ - query: login, + peter = await userFactory(testEnv, peterLustig) + + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) result = await mutate({ @@ -664,12 +768,16 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith('Contribution not found for given id') + }) }) - describe('other user sends a deleteContribtuion', () => { + describe('other user sends a deleteContribution', () => { it('returns an error', async () => { - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) await expect( @@ -685,6 +793,10 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith('Can not delete contribution of another user') + }) }) describe('User deletes own contribution', () => { @@ -698,12 +810,40 @@ describe('ContributionResolver', () => { }), ).resolves.toBeTruthy() }) + + it('stores the delete contribution event in the database', async () => { + const contribution = await mutate({ + mutation: createContribution, + variables: { + amount: 166.0, + memo: 'Whatever contribution', + creationDate: new Date().toString(), + }, + }) + + await mutate({ + mutation: deleteContribution, + variables: { + id: contribution.data.createContribution.id, + }, + }) + + await expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.CONTRIBUTION_DELETE, + contributionId: contribution.data.createContribution.id, + amount: expect.decimalEqual(166), + userId: peter.id, + }), + ) + }) }) describe('User deletes already confirmed contribution', () => { it('throws an error', async () => { - await query({ - query: login, + jest.clearAllMocks() + await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) await mutate({ @@ -712,8 +852,8 @@ describe('ContributionResolver', () => { id: result.data.createContribution.id, }, }) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, }) await expect( @@ -729,6 +869,10 @@ describe('ContributionResolver', () => { }), ) }) + + it('logs the error found', () => { + expect(logger.error).toBeCalledWith('A confirmed contribution can not be deleted') + }) }) }) }) diff --git a/backend/src/graphql/resolver/ContributionResolver.ts b/backend/src/graphql/resolver/ContributionResolver.ts index fc93880f1..a061304b7 100644 --- a/backend/src/graphql/resolver/ContributionResolver.ts +++ b/backend/src/graphql/resolver/ContributionResolver.ts @@ -13,6 +13,15 @@ import { Contribution, ContributionListResult } from '@model/Contribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { validateContribution, getUserCreation, updateCreations } from './util/creations' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' +import { ContributionMessage } from '@entity/ContributionMessage' +import { ContributionMessageType } from '@enum/MessageType' +import { + Event, + EventContributionCreate, + EventContributionDelete, + EventContributionUpdate, +} from '@/event/Event' +import { eventProtocol } from '@/event/EventProtocolEmitter' @Resolver() export class ContributionResolver { @@ -23,15 +32,17 @@ export class ContributionResolver { @Ctx() context: Context, ): Promise { if (memo.length > MEMO_MAX_CHARS) { - logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`) + logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) } if (memo.length < MEMO_MIN_CHARS) { - logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`) + logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) } + const event = new Event() + const user = getUser(context) const creations = await getUserCreation(user.id) logger.trace('creations', creations) @@ -49,6 +60,13 @@ export class ContributionResolver { logger.trace('contribution to save', contribution) await dbContribution.save(contribution) + + const eventCreateContribution = new EventContributionCreate() + eventCreateContribution.userId = user.id + eventCreateContribution.amount = amount + eventCreateContribution.contributionId = contribution.id + await eventProtocol.writeEvent(event.setEventContributionCreate(eventCreateContribution)) + return new UnconfirmedContribution(contribution, user, creations) } @@ -58,19 +76,33 @@ export class ContributionResolver { @Arg('id', () => Int) id: number, @Ctx() context: Context, ): Promise { + const event = new Event() const user = getUser(context) const contribution = await dbContribution.findOne(id) if (!contribution) { + logger.error('Contribution not found for given id') throw new Error('Contribution not found for given id.') } if (contribution.userId !== user.id) { + logger.error('Can not delete contribution of another user') throw new Error('Can not delete contribution of another user') } if (contribution.confirmedAt) { + logger.error('A confirmed contribution can not be deleted') throw new Error('A confirmed contribution can not be deleted') } + contribution.contributionStatus = ContributionStatus.DELETED + contribution.deletedBy = user.id + contribution.deletedAt = new Date() await contribution.save() + + const eventDeleteContribution = new EventContributionDelete() + eventDeleteContribution.userId = user.id + eventDeleteContribution.contributionId = contribution.id + eventDeleteContribution.amount = contribution.amount + await eventProtocol.writeEvent(event.setEventContributionDelete(eventDeleteContribution)) + const res = await contribution.softRemove() return !!res } @@ -98,6 +130,7 @@ export class ContributionResolver { .from(dbContribution, 'c') .leftJoinAndSelect('c.messages', 'm') .where(where) + .withDeleted() .orderBy('c.createdAt', order) .limit(pageSize) .offset((currentPage - 1) * pageSize) @@ -139,12 +172,12 @@ export class ContributionResolver { @Ctx() context: Context, ): Promise { if (memo.length > MEMO_MAX_CHARS) { - logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`) + logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) } if (memo.length < MEMO_MIN_CHARS) { - logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`) + logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) } @@ -154,26 +187,67 @@ export class ContributionResolver { where: { id: contributionId, confirmedAt: IsNull() }, }) if (!contributionToUpdate) { + logger.error('No contribution found to given id') throw new Error('No contribution found to given id.') } if (contributionToUpdate.userId !== user.id) { + logger.error('user of the pending contribution and send user does not correspond') throw new Error('user of the pending contribution and send user does not correspond') } - + if ( + contributionToUpdate.contributionStatus !== ContributionStatus.IN_PROGRESS && + contributionToUpdate.contributionStatus !== ContributionStatus.PENDING + ) { + logger.error( + `Contribution can not be updated since the state is ${contributionToUpdate.contributionStatus}`, + ) + throw new Error( + `Contribution can not be updated since the state is ${contributionToUpdate.contributionStatus}`, + ) + } const creationDateObj = new Date(creationDate) let creations = await getUserCreation(user.id) if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { creations = updateCreations(creations, contributionToUpdate) + } else { + logger.error('Currently the month of the contribution cannot change.') + throw new Error('Currently the month of the contribution cannot change.') } // all possible cases not to be true are thrown in this function validateContribution(creations, amount, creationDateObj) + + const contributionMessage = ContributionMessage.create() + contributionMessage.contributionId = contributionId + contributionMessage.createdAt = contributionToUpdate.updatedAt + ? contributionToUpdate.updatedAt + : contributionToUpdate.createdAt + const changeMessage = `${contributionToUpdate.contributionDate} + --- + ${contributionToUpdate.memo} + --- + ${contributionToUpdate.amount}` + contributionMessage.message = changeMessage + contributionMessage.isModerator = false + contributionMessage.userId = user.id + contributionMessage.type = ContributionMessageType.HISTORY + ContributionMessage.save(contributionMessage) + contributionToUpdate.amount = amount contributionToUpdate.memo = memo contributionToUpdate.contributionDate = new Date(creationDate) contributionToUpdate.contributionStatus = ContributionStatus.PENDING + contributionToUpdate.updatedAt = new Date() dbContribution.save(contributionToUpdate) + const event = new Event() + + const eventUpdateContribution = new EventContributionUpdate() + eventUpdateContribution.userId = user.id + eventUpdateContribution.contributionId = contributionId + eventUpdateContribution.amount = amount + await eventProtocol.writeEvent(event.setEventContributionUpdate(eventUpdateContribution)) + return new UnconfirmedContribution(contributionToUpdate, user, creations) } } diff --git a/backend/src/graphql/resolver/StatisticsResolver.ts b/backend/src/graphql/resolver/StatisticsResolver.ts index b0c061d91..7bfae319e 100644 --- a/backend/src/graphql/resolver/StatisticsResolver.ts +++ b/backend/src/graphql/resolver/StatisticsResolver.ts @@ -63,6 +63,8 @@ export class StatisticsResolver { .where('transaction.decay IS NOT NULL') .getRawOne() + await queryRunner.release() + return { totalUsers, activeUsers, diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts index 5a1a39dca..275242bd3 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -1,4 +1,229 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + import { transactionLinkCode } from './TransactionLinkResolver' +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { cleanDB, testEnvironment } from '@test/helpers' +import { userFactory } from '@/seeds/factory/user' +import { + login, + createContributionLink, + redeemTransactionLink, + createContribution, + updateContribution, +} from '@/seeds/graphql/mutations' +import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' +import { UnconfirmedContribution } from '@model/UnconfirmedContribution' +import Decimal from 'decimal.js-light' +import { GraphQLError } from 'graphql' + +let mutate: any, con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + con = testEnv.con + await cleanDB() + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, peterLustig) +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +describe('TransactionLinkResolver', () => { + describe('redeem daily Contribution Link', () => { + const now = new Date() + let contributionLink: DbContributionLink | undefined + let contribution: UnconfirmedContribution | undefined + + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: createContributionLink, + variables: { + amount: new Decimal(5), + name: 'Daily Contribution Link', + memo: 'Thank you for contribute daily to the community', + cycle: 'DAILY', + validFrom: new Date(now.getFullYear(), 0, 1).toISOString(), + validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(), + maxAmountPerMonth: new Decimal(200), + maxPerCycle: 1, + }, + }) + }) + + it('has a daily contribution link in the database', async () => { + const cls = await DbContributionLink.find() + expect(cls).toHaveLength(1) + contributionLink = cls[0] + expect(contributionLink).toEqual( + expect.objectContaining({ + id: expect.any(Number), + name: 'Daily Contribution Link', + memo: 'Thank you for contribute daily to the community', + validFrom: new Date(now.getFullYear(), 0, 1), + validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0), + cycle: 'DAILY', + maxPerCycle: 1, + totalMaxCountOfContribution: null, + maxAccountBalance: null, + minGapHours: null, + createdAt: expect.any(Date), + deletedAt: null, + code: expect.stringMatching(/^[0-9a-f]{24,24}$/), + linkEnabled: true, + amount: expect.decimalEqual(5), + maxAmountPerMonth: expect.decimalEqual(200), + }), + ) + }) + + describe('user has pending contribution of 1000 GDD', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + const result = await mutate({ + mutation: createContribution, + variables: { + amount: new Decimal(1000), + memo: 'I was brewing potions for the community the whole month', + creationDate: now.toISOString(), + }, + }) + contribution = result.data.createContribution + }) + + it('does not allow the user to redeem the contribution link', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.', + ), + ], + }) + }) + }) + + describe('user has no pending contributions that would not allow to redeem the link', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + await mutate({ + mutation: updateContribution, + variables: { + contributionId: contribution ? contribution.id : -1, + amount: new Decimal(800), + memo: 'I was brewing potions for the community the whole month', + creationDate: now.toISOString(), + }, + }) + }) + + it('allows the user to redeem the contribution link', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + data: { + redeemTransactionLink: true, + }, + errors: undefined, + }) + }) + + it('does not allow the user to redeem the contribution link a second time on the same day', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', + ), + ], + }) + }) + + describe('after one day', () => { + beforeAll(async () => { + jest.useFakeTimers() + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + setTimeout(() => {}, 1000 * 60 * 60 * 24) + jest.runAllTimers() + await mutate({ + mutation: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(() => { + jest.useRealTimers() + }) + + it('allows the user to redeem the contribution link again', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + data: { + redeemTransactionLink: true, + }, + errors: undefined, + }) + }) + + it('does not allow the user to redeem the contribution link a second time on the same day', async () => { + await expect( + mutate({ + mutation: redeemTransactionLink, + variables: { + code: 'CL-' + (contributionLink ? contributionLink.code : ''), + }, + }), + ).resolves.toMatchObject({ + errors: [ + new GraphQLError( + 'Creation from contribution link was not successful. Error: Contribution link already redeemed today', + ), + ], + }) + }) + }) + }) + }) +}) describe('transactionLinkCode', () => { const date = new Date() diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index c9acbace3..74c531c54 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -34,6 +34,7 @@ import { getUserCreation, validateContribution } from './util/creations' import { Decay } from '@model/Decay' import Decimal from 'decimal.js-light' import { TransactionTypeId } from '@enum/TransactionTypeId' +import { ContributionCycleType } from '@enum/ContributionCycleType' const QueryLinkResult = createUnionType({ name: 'QueryLinkResult', // the name of the GraphQL union @@ -73,10 +74,7 @@ export class TransactionLinkResolver { const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay) // validate amount - const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate) - if (!sendBalance) { - throw new Error("user hasn't enough GDD or amount is < 0") - } + await calculateBalance(user.id, holdAvailableAmount, createdDate) const transactionLink = dbTransactionLink.create() transactionLink.userId = user.id @@ -204,26 +202,63 @@ export class TransactionLinkResolver { throw new Error('Contribution link is depricated') } } - if (contributionLink.cycle !== 'ONCE') { - logger.error('contribution link has unknown cycle', contributionLink.cycle) - throw new Error('Contribution link has unknown cycle') - } - // Test ONCE rule - const alreadyRedeemed = await queryRunner.manager - .createQueryBuilder() - .select('contribution') - .from(DbContribution, 'contribution') - .where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', { - linkId: contributionLink.id, - id: user.id, - }) - .getOne() - if (alreadyRedeemed) { - logger.error('contribution link with rule ONCE already redeemed by user with id', user.id) - throw new Error('Contribution link already redeemed') + let alreadyRedeemed: DbContribution | undefined + switch (contributionLink.cycle) { + case ContributionCycleType.ONCE: { + alreadyRedeemed = await queryRunner.manager + .createQueryBuilder() + .select('contribution') + .from(DbContribution, 'contribution') + .where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', { + linkId: contributionLink.id, + id: user.id, + }) + .getOne() + if (alreadyRedeemed) { + logger.error( + 'contribution link with rule ONCE already redeemed by user with id', + user.id, + ) + throw new Error('Contribution link already redeemed') + } + break + } + case ContributionCycleType.DAILY: { + const start = new Date() + start.setHours(0, 0, 0, 0) + const end = new Date() + end.setHours(23, 59, 59, 999) + alreadyRedeemed = await queryRunner.manager + .createQueryBuilder() + .select('contribution') + .from(DbContribution, 'contribution') + .where( + `contribution.contributionLinkId = :linkId AND contribution.userId = :id + AND Date(contribution.confirmedAt) BETWEEN :start AND :end`, + { + linkId: contributionLink.id, + id: user.id, + start, + end, + }, + ) + .getOne() + if (alreadyRedeemed) { + logger.error( + 'contribution link with rule DAILY already redeemed by user with id', + user.id, + ) + throw new Error('Contribution link already redeemed today') + } + break + } + default: { + logger.error('contribution link has unknown cycle', contributionLink.cycle) + throw new Error('Contribution link has unknown cycle') + } } - const creations = await getUserCreation(user.id, false) + const creations = await getUserCreation(user.id) logger.info('open creations', creations) validateContribution(creations, contributionLink.amount, now) const contribution = new DbContribution() diff --git a/backend/src/graphql/resolver/TransactionResolver.test.ts b/backend/src/graphql/resolver/TransactionResolver.test.ts new file mode 100644 index 000000000..9e74623c8 --- /dev/null +++ b/backend/src/graphql/resolver/TransactionResolver.test.ts @@ -0,0 +1,374 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { EventProtocolType } from '@/event/EventProtocolType' +import { userFactory } from '@/seeds/factory/user' +import { + confirmContribution, + createContribution, + login, + sendCoins, +} from '@/seeds/graphql/mutations' +import { bobBaumeister } from '@/seeds/users/bob-baumeister' +import { garrickOllivander } from '@/seeds/users/garrick-ollivander' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { stephenHawking } from '@/seeds/users/stephen-hawking' +import { EventProtocol } from '@entity/EventProtocol' +import { Transaction } from '@entity/Transaction' +import { User } from '@entity/User' +import { cleanDB, resetToken, testEnvironment } from '@test/helpers' +import { logger } from '@test/testSetup' +import { GraphQLError } from 'graphql' +import { findUserByEmail } from './UserResolver' + +let mutate: any, query: any, con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment(logger) + mutate = testEnv.mutate + query = testEnv.query + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +let bobData: any +let peterData: any +let user: User[] + +describe('send coins', () => { + beforeAll(async () => { + await userFactory(testEnv, peterLustig) + await userFactory(testEnv, bobBaumeister) + await userFactory(testEnv, stephenHawking) + await userFactory(testEnv, garrickOllivander) + + bobData = { + email: 'bob@baumeister.de', + password: 'Aa12345_', + } + + peterData = { + email: 'peter@lustig.de', + password: 'Aa12345_', + } + + user = await User.find({ relations: ['emailContact'] }) + }) + + afterAll(async () => { + await cleanDB() + }) + + describe('unknown recipient', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await mutate({ + mutation: login, + variables: bobData, + }) + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'wrong@email.com', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No user with this credentials')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith(`UserContact with email=wrong@email.com does not exists`) + }) + + describe('deleted recipient', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await mutate({ + mutation: login, + variables: peterData, + }) + + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'stephen@hawking.uk', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The recipient account was deleted')], + }), + ) + }) + + it('logs the error thrown', async () => { + // find peter to check the log + const user = await findUserByEmail(peterData.email) + expect(logger.error).toBeCalledWith( + `The recipient account was deleted: recipientUser=${user}`, + ) + }) + }) + + describe('recipient account not activated', () => { + it('throws an error', async () => { + jest.clearAllMocks() + await mutate({ + mutation: login, + variables: peterData, + }) + + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'garrick@ollivander.com', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('The recipient account is not activated')], + }), + ) + }) + + it('logs the error thrown', async () => { + // find peter to check the log + const user = await findUserByEmail(peterData.email) + expect(logger.error).toBeCalledWith( + `The recipient account is not activated: recipientUser=${user}`, + ) + }) + }) + }) + + describe('errors in the transaction itself', () => { + beforeAll(async () => { + await mutate({ + mutation: login, + variables: bobData, + }) + }) + + describe('sender and recipient are the same', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'bob@baumeister.de', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Sender and Recipient are the same.')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Sender and Recipient are the same.') + }) + }) + + describe('memo text is too long', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 100, + memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test t', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('memo text is too long (255 characters maximum)')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('memo text is too long: memo.length=256 > 255') + }) + }) + + describe('memo text is too short', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 100, + memo: 'test', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('memo text is too short (5 characters minimum)')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < 5') + }) + }) + + describe('user has not enough GDD', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 100, + memo: 'testing', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`User has not received any GDD yet`)], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith( + `No prior transaction found for user with id: ${user[1].id}`, + ) + }) + }) + + describe('sending negative amount', () => { + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: -50, + memo: 'testing negative', + }, + }), + ).toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Transaction amount must be greater than 0')], + }), + ) + }) + + it('logs the error thrown', () => { + expect(logger.error).toBeCalledWith('Transaction amount must be greater than 0: -50') + }) + }) + }) + + describe('user has some GDD', () => { + beforeAll(async () => { + resetToken() + + // login as bob again + await query({ mutation: login, variables: bobData }) + + // create contribution as user bob + const contribution = await mutate({ + mutation: createContribution, + variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() }, + }) + + // login as admin + await query({ mutation: login, variables: peterData }) + + // confirm the contribution + await mutate({ + mutation: confirmContribution, + variables: { id: contribution.data.createContribution.id }, + }) + + // login as bob again + await query({ mutation: login, variables: bobData }) + }) + + describe('good transaction', () => { + it('sends the coins', async () => { + expect( + await mutate({ + mutation: sendCoins, + variables: { + email: 'peter@lustig.de', + amount: 50, + memo: 'unrepeatable memo', + }, + }), + ).toEqual( + expect.objectContaining({ + data: { + sendCoins: 'true', + }, + }), + ) + }) + + it('stores the send transaction event in the database', async () => { + // Find the exact transaction (sent one is the one with user[1] as user) + const transaction = await Transaction.find({ + userId: user[1].id, + memo: 'unrepeatable memo', + }) + + expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.TRANSACTION_SEND, + userId: user[1].id, + transactionId: transaction[0].id, + xUserId: user[0].id, + }), + ) + }) + + it('stores the receive event in the database', async () => { + // Find the exact transaction (received one is the one with user[0] as user) + const transaction = await Transaction.find({ + userId: user[0].id, + memo: 'unrepeatable memo', + }) + + expect(EventProtocol.find()).resolves.toContainEqual( + expect.objectContaining({ + type: EventProtocolType.TRANSACTION_RECEIVE, + userId: user[0].id, + transactionId: transaction[0].id, + xUserId: user[1].id, + }), + ) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index b00d84de6..f0fb2f452 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -6,7 +6,7 @@ import CONFIG from '@/config' import { Context, getUser } from '@/server/context' import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql' -import { getCustomRepository, getConnection } from '@dbTools/typeorm' +import { getCustomRepository, getConnection, In } from '@dbTools/typeorm' import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail' @@ -37,6 +37,9 @@ import { BalanceResolver } from './BalanceResolver' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' import { findUserByEmail } from './UserResolver' import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed' +import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event' +import { eventProtocol } from '@/event/EventProtocolEmitter' +import { Decay } from '../model/Decay' export const executeTransaction = async ( amount: Decimal, @@ -55,28 +58,19 @@ export const executeTransaction = async ( } if (memo.length > MEMO_MAX_CHARS) { - logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`) + logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) } if (memo.length < MEMO_MIN_CHARS) { - logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`) + logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) } // validate amount const receivedCallDate = new Date() - const sendBalance = await calculateBalance( - sender.id, - amount.mul(-1), - receivedCallDate, - transactionLink, - ) - logger.debug(`calculated Balance=${sendBalance}`) - if (!sendBalance) { - logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`) - throw new Error("user hasn't enough GDD or amount is < 0") - } + + const sendBalance = await calculateBalance(sender.id, amount, receivedCallDate, transactionLink) const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() @@ -106,7 +100,24 @@ export const executeTransaction = async ( transactionReceive.userId = recipient.id transactionReceive.linkedUserId = sender.id transactionReceive.amount = amount - const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate) + + // state received balance + let receiveBalance: { + balance: Decimal + decay: Decay + lastTransactionId: number + } | null + + // try received balance + try { + receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate) + } catch (e) { + logger.info( + `User with no transactions sent: ${recipient.id}, has received a transaction of ${amount} GDD from user: ${sender.id}`, + ) + receiveBalance = null + } + transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount transactionReceive.balanceDate = receivedCallDate transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) @@ -135,6 +146,20 @@ export const executeTransaction = async ( await queryRunner.commitTransaction() logger.info(`commit Transaction successful...`) + + const eventTransactionSend = new EventTransactionSend() + eventTransactionSend.userId = transactionSend.userId + eventTransactionSend.xUserId = transactionSend.linkedUserId + eventTransactionSend.transactionId = transactionSend.id + eventTransactionSend.amount = transactionSend.amount.mul(-1) + await eventProtocol.writeEvent(new Event().setEventTransactionSend(eventTransactionSend)) + + const eventTransactionReceive = new EventTransactionReceive() + eventTransactionReceive.userId = transactionReceive.userId + eventTransactionReceive.xUserId = transactionReceive.linkedUserId + eventTransactionReceive.transactionId = transactionReceive.id + eventTransactionReceive.amount = transactionReceive.amount + await eventProtocol.writeEvent(new Event().setEventTransactionReceive(eventTransactionReceive)) } catch (e) { await queryRunner.rollbackTransaction() logger.error(`Transaction was not successful: ${e}`) @@ -224,11 +249,11 @@ export class TransactionResolver { logger.debug(`involvedUserIds=${involvedUserIds}`) // We need to show the name for deleted users for old transactions - const involvedDbUsers = await dbUser - .createQueryBuilder() - .withDeleted() - .where('id IN (:...userIds)', { userIds: involvedUserIds }) - .getMany() + const involvedDbUsers = await dbUser.find({ + where: { id: In(involvedUserIds) }, + withDeleted: true, + relations: ['emailContact'], + }) const involvedUsers = involvedDbUsers.map((u) => new User(u)) logger.debug(`involvedUsers=${involvedUsers}`) @@ -316,6 +341,10 @@ export class TransactionResolver { } */ // const recipientUser = await dbUser.findOne({ id: emailContact.userId }) + + /* Code inside this if statement is unreachable (useless by so), + in findUserByEmail() an error is already thrown if the user is not found + */ if (!recipientUser) { logger.error(`unknown recipient to UserContact: email=${email}`) throw new Error('unknown recipient') diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index b7db68c1b..6323abfde 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -5,6 +5,8 @@ import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/help import { userFactory } from '@/seeds/factory/user' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { + login, + logout, createUser, setPassword, forgotPassword, @@ -12,7 +14,7 @@ import { createContribution, confirmContribution, } from '@/seeds/graphql/mutations' -import { login, logout, verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries' +import { verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries' import { GraphQLError } from 'graphql' import { User } from '@entity/User' import CONFIG from '@/config' @@ -359,7 +361,7 @@ describe('UserResolver', () => { beforeAll(async () => { await userFactory(testEnv, peterLustig) await userFactory(testEnv, bobBaumeister) - await query({ query: login, variables: bobData }) + await mutate({ mutation: login, variables: bobData }) // create contribution as user bob contribution = await mutate({ @@ -368,7 +370,7 @@ describe('UserResolver', () => { }) // login as admin - await query({ query: login, variables: peterData }) + await mutate({ mutation: login, variables: peterData }) // confirm the contribution contribution = await mutate({ @@ -377,7 +379,7 @@ describe('UserResolver', () => { }) // login as user bob - bob = await query({ query: login, variables: bobData }) + bob = await mutate({ mutation: login, variables: bobData }) // create transaction link await transactionLinkFactory(testEnv, { @@ -513,18 +515,20 @@ describe('UserResolver', () => { await mutate({ mutation: createUser, variables: createUserVariables }) const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email }) emailVerificationCode = emailContact.emailVerificationCode.toString() - result = await mutate({ - mutation: setPassword, - variables: { code: emailVerificationCode, password: 'not-valid' }, - }) }) afterAll(async () => { await cleanDB() }) - it('throws an error', () => { - expect(result).toEqual( + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: setPassword, + variables: { code: emailVerificationCode, password: 'not-valid' }, + }), + ).toEqual( expect.objectContaining({ errors: [ new GraphQLError( @@ -543,18 +547,20 @@ describe('UserResolver', () => { describe('no valid optin code', () => { beforeAll(async () => { await mutate({ mutation: createUser, variables: createUserVariables }) - result = await mutate({ - mutation: setPassword, - variables: { code: 'not valid', password: 'Aa12345_' }, - }) }) afterAll(async () => { await cleanDB() }) - it('throws an error', () => { - expect(result).toEqual( + it('throws an error', async () => { + jest.clearAllMocks() + expect( + await mutate({ + mutation: setPassword, + variables: { code: 'not valid', password: 'Aa12345_' }, + }), + ).toEqual( expect.objectContaining({ errors: [new GraphQLError('Could not login with emailVerificationCode')], }), @@ -581,13 +587,9 @@ describe('UserResolver', () => { }) describe('no users in database', () => { - beforeAll(async () => { + it('throws an error', async () => { jest.clearAllMocks() - result = await query({ query: login, variables }) - }) - - it('throws an error', () => { - expect(result).toEqual( + expect(await mutate({ mutation: login, variables })).toEqual( expect.objectContaining({ errors: [new GraphQLError('No user with this credentials')], }), @@ -604,7 +606,7 @@ describe('UserResolver', () => { describe('user is in database and correct login data', () => { beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - result = await query({ query: login, variables }) + result = await mutate({ mutation: login, variables }) }) afterAll(async () => { @@ -641,7 +643,7 @@ describe('UserResolver', () => { describe('user is in database and wrong password', () => { beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - result = await query({ query: login, variables: { ...variables, password: 'wrong' } }) + result = await mutate({ mutation: login, variables: { ...variables, password: 'wrong' } }) }) afterAll(async () => { @@ -665,8 +667,9 @@ describe('UserResolver', () => { describe('logout', () => { describe('unauthenticated', () => { it('throws an error', async () => { + jest.clearAllMocks() resetToken() - await expect(query({ query: logout })).resolves.toEqual( + await expect(mutate({ mutation: logout })).resolves.toEqual( expect.objectContaining({ errors: [new GraphQLError('401 Unauthorized')], }), @@ -682,7 +685,7 @@ describe('UserResolver', () => { beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - await query({ query: login, variables }) + await mutate({ mutation: login, variables }) }) afterAll(async () => { @@ -690,7 +693,7 @@ describe('UserResolver', () => { }) it('returns true', async () => { - await expect(query({ query: logout })).resolves.toEqual( + await expect(mutate({ mutation: logout })).resolves.toEqual( expect.objectContaining({ data: { logout: 'true' }, errors: undefined, @@ -703,6 +706,7 @@ describe('UserResolver', () => { describe('verifyLogin', () => { describe('unauthenticated', () => { it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(query({ query: verifyLogin })).resolves.toEqual( expect.objectContaining({ @@ -722,6 +726,7 @@ describe('UserResolver', () => { }) it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(query({ query: verifyLogin })).resolves.toEqual( expect.objectContaining({ @@ -739,7 +744,7 @@ describe('UserResolver', () => { } beforeAll(async () => { - await query({ query: login, variables }) + await mutate({ mutation: login, variables }) user = await User.find() }) @@ -882,6 +887,7 @@ describe('UserResolver', () => { describe('wrong optin code', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( query({ query: queryOptIn, variables: { optIn: 'not-valid' } }), ).resolves.toEqual( @@ -918,6 +924,7 @@ describe('UserResolver', () => { describe('updateUserInfos', () => { describe('unauthenticated', () => { it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(mutate({ mutation: updateUserInfos })).resolves.toEqual( expect.objectContaining({ @@ -930,8 +937,8 @@ describe('UserResolver', () => { describe('authenticated', () => { beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_', @@ -975,6 +982,7 @@ describe('UserResolver', () => { describe('language is not valid', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateUserInfos, @@ -997,6 +1005,7 @@ describe('UserResolver', () => { describe('password', () => { describe('wrong old password', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateUserInfos, @@ -1019,6 +1028,7 @@ describe('UserResolver', () => { describe('invalid new password', () => { it('throws an error', async () => { + jest.clearAllMocks() await expect( mutate({ mutation: updateUserInfos, @@ -1062,8 +1072,8 @@ describe('UserResolver', () => { it('can login with new password', async () => { await expect( - query({ - query: login, + mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Bb12345_', @@ -1082,8 +1092,8 @@ describe('UserResolver', () => { it('cannot login with old password', async () => { await expect( - query({ - query: login, + mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_', @@ -1107,6 +1117,7 @@ describe('UserResolver', () => { describe('searchAdminUsers', () => { describe('unauthenticated', () => { it('throws an error', async () => { + jest.clearAllMocks() resetToken() await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual( expect.objectContaining({ @@ -1120,8 +1131,8 @@ describe('UserResolver', () => { beforeAll(async () => { await userFactory(testEnv, bibiBloxberg) await userFactory(testEnv, peterLustig) - await query({ - query: login, + await mutate({ + mutation: login, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_', diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index d111dd6f2..81d0bab0f 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -317,7 +317,7 @@ export class UserResolver { } @Authorized([RIGHTS.LOGIN]) - @Query(() => User) + @Mutation(() => User) @UseMiddleware(klicktippNewsletterStateMiddleware) async login( @Args() { email, password, publisherId }: UnsecureLoginArgs, @@ -380,7 +380,7 @@ export class UserResolver { } @Authorized([RIGHTS.LOGOUT]) - @Query(() => String) + @Mutation(() => String) async logout(): Promise { // TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token. // Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login) diff --git a/backend/src/graphql/resolver/util/creations.ts b/backend/src/graphql/resolver/util/creations.ts index 4f1cec0e0..abf4017cb 100644 --- a/backend/src/graphql/resolver/util/creations.ts +++ b/backend/src/graphql/resolver/util/creations.ts @@ -1,4 +1,3 @@ -import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId' import { backendLogger as logger } from '@/server/logger' import { getConnection } from '@dbTools/typeorm' import { Contribution } from '@entity/Contribution' @@ -21,7 +20,7 @@ export const validateContribution = ( if (index < 0) { logger.error( 'No information for available creations with the given creationDate=', - creationDate, + creationDate.toString(), ) throw new Error('No information for available creations for the given date') } @@ -50,27 +49,27 @@ export const getUserCreations = async ( const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day' logger.trace('getUserCreations dateFilter=', dateFilter) - const unionString = includePending - ? ` - UNION - SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions - WHERE user_id IN (${ids.toString()}) - AND contribution_date >= ${dateFilter} - AND confirmed_at IS NULL AND deleted_at IS NULL` - : '' - logger.trace('getUserCreations unionString=', unionString) + const sumAmountContributionPerUserAndLast3MonthQuery = queryRunner.manager + .createQueryBuilder(Contribution, 'c') + .select('month(contribution_date)', 'month') + .addSelect('user_id', 'userId') + .addSelect('sum(amount)', 'sum') + .where(`user_id in (${ids.toString()})`) + .andWhere(`contribution_date >= ${dateFilter}`) + .andWhere('deleted_at IS NULL') + .andWhere('denied_at IS NULL') + .groupBy('month') + .addGroupBy('userId') + .orderBy('month', 'DESC') - const unionQuery = await queryRunner.manager.query(` - SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM - (SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions - WHERE user_id IN (${ids.toString()}) - AND type_id = ${TransactionTypeId.CREATION} - AND creation_date >= ${dateFilter} - ${unionString}) AS result - GROUP BY month, userId - ORDER BY date DESC - `) - logger.trace('getUserCreations unionQuery=', unionQuery) + if (!includePending) { + sumAmountContributionPerUserAndLast3MonthQuery.andWhere('confirmed_at IS NOT NULL') + } + + const sumAmountContributionPerUserAndLast3Month = + await sumAmountContributionPerUserAndLast3MonthQuery.getRawMany() + + logger.trace(sumAmountContributionPerUserAndLast3Month) await queryRunner.release() @@ -78,9 +77,9 @@ export const getUserCreations = async ( return { id, creations: months.map((month) => { - const creation = unionQuery.find( - (raw: { month: string; id: string; creation: number[] }) => - parseInt(raw.month) === month && parseInt(raw.id) === id, + const creation = sumAmountContributionPerUserAndLast3Month.find( + (raw: { month: string; userId: string; creation: number[] }) => + parseInt(raw.month) === month && parseInt(raw.userId) === id, ) return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0) }), diff --git a/backend/src/index.ts b/backend/src/index.ts index 4c08b422d..dc1bbb115 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import createServer from './server/createServer' +import { startDHT } from '@/federation/index' // config import CONFIG from './config' @@ -16,6 +17,11 @@ async function main() { console.log(`GraphIQL available at http://localhost:${CONFIG.PORT}`) } }) + + // start DHT hyperswarm when DHT_TOPIC is set in .env + if (CONFIG.DHT_TOPIC) { + await startDHT(CONFIG.DHT_TOPIC) // con, + } } main().catch((e) => { diff --git a/backend/src/mailer/sendAddedContributionMessageEmail.test.ts b/backend/src/mailer/sendAddedContributionMessageEmail.test.ts index 1151a0abc..9a2ec1aa1 100644 --- a/backend/src/mailer/sendAddedContributionMessageEmail.test.ts +++ b/backend/src/mailer/sendAddedContributionMessageEmail.test.ts @@ -26,12 +26,12 @@ describe('sendAddedContributionMessageEmail', () => { it('calls sendEMail', () => { expect(sendEMail).toBeCalledWith({ to: `Bibi Bloxberg `, - subject: 'Gradido Frage zur Schöpfung', + subject: 'Nachricht zu deinem Gemeinwohl-Beitrag', text: expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Peter Lustig') && expect.stringContaining( - 'Du hast soeben zu deinem eingereichten Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Rückfrage von Peter Lustig erhalten.', + 'du hast zu deinem Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Nachricht von Peter Lustig erhalten.', ) && expect.stringContaining('Was für ein Besen ist es geworden?') && expect.stringContaining('http://localhost/overview'), diff --git a/backend/src/mailer/sendContributionConfirmedEmail.test.ts b/backend/src/mailer/sendContributionConfirmedEmail.test.ts index 1935144fd..bd89afa69 100644 --- a/backend/src/mailer/sendContributionConfirmedEmail.test.ts +++ b/backend/src/mailer/sendContributionConfirmedEmail.test.ts @@ -26,11 +26,11 @@ describe('sendContributionConfirmedEmail', () => { it('calls sendEMail', () => { expect(sendEMail).toBeCalledWith({ to: 'Bibi Bloxberg ', - subject: 'Schöpfung wurde bestätigt', + subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt', text: expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining( - 'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben bestätigt.', + 'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben von Peter Lustig bestätigt und in deinem Gradido-Konto gutgeschrieben.', ) && expect.stringContaining('Betrag: 200,00 GDD') && expect.stringContaining('Link zu deinem Konto: http://localhost/overview'), diff --git a/backend/src/mailer/sendContributionRejectedEmail.test.ts b/backend/src/mailer/sendContributionRejectedEmail.test.ts new file mode 100644 index 000000000..be41ff15f --- /dev/null +++ b/backend/src/mailer/sendContributionRejectedEmail.test.ts @@ -0,0 +1,38 @@ +import Decimal from 'decimal.js-light' +import { sendContributionRejectedEmail } from './sendContributionRejectedEmail' +import { sendEMail } from './sendEMail' + +jest.mock('./sendEMail', () => { + return { + __esModule: true, + sendEMail: jest.fn(), + } +}) + +describe('sendContributionConfirmedEmail', () => { + beforeEach(async () => { + await sendContributionRejectedEmail({ + senderFirstName: 'Peter', + senderLastName: 'Lustig', + recipientFirstName: 'Bibi', + recipientLastName: 'Bloxberg', + recipientEmail: 'bibi@bloxberg.de', + contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!', + contributionAmount: new Decimal(200.0), + overviewURL: 'http://localhost/overview', + }) + }) + + it('calls sendEMail', () => { + expect(sendEMail).toBeCalledWith({ + to: 'Bibi Bloxberg ', + subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt', + text: + expect.stringContaining('Hallo Bibi Bloxberg') && + expect.stringContaining( + 'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde von Peter Lustig abgelehnt.', + ) && + expect.stringContaining('Link zu deinem Konto: http://localhost/overview'), + }) + }) +}) diff --git a/backend/src/mailer/sendContributionRejectedEmail.ts b/backend/src/mailer/sendContributionRejectedEmail.ts new file mode 100644 index 000000000..9edb5ba2a --- /dev/null +++ b/backend/src/mailer/sendContributionRejectedEmail.ts @@ -0,0 +1,26 @@ +import { backendLogger as logger } from '@/server/logger' +import Decimal from 'decimal.js-light' +import { sendEMail } from './sendEMail' +import { contributionRejected } from './text/contributionRejected' + +export const sendContributionRejectedEmail = (data: { + senderFirstName: string + senderLastName: string + recipientFirstName: string + recipientLastName: string + recipientEmail: string + contributionMemo: string + contributionAmount: Decimal + overviewURL: string +}): Promise => { + logger.info( + `sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>, + subject=${contributionRejected.de.subject}, + text=${contributionRejected.de.text(data)}`, + ) + return sendEMail({ + to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`, + subject: contributionRejected.de.subject, + text: contributionRejected.de.text(data), + }) +} diff --git a/backend/src/mailer/sendEMail.test.ts b/backend/src/mailer/sendEMail.test.ts index 19cdab7b0..e062b71d8 100644 --- a/backend/src/mailer/sendEMail.test.ts +++ b/backend/src/mailer/sendEMail.test.ts @@ -29,6 +29,7 @@ describe('sendEMail', () => { let result: boolean describe('config email is false', () => { beforeEach(async () => { + jest.clearAllMocks() result = await sendEMail({ to: 'receiver@mail.org', cc: 'support@gradido.net', @@ -48,6 +49,7 @@ describe('sendEMail', () => { describe('config email is true', () => { beforeEach(async () => { + jest.clearAllMocks() CONFIG.EMAIL = true result = await sendEMail({ to: 'receiver@mail.org', @@ -73,7 +75,7 @@ describe('sendEMail', () => { it('calls sendMail of transporter', () => { expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({ from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, - to: `${CONFIG.EMAIL_TEST_RECEIVER}`, + to: 'receiver@mail.org', cc: 'support@gradido.net', subject: 'Subject', text: 'Text text text', @@ -84,4 +86,28 @@ describe('sendEMail', () => { expect(result).toBeTruthy() }) }) + + describe('with email EMAIL_TEST_MODUS true', () => { + beforeEach(async () => { + jest.clearAllMocks() + CONFIG.EMAIL = true + CONFIG.EMAIL_TEST_MODUS = true + result = await sendEMail({ + to: 'receiver@mail.org', + cc: 'support@gradido.net', + subject: 'Subject', + text: 'Text text text', + }) + }) + + it('calls sendMail of transporter with faked to', () => { + expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({ + from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, + to: CONFIG.EMAIL_TEST_RECEIVER, + cc: 'support@gradido.net', + subject: 'Subject', + text: 'Text text text', + }) + }) + }) }) diff --git a/backend/src/mailer/sendTransactionReceivedEmail.test.ts b/backend/src/mailer/sendTransactionReceivedEmail.test.ts index 9f2ba9938..ca813c033 100644 --- a/backend/src/mailer/sendTransactionReceivedEmail.test.ts +++ b/backend/src/mailer/sendTransactionReceivedEmail.test.ts @@ -26,7 +26,7 @@ describe('sendTransactionReceivedEmail', () => { it('calls sendEMail', () => { expect(sendEMail).toBeCalledWith({ to: `Peter Lustig `, - subject: 'Gradido Überweisung', + subject: 'Du hast Gradidos erhalten', text: expect.stringContaining('Hallo Peter Lustig') && expect.stringContaining('42,00 GDD') && diff --git a/backend/src/mailer/text/contributionConfirmed.ts b/backend/src/mailer/text/contributionConfirmed.ts index dc82d7615..106c3a4c5 100644 --- a/backend/src/mailer/text/contributionConfirmed.ts +++ b/backend/src/mailer/text/contributionConfirmed.ts @@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light' export const contributionConfirmed = { de: { - subject: 'Schöpfung wurde bestätigt', + subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt', text: (data: { senderFirstName: string senderLastName: string @@ -14,18 +14,17 @@ export const contributionConfirmed = { }): string => `Hallo ${data.recipientFirstName} ${data.recipientLastName}, -Dein eingereichter Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${ - data.senderFirstName - } ${data.senderLastName} bestätigt. +dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${data.senderFirstName} ${ + data.senderLastName + } bestätigt und in deinem Gradido-Konto gutgeschrieben. Betrag: ${data.contributionAmount.toFixed(2).replace('.', ',')} GDD +Link zu deinem Konto: ${data.overviewURL} + Bitte antworte nicht auf diese E-Mail! -Mit freundlichen Grüßen, -dein Gradido-Team - - -Link zu deinem Konto: ${data.overviewURL}`, +Liebe Grüße +dein Gradido-Team`, }, } diff --git a/backend/src/mailer/text/contributionMessageReceived.ts b/backend/src/mailer/text/contributionMessageReceived.ts index b0c9c4d30..301ebef22 100644 --- a/backend/src/mailer/text/contributionMessageReceived.ts +++ b/backend/src/mailer/text/contributionMessageReceived.ts @@ -1,6 +1,6 @@ export const contributionMessageReceived = { de: { - subject: 'Gradido Frage zur Schöpfung', + subject: 'Nachricht zu deinem Gemeinwohl-Beitrag', text: (data: { senderFirstName: string senderLastName: string @@ -14,15 +14,15 @@ export const contributionMessageReceived = { }): string => `Hallo ${data.recipientFirstName} ${data.recipientLastName}, -du hast soeben zu deinem eingereichten Gemeinwohl-Beitrag "${data.contributionMemo}" eine Rückfrage von ${data.senderFirstName} ${data.senderLastName} erhalten. +du hast zu deinem Gemeinwohl-Beitrag "${data.contributionMemo}" eine Nachricht von ${data.senderFirstName} ${data.senderLastName} erhalten. -Bitte beantworte die Rückfrage in deinem Gradido-Konto im Menü "Gemeinschaft" im Tab "Meine Beiträge zum Gemeinwohl"! +Um die Nachricht zu sehen und darauf zu antworten, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"! Link zu deinem Konto: ${data.overviewURL} Bitte antworte nicht auf diese E-Mail! -Mit freundlichen Grüßen, +Liebe Grüße dein Gradido-Team`, }, } diff --git a/backend/src/mailer/text/contributionRejected.ts b/backend/src/mailer/text/contributionRejected.ts new file mode 100644 index 000000000..ff52c7b5a --- /dev/null +++ b/backend/src/mailer/text/contributionRejected.ts @@ -0,0 +1,28 @@ +import Decimal from 'decimal.js-light' + +export const contributionRejected = { + de: { + subject: 'Dein Gemeinwohl-Beitrag wurde abgelehnt', + text: (data: { + senderFirstName: string + senderLastName: string + recipientFirstName: string + recipientLastName: string + contributionMemo: string + contributionAmount: Decimal + overviewURL: string + }): string => + `Hallo ${data.recipientFirstName} ${data.recipientLastName}, + +dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde von ${data.senderFirstName} ${data.senderLastName} abgelehnt. + +Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"! + +Link zu deinem Konto: ${data.overviewURL} + +Bitte antworte nicht auf diese E-Mail! + +Liebe Grüße +dein Gradido-Team`, + }, +} diff --git a/backend/src/mailer/text/transactionLinkRedeemed.ts b/backend/src/mailer/text/transactionLinkRedeemed.ts index 4d8e89cae..a63e5d275 100644 --- a/backend/src/mailer/text/transactionLinkRedeemed.ts +++ b/backend/src/mailer/text/transactionLinkRedeemed.ts @@ -14,20 +14,20 @@ export const transactionLinkRedeemed = { memo: string overviewURL: string }): string => - `Hallo ${data.recipientFirstName} ${data.recipientLastName} + `Hallo ${data.recipientFirstName} ${data.recipientLastName}, - ${data.senderFirstName} ${data.senderLastName} (${ +${data.senderFirstName} ${data.senderLastName} (${ data.senderEmail }) hat soeben deinen Link eingelöst. - - Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD, - Memo: ${data.memo} - - Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL} - - Bitte antworte nicht auf diese E-Mail! - - Mit freundlichen Grüßen, - dein Gradido-Team`, + +Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD, +Memo: ${data.memo} + +Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL} + +Bitte antworte nicht auf diese E-Mail! + +Liebe Grüße +dein Gradido-Team`, }, } diff --git a/backend/src/mailer/text/transactionReceived.ts b/backend/src/mailer/text/transactionReceived.ts index ba61ae680..67758c0e1 100644 --- a/backend/src/mailer/text/transactionReceived.ts +++ b/backend/src/mailer/text/transactionReceived.ts @@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light' export const transactionReceived = { de: { - subject: 'Gradido Überweisung', + subject: 'Du hast Gradidos erhalten', text: (data: { senderFirstName: string senderLastName: string @@ -13,9 +13,9 @@ export const transactionReceived = { amount: Decimal overviewURL: string }): string => - `Hallo ${data.recipientFirstName} ${data.recipientLastName} + `Hallo ${data.recipientFirstName} ${data.recipientLastName}, -Du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${ +du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${ data.senderLastName } (${data.senderEmail}) erhalten. @@ -23,7 +23,7 @@ Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL} Bitte antworte nicht auf diese E-Mail! -Mit freundlichen Grüßen, +Liebe Grüße dein Gradido-Team`, }, } diff --git a/backend/src/seeds/factory/contributionLink.ts b/backend/src/seeds/factory/contributionLink.ts index d8f31d585..08f784604 100644 --- a/backend/src/seeds/factory/contributionLink.ts +++ b/backend/src/seeds/factory/contributionLink.ts @@ -1,6 +1,5 @@ import { ApolloServerTestClient } from 'apollo-server-testing' -import { createContributionLink } from '@/seeds/graphql/mutations' -import { login } from '@/seeds/graphql/queries' +import { login, createContributionLink } from '@/seeds/graphql/mutations' import { ContributionLink } from '@model/ContributionLink' import { ContributionLinkInterface } from '@/seeds/contributionLink/ContributionLinkInterface' @@ -8,12 +7,12 @@ export const contributionLinkFactory = async ( client: ApolloServerTestClient, contributionLink: ContributionLinkInterface, ): Promise => { - const { mutate, query } = client + const { mutate } = client // login as admin // eslint-disable-next-line @typescript-eslint/no-unused-vars - const user = await query({ - query: login, + const user = await mutate({ + mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, }) diff --git a/backend/src/seeds/factory/creation.ts b/backend/src/seeds/factory/creation.ts index 606bac1f7..36e481519 100644 --- a/backend/src/seeds/factory/creation.ts +++ b/backend/src/seeds/factory/creation.ts @@ -2,8 +2,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import { backendLogger as logger } from '@/server/logger' -import { adminCreateContribution, confirmContribution } from '@/seeds/graphql/mutations' -import { login } from '@/seeds/graphql/queries' +import { login, adminCreateContribution, confirmContribution } from '@/seeds/graphql/mutations' import { CreationInterface } from '@/seeds/creation/CreationInterface' import { ApolloServerTestClient } from 'apollo-server-testing' import { Transaction } from '@entity/Transaction' @@ -19,9 +18,9 @@ export const creationFactory = async ( client: ApolloServerTestClient, creation: CreationInterface, ): Promise => { - const { mutate, query } = client + const { mutate } = client logger.trace('creationFactory...') - await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } }) + await mutate({ mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } }) logger.trace('creationFactory... after login') // TODO it would be nice to have this mutation return the id await mutate({ mutation: adminCreateContribution, variables: { ...creation } }) diff --git a/backend/src/seeds/factory/transactionLink.ts b/backend/src/seeds/factory/transactionLink.ts index 2f54dc70c..be5a01d22 100644 --- a/backend/src/seeds/factory/transactionLink.ts +++ b/backend/src/seeds/factory/transactionLink.ts @@ -1,6 +1,5 @@ import { ApolloServerTestClient } from 'apollo-server-testing' -import { createTransactionLink } from '@/seeds/graphql/mutations' -import { login } from '@/seeds/graphql/queries' +import { login, createTransactionLink } from '@/seeds/graphql/mutations' import { TransactionLinkInterface } from '@/seeds/transactionLink/TransactionLinkInterface' import { transactionLinkExpireDate } from '@/graphql/resolver/TransactionLinkResolver' import { TransactionLink } from '@entity/TransactionLink' @@ -9,10 +8,13 @@ export const transactionLinkFactory = async ( client: ApolloServerTestClient, transactionLink: TransactionLinkInterface, ): Promise => { - const { mutate, query } = client + const { mutate } = client // login - await query({ query: login, variables: { email: transactionLink.email, password: 'Aa12345_' } }) + await mutate({ + mutation: login, + variables: { email: transactionLink.email, password: 'Aa12345_' }, + }) const variables = { amount: transactionLink.amount, diff --git a/backend/src/seeds/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index e5f290645..e5dc90a0e 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -289,3 +289,33 @@ export const adminCreateContributionMessage = gql` } } ` + +export const redeemTransactionLink = gql` + mutation ($code: String!) { + redeemTransactionLink(code: $code) + } +` + +export const login = gql` + mutation ($email: String!, $password: String!, $publisherId: Int) { + login(email: $email, password: $password, publisherId: $publisherId) { + id + email + firstName + lastName + language + klickTipp { + newsletterState + } + hasElopage + publisherId + isAdmin + } + } +` + +export const logout = gql` + mutation { + logout + } +` diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index 60dffa21b..97f871235 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -1,23 +1,5 @@ import gql from 'graphql-tag' -export const login = gql` - query ($email: String!, $password: String!, $publisherId: Int) { - login(email: $email, password: $password, publisherId: $publisherId) { - id - email - firstName - lastName - language - klickTipp { - newsletterState - } - hasElopage - publisherId - isAdmin - } - } -` - export const verifyLogin = gql` query { verifyLogin { @@ -35,12 +17,6 @@ export const verifyLogin = gql` } ` -export const logout = gql` - query { - logout - } -` - export const queryOptIn = gql` query ($optIn: String!) { queryOptIn(optIn: $optIn) diff --git a/backend/src/typeorm/repository/User.ts b/backend/src/typeorm/repository/User.ts index 8b3e29859..c20ef85ff 100644 --- a/backend/src/typeorm/repository/User.ts +++ b/backend/src/typeorm/repository/User.ts @@ -28,8 +28,8 @@ export class UserRepository extends Repository { ): Promise<[DbUser[], number]> { const query = this.createQueryBuilder('user') .select(select) - .leftJoinAndSelect('user.emailContact', 'emailContact') .withDeleted() + .leftJoinAndSelect('user.emailContact', 'emailContact') .where( new Brackets((qb) => { qb.where( diff --git a/backend/src/util/utilities.ts b/backend/src/util/utilities.ts index 9abb31554..65214ebb5 100644 --- a/backend/src/util/utilities.ts +++ b/backend/src/util/utilities.ts @@ -1,5 +1,17 @@ +import Decimal from 'decimal.js-light' + export const objectValuesToArray = (obj: { [x: string]: string }): Array => { return Object.keys(obj).map(function (key) { return obj[key] }) } + +// to improve code readability, as String is needed, it is handled inside this utility function +export const decimalAddition = (a: Decimal, b: Decimal): Decimal => { + return a.add(b.toString()) +} + +// to improve code readability, as String is needed, it is handled inside this utility function +export const decimalSubtraction = (a: Decimal, b: Decimal): Decimal => { + return a.minus(b.toString()) +} diff --git a/backend/src/util/validate.ts b/backend/src/util/validate.ts index 8d1c90ca4..9640cc614 100644 --- a/backend/src/util/validate.ts +++ b/backend/src/util/validate.ts @@ -5,6 +5,8 @@ import { Decay } from '@model/Decay' import { getCustomRepository } from '@dbTools/typeorm' import { TransactionLinkRepository } from '@repository/TransactionLink' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' +import { decimalSubtraction, decimalAddition } from './utilities' +import { backendLogger as logger } from '@/server/logger' function isStringBoolean(value: string): boolean { const lowerValue = value.toLowerCase() @@ -23,14 +25,26 @@ async function calculateBalance( amount: Decimal, time: Date, transactionLink?: dbTransactionLink | null, -): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> { +): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number }> { + // negative or empty amount should not be allowed + if (amount.lessThanOrEqualTo(0)) { + logger.error(`Transaction amount must be greater than 0: ${amount}`) + throw new Error('Transaction amount must be greater than 0') + } + + // check if user has prior transactions const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } }) - if (!lastTransaction) return null + + if (!lastTransaction) { + logger.error(`No prior transaction found for user with id: ${userId}`) + throw new Error('User has not received any GDD yet') + } const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time) - // TODO why we have to use toString() here? - const balance = decay.balance.add(amount.toString()) + // new balance is the old balance minus the amount used + const balance = decimalSubtraction(decay.balance, amount) + const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time) @@ -38,11 +52,16 @@ async function calculateBalance( // else we cannot redeem links which are more or equal to half of what an account actually owns const releasedLinkAmount = transactionLink ? transactionLink.holdAvailableAmount : new Decimal(0) - if ( - balance.minus(sumHoldAvailableAmount.toString()).plus(releasedLinkAmount.toString()).lessThan(0) - ) { - return null + const availableBalance = decimalSubtraction(balance, sumHoldAvailableAmount) + + if (decimalAddition(availableBalance, releasedLinkAmount).lessThan(0)) { + logger.error( + `Not enough funds for a transaction of ${amount} GDD, user with id: ${userId} has only ${balance} GDD available`, + ) + throw new Error('Not enough funds for transaction') } + + logger.debug(`calculated Balance=${balance}`) return { balance, lastTransactionId: lastTransaction.id, decay } } diff --git a/backend/test/extensions.ts b/backend/test/extensions.ts new file mode 100644 index 000000000..69c2ff7a6 --- /dev/null +++ b/backend/test/extensions.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ + +import Decimal from 'decimal.js-light' + +expect.extend({ + decimalEqual(received, value) { + const pass = new Decimal(value).equals(received.toString()) + if (pass) { + return { + message: () => `expected ${received} to not equal ${value}`, + pass: true, + } + } else { + return { + message: () => `expected ${received} to equal ${value}`, + pass: false, + } + } + }, +}) + +interface CustomMatchers { + decimalEqual(value: number): R +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Expect extends CustomMatchers {} + interface Matchers extends CustomMatchers {} + interface InverseAsymmetricMatchers extends CustomMatchers {} + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index f81bf22d5..2e5a8b5b2 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -58,7 +58,7 @@ "@entity/*": ["../database/entity/*", "../../database/build/entity/*"] }, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ + "typeRoots": ["src/federation/@types", "node_modules/@types"], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ diff --git a/backend/yarn.lock b/backend/yarn.lock index fa50a6ea7..940906cfa 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -430,6 +430,42 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf" integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w== +"@hyperswarm/dht@^6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@hyperswarm/dht/-/dht-6.2.0.tgz#b2cb1218752b52fabb66f304e73448a108d1effd" + integrity sha512-AeyfRdAkfCz/J3vTC4rdpzEpT7xQ+tls87Zpzw9Py3VGUZD8hMT7pr43OOdkCBNvcln6K/5/Lxhnq5lBkzH3yw== + dependencies: + "@hyperswarm/secret-stream" "^6.0.0" + b4a "^1.3.1" + bogon "^1.0.0" + compact-encoding "^2.4.1" + compact-encoding-net "^1.0.1" + debugging-stream "^2.0.0" + dht-rpc "^6.0.0" + events "^3.3.0" + hypercore-crypto "^3.3.0" + noise-curve-ed "^1.0.2" + noise-handshake "^2.1.0" + record-cache "^1.1.1" + safety-catch "^1.0.1" + sodium-universal "^3.0.4" + udx-native "^1.1.0" + xache "^1.1.0" + +"@hyperswarm/secret-stream@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@hyperswarm/secret-stream/-/secret-stream-6.0.0.tgz#67db820308cc9fed899cb8f5e9f47ae819d5a4e3" + integrity sha512-0xuyJIJDe8JYk4uWUx25qJvWqybdjKU2ZIfP1GTqd7dQxwdR0bpYrQKdLkrn5txWSK4a28ySC2AjH0G3I0gXTA== + dependencies: + b4a "^1.1.0" + hypercore-crypto "^3.3.0" + noise-curve-ed "^1.0.2" + noise-handshake "^2.1.0" + sodium-secretstream "^1.0.0" + sodium-universal "^3.0.4" + streamx "^2.10.2" + timeout-refresh "^2.0.0" + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1614,6 +1650,11 @@ axios@^0.21.1: dependencies: follow-redirects "^1.14.0" +b4a@^1.0.1, b4a@^1.1.0, b4a@^1.1.1, b4a@^1.3.0, b4a@^1.3.1, b4a@^1.5.0: + version "1.5.3" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.5.3.tgz#56293b5607aeda3fd81c481e516e9f103fc88341" + integrity sha512-1aCQIzQJK7G0z1Una75tWMlwVAR8o+QHoAlnWc5XAxRVBESY9WsitfBgM5nPyDBP5HrhPU1Np4Pq2Y7CJQ+tVw== + babel-jest@^27.2.5: version "27.2.5" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-27.2.5.tgz#6bbbc1bb4200fe0bfd1b1fbcbe02fc62ebed16aa" @@ -1697,6 +1738,22 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +blake2b-wasm@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/blake2b-wasm/-/blake2b-wasm-2.4.0.tgz#9115649111edbbd87eb24ce7c04b427e4e2be5be" + integrity sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w== + dependencies: + b4a "^1.0.1" + nanoassert "^2.0.0" + +blake2b@^2.1.1: + version "2.1.4" + resolved "https://registry.yarnpkg.com/blake2b/-/blake2b-2.1.4.tgz#817d278526ddb4cd673bfb1af16d1ad61e393ba3" + integrity sha512-AyBuuJNI64gIvwx13qiICz6H6hpmjvYS5DGkG6jbXMOT8Z3WUJ3V1X0FlhIoT1b/5JtHE3ki+xjtMvu1nn+t9A== + dependencies: + blake2b-wasm "^2.4.0" + nanoassert "^2.0.0" + bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -1718,6 +1775,11 @@ body-parser@1.19.0, body-parser@^1.18.3: raw-body "2.4.0" type-is "~1.6.17" +bogon@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/bogon/-/bogon-1.0.0.tgz#66b8cdd269f790e3aa988e157bb34d4ba75ee586" + integrity sha512-mXxtlBtnW8koqFWPUBtKJm97vBSKZRpOvxvMRVun33qQXwMNfQzq9eTcQzKzqEoNUhNqF9t8rDc/wakKCcHMTg== + boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -1846,9 +1908,16 @@ camelcase@^6.2.0: integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== caniuse-lite@^1.0.30001264: - version "1.0.30001325" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001325.tgz" - integrity sha512-sB1bZHjseSjDtijV1Hb7PB2Zd58Kyx+n/9EotvZ4Qcz2K3d0lWB8dB4nb8wN/TsOGFq3UuAm0zQZNQ4SoR7TrQ== + version "1.0.30001418" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001418.tgz" + integrity sha512-oIs7+JL3K9JRQ3jPZjlH6qyYDp+nBTCais7hjh0s+fuBwufc7uZ7hPYMXrDOJhV360KGMTcczMRObk0/iMqZRg== + +chacha20-universal@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/chacha20-universal/-/chacha20-universal-1.0.4.tgz#e8a33a386500b1ce5361b811ec5e81f1797883f5" + integrity sha512-/IOxdWWNa7nRabfe7+oF+jVkGjlr2xUL4J8l/OvzZhj+c9RpMqoo3Dq+5nU1j/BflRV4BKnaQ4+4oH1yBpQG1Q== + dependencies: + nanoassert "^2.0.0" chalk@^2.0.0: version "2.4.2" @@ -2019,6 +2088,20 @@ commander@^6.1.0: resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== +compact-encoding-net@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/compact-encoding-net/-/compact-encoding-net-1.0.1.tgz#4da743d52721f5d0cc73a6d00556a96bc9b9fa1b" + integrity sha512-N9k1Qwg9b1ENk+TZsZhthzkuMtn3rn4ZinN75gf3/LplE+uaTCKjyaau5sK0m2NEUa/MmR77VxiGfD/Qz1ar0g== + dependencies: + compact-encoding "^2.4.1" + +compact-encoding@^2.1.0, compact-encoding@^2.4.1, compact-encoding@^2.5.1: + version "2.7.0" + resolved "https://registry.yarnpkg.com/compact-encoding/-/compact-encoding-2.7.0.tgz#e6a0df408c25cbcdf7d619c97527074478cafd06" + integrity sha512-2I0A+pYKXYwxewbLxj26tU4pJyKlFNjadzjZ+36xJ5HwTrnhD9KcMQk3McEQRl1at6jrwA8E7UjmBdsGhEAPMw== + dependencies: + b4a "^1.3.0" + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -2217,6 +2300,13 @@ debug@^4.3.3, debug@^4.3.4: dependencies: ms "2.1.2" +debugging-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/debugging-stream/-/debugging-stream-2.0.0.tgz#515cad5a35299cf4b4bc0afcbd69d52c809c84ce" + integrity sha512-xwfl6wB/3xc553uwtGnSa94jFxnGOc02C0WU2Nmzwr80gzeqn1FX4VcbvoKIhe8L/lPq4BTQttAbrTN94uN8rA== + dependencies: + streamx "^2.12.4" + decimal.js-light@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" @@ -2296,6 +2386,23 @@ detect-newline@^3.0.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== +dht-rpc@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/dht-rpc/-/dht-rpc-6.1.1.tgz#a292a22aa19b05136978d33528cb571d6e32502f" + integrity sha512-wo0nMXwn/rhxVz62V0d+l/0HuikxLQh6lkwlUIdoaUzGl9DobFj4epSScD3/lTMwKts+Ih0DFNqP+j0tYwdajQ== + dependencies: + b4a "^1.3.1" + compact-encoding "^2.1.0" + compact-encoding-net "^1.0.1" + events "^3.3.0" + fast-fifo "^1.0.0" + kademlia-routing-table "^1.0.0" + nat-sampler "^1.0.1" + sodium-universal "^3.0.4" + streamx "^2.10.3" + time-ordered-set "^1.0.2" + udx-native "^1.1.0" + dicer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.3.0.tgz#eacd98b3bfbf92e8ab5c2fdb71aaac44bb06b872" @@ -2787,6 +2894,11 @@ eventemitter3@^3.1.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== +events@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + execa@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/execa/-/execa-0.10.0.tgz#ff456a8f53f90f8eccc71a96d11bdfc7f082cb50" @@ -2883,6 +2995,11 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== +fast-fifo@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.1.0.tgz#17d1a3646880b9891dfa0c54e69c5fef33cad779" + integrity sha512-Kl29QoNbNvn4nhDsLYjyIAaIqaJB6rBx5p3sL9VjaefJ+eMFBWVZiaoguaoZfzEKr5RhAti0UgM8703akGPJ6g== + fast-glob@^3.1.1: version "3.2.7" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1" @@ -3292,6 +3409,15 @@ he@1.2.0, he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +hmac-blake2b@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/hmac-blake2b/-/hmac-blake2b-2.0.0.tgz#09494e5d245d7afe45d157093080b159f7bacf15" + integrity sha512-JbGNtM1YRd8EQH/2vNTAP1oy5lJVPlBFYZfCJTu3k8sqOUm0rRIf/3+MCd5noVykETwTbun6jEOc+4Tu78ubHA== + dependencies: + nanoassert "^1.1.0" + sodium-native "^3.1.1" + sodium-universal "^3.0.0" + hosted-git-info@^2.1.4: version "2.8.9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" @@ -3413,6 +3539,15 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +hypercore-crypto@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/hypercore-crypto/-/hypercore-crypto-3.3.0.tgz#03ab5b44608a563e131f629f671c6f90a83c52e6" + integrity sha512-zAWbDqG7kWwS6rCxxTUeB/OeFAz3PoOmouKaoMubtDJYJsLHqXtA3wE2mLsw+E2+iYyom5zrFyBTFVYxmgwW6g== + dependencies: + b4a "^1.1.0" + compact-encoding "^2.5.1" + sodium-universal "^3.0.0" + i18n-locales@^0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/i18n-locales/-/i18n-locales-0.0.5.tgz#8f587e598ab982511d7c7db910cb45b8d93cd96a" @@ -3595,9 +3730,9 @@ is-core-module@^2.2.0, is-core-module@^2.6.0: has "^1.0.3" is-core-module@^2.9.0: - version "2.10.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" - integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== + version "2.9.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" + integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== dependencies: has "^1.0.3" @@ -4377,6 +4512,11 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +kademlia-routing-table@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/kademlia-routing-table/-/kademlia-routing-table-1.0.1.tgz#6f18416f612e885a8d4df128f04c490a90d772f6" + integrity sha512-dKk19sC3/+kWhBIvOKCthxVV+JH0NrswSBq4sA4eOkkPMqQM1rRuOWte1WSKXeP8r9Nx4NuiH2gny3lMddJTpw== + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -4787,6 +4927,26 @@ named-placeholders@^1.1.2: dependencies: lru-cache "^4.1.3" +nanoassert@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/nanoassert/-/nanoassert-1.1.0.tgz#4f3152e09540fde28c76f44b19bbcd1d5a42478d" + integrity sha512-C40jQ3NzfkP53NsO8kEOFd79p4b9kDXQMwgiY1z8ZwrDZgUyom0AHwGegF4Dm99L+YoYhuaB0ceerUcXmqr1rQ== + +nanoassert@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/nanoassert/-/nanoassert-2.0.0.tgz#a05f86de6c7a51618038a620f88878ed1e490c09" + integrity sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA== + +napi-macros@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/napi-macros/-/napi-macros-2.0.0.tgz#2b6bae421e7b96eb687aa6c77a7858640670001b" + integrity sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg== + +nat-sampler@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/nat-sampler/-/nat-sampler-1.0.1.tgz#2b68338ea6d4c139450cd971fd00a4ac1b33d923" + integrity sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -4826,10 +4986,10 @@ node-fetch@^2.6.1: dependencies: whatwg-url "^5.0.0" -node-gyp-build@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" - integrity sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q== +node-gyp-build@^4.3.0, node-gyp-build@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.5.0.tgz#7a64eefa0b21112f89f58379da128ac177f20e40" + integrity sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg== node-int64@^0.4.0: version "0.4.0" @@ -4877,6 +5037,25 @@ nodemon@^2.0.7: undefsafe "^2.0.3" update-notifier "^5.1.0" +noise-curve-ed@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/noise-curve-ed/-/noise-curve-ed-1.0.4.tgz#8ae83f5d2d2e31d0c9c069271ca6e462d31cd884" + integrity sha512-plUUSEOU66FZ9TaBKpk4+fgQeeS+OLlThS2o8a1TxVpMWV2v1izvEnjSpFV9gEPZl4/1yN+S5KqLubFjogqQOw== + dependencies: + b4a "^1.1.0" + nanoassert "^2.0.0" + sodium-universal "^3.0.4" + +noise-handshake@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/noise-handshake/-/noise-handshake-2.2.0.tgz#24c98f502d49118770e1ec2af2894b8789f0ac7c" + integrity sha512-+0mFUc5YSnOPI+4K/7nr6XDGduITaUasPVurzrH03sk6yW+udKxP/qjEwEekRwIpnvcCKYnjiZ9HJenJv9ljZg== + dependencies: + b4a "^1.1.0" + hmac-blake2b "^2.0.0" + nanoassert "^2.0.0" + sodium-universal "^3.0.4" + nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" @@ -5482,6 +5661,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue-tick@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.0.tgz#011104793a3309ae86bfeddd54e251dc94a36725" + integrity sha512-ULWhjjE8BmiICGn3G8+1L9wFpERNxkf8ysxkAer4+TFdRefDaXOCV5m92aMB9FtBVmn/8sETXLXY6BfW7hyaWQ== + railroad-diagrams@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e" @@ -5554,6 +5738,13 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +record-cache@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/record-cache/-/record-cache-1.2.0.tgz#e601bc4f164d58330cc00055e27aa4682291c882" + integrity sha512-kyy3HWCez2WrotaL3O4fTn0rsIdfRKOdQQcEJ9KpvmKmbffKVvwsloX063EgRUlpJIXHiDQFhJcTbZequ2uTZw== + dependencies: + b4a "^1.3.1" + reflect-metadata@^0.1.13: version "0.1.13" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" @@ -5613,7 +5804,7 @@ resolve@^1.10.0, resolve@^1.10.1, resolve@^1.20.0: is-core-module "^2.2.0" path-parse "^1.0.6" -resolve@^1.15.1: +resolve@^1.15.1, resolve@^1.17.0: version "1.22.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== @@ -5690,6 +5881,11 @@ safe-identifier@^0.4.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +safety-catch@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/safety-catch/-/safety-catch-1.0.2.tgz#d64cbd57fd601da91c356b6ab8902f3e449a7a4b" + integrity sha512-C1UYVZ4dtbBxEtvOcpjBaaD27nP8MlvyAQEp2fOTOEe6pfUpk1cDUxij6BR1jZup6rSyUTaBBplK7LanskrULA== + saxes@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/saxes/-/saxes-5.0.1.tgz#eebab953fa3b7608dbe94e5dadb15c888fa6696d" @@ -5780,6 +5976,38 @@ sha.js@^2.4.11: inherits "^2.0.1" safe-buffer "^5.0.1" +sha256-universal@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sha256-universal/-/sha256-universal-1.2.1.tgz#051d92decce280cd6137d42d496eac88da942c0e" + integrity sha512-ghn3muhdn1ailCQqqceNxRgkOeZSVfSE13RQWEg6njB+itsFzGVSJv+O//2hvNXZuxVIRyNzrgsZ37SPDdGJJw== + dependencies: + b4a "^1.0.1" + sha256-wasm "^2.2.1" + +sha256-wasm@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/sha256-wasm/-/sha256-wasm-2.2.2.tgz#4940b6c9ba28f3f08b700efce587ef36d4d516d4" + integrity sha512-qKSGARvao+JQlFiA+sjJZhJ/61gmW/3aNLblB2rsgIxDlDxsJPHo8a1seXj12oKtuHVgJSJJ7QEGBUYQN741lQ== + dependencies: + b4a "^1.0.1" + nanoassert "^2.0.0" + +sha512-universal@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/sha512-universal/-/sha512-universal-1.2.1.tgz#829505a7586530515cc1a10b78815c99722c4df0" + integrity sha512-kehYuigMoRkIngCv7rhgruLJNNHDnitGTBdkcYbCbooL8Cidj/bS78MDxByIjcc69M915WxcQTgZetZ1JbeQTQ== + dependencies: + b4a "^1.0.1" + sha512-wasm "^2.3.1" + +sha512-wasm@^2.3.1: + version "2.3.4" + resolved "https://registry.yarnpkg.com/sha512-wasm/-/sha512-wasm-2.3.4.tgz#b86b37112ff6d1fc3740f2484a6855f17a6e1300" + integrity sha512-akWoxJPGCB3aZCrZ+fm6VIFhJ/p8idBv7AWGFng/CZIrQo51oQNsvDbTSRXWAzIiZJvpy16oIDiCCPqTe21sKg== + dependencies: + b4a "^1.0.1" + nanoassert "^2.0.0" + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" @@ -5823,6 +6051,13 @@ signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f" integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ== +siphash24@^1.0.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/siphash24/-/siphash24-1.3.1.tgz#7f87fd2c5db88d8d46335a68f780f281641c8b22" + integrity sha512-moemC3ZKiTzH29nbFo3Iw8fbemWWod4vNs/WgKbQ54oEs6mE6XVlguxvinYjB+UmaE0PThgyED9fUkWvirT8hA== + dependencies: + nanoassert "^2.0.0" + sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" @@ -5847,13 +6082,50 @@ slick@^1.12.2: resolved "https://registry.yarnpkg.com/slick/-/slick-1.12.2.tgz#bd048ddb74de7d1ca6915faa4a57570b3550c2d7" integrity sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A== -sodium-native@^3.3.0: +sodium-javascript@~0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/sodium-javascript/-/sodium-javascript-0.8.0.tgz#0a94d7bb58ab17be82255f3949259af59778fdbc" + integrity sha512-rEBzR5mPxPES+UjyMDvKPIXy9ImF17KOJ32nJNi9uIquWpS/nfj+h6m05J5yLJaGXjgM72LmQoUbWZVxh/rmGg== + dependencies: + blake2b "^2.1.1" + chacha20-universal "^1.0.4" + nanoassert "^2.0.0" + sha256-universal "^1.1.0" + sha512-universal "^1.1.0" + siphash24 "^1.0.1" + xsalsa20 "^1.0.0" + +sodium-native@^3.1.1, sodium-native@^3.2.0, sodium-native@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/sodium-native/-/sodium-native-3.3.0.tgz#50ee52ac843315866cce3d0c08ab03eb78f22361" integrity sha512-rg6lCDM/qa3p07YGqaVD+ciAbUqm6SoO4xmlcfkbU5r1zIGrguXztLiEtaLYTV5U6k8KSIUFmnU3yQUSKmf6DA== dependencies: node-gyp-build "^4.3.0" +sodium-secretstream@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/sodium-secretstream/-/sodium-secretstream-1.0.2.tgz#ae6fec16555f1a1d9fd2460b41256736d5044e13" + integrity sha512-AsWztbBHhHid+w5g28ftXA0mTrS52Dup7FYI0GR7ri1TQTlVsw0z//FNlhIqWsgtBctO/DxQosacbElCpmdcZw== + dependencies: + b4a "^1.1.1" + sodium-universal "^3.0.4" + +sodium-universal@^3.0.0, sodium-universal@^3.0.4: + version "3.1.0" + resolved "https://registry.yarnpkg.com/sodium-universal/-/sodium-universal-3.1.0.tgz#f2fa0384d16b7cb99b1c8551a39cc05391a3ed41" + integrity sha512-N2gxk68Kg2qZLSJ4h0NffEhp4BjgWHCHXVlDi1aG1hA3y+ZeWEmHqnpml8Hy47QzfL1xLy5nwr9LcsWAg2Ep0A== + dependencies: + blake2b "^2.1.1" + chacha20-universal "^1.0.4" + nanoassert "^2.0.0" + resolve "^1.17.0" + sha256-universal "^1.1.0" + sha512-universal "^1.1.0" + siphash24 "^1.0.1" + sodium-javascript "~0.8.0" + sodium-native "^3.2.0" + xsalsa20 "^1.0.0" + source-map-support@^0.5.6: version "0.5.20" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.20.tgz#12166089f8f5e5e8c56926b377633392dd2cb6c9" @@ -5939,6 +6211,14 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +streamx@^2.10.2, streamx@^2.10.3, streamx@^2.12.0, streamx@^2.12.4: + version "2.12.4" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.12.4.tgz#0369848b20b8f79c65320735372df17cafcd9aff" + integrity sha512-K3xdIp8YSkvbdI0PrCcP0JkniN8cPCyeKlcZgRFSl1o1xKINCYM93FryvTSOY57x73pz5/AjO5B8b9BYf21wWw== + dependencies: + fast-fifo "^1.0.0" + queue-tick "^1.0.0" + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -6103,6 +6383,16 @@ throat@^6.0.1: resolved "https://registry.yarnpkg.com/throat/-/throat-6.0.1.tgz#d514fedad95740c12c2d7fc70ea863eb51ade375" integrity sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w== +time-ordered-set@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/time-ordered-set/-/time-ordered-set-1.0.2.tgz#3bd931fc048234147f8c2b8b1ebbebb0a3ecb96f" + integrity sha512-vGO99JkxvgX+u+LtOKQEpYf31Kj3i/GNwVstfnh4dyINakMgeZCpew1e3Aj+06hEslhtHEd52g7m5IV+o1K8Mw== + +timeout-refresh@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/timeout-refresh/-/timeout-refresh-2.0.1.tgz#f8ec7cf1f9d93b2635b7d4388cb820c5f6c16f98" + integrity sha512-SVqEcMZBsZF9mA78rjzCrYrUs37LMJk3ShZ851ygZYW1cMeIjs9mL57KO6Iv5mmjSQnOe/29/VAfGXo+oRCiVw== + titleize@2: version "2.1.0" resolved "https://registry.yarnpkg.com/titleize/-/titleize-2.1.0.tgz#5530de07c22147a0488887172b5bd94f5b30a48f" @@ -6327,6 +6617,16 @@ uc.micro@^1.0.1: resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== +udx-native@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/udx-native/-/udx-native-1.2.1.tgz#a229b8bfab8c9c9eea05c7e0d68e671ab70d562d" + integrity sha512-hLoJ3rE1PuqO/A1YENG8oYNuAGltdwXofzavYwXbg2yk/qQgGBDpUQd/qtdENxkawad5cEEdJEdwvchslDl7OA== + dependencies: + b4a "^1.5.0" + napi-macros "^2.0.0" + node-gyp-build "^4.4.0" + streamx "^2.12.0" + unbox-primitive@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471" @@ -6631,6 +6931,11 @@ write-file-atomic@^3.0.0: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== +xache@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/xache/-/xache-1.1.0.tgz#afc20dec9ff8b2260eea03f5ad9422dc0200c6e9" + integrity sha512-RQGZDHLy/uCvnIrAvaorZH/e6Dfrtxj16iVlGjkj4KD2/G/dNXNqhk5IdSucv5nSSnDK00y8Y/2csyRdHveJ+Q== + xdg-basedir@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" @@ -6646,6 +6951,11 @@ xmlchars@^2.2.0: resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== +xsalsa20@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/xsalsa20/-/xsalsa20-1.2.0.tgz#e5a05cb26f8cef723f94a559102ed50c1b44c25c" + integrity sha512-FIr/DEeoHfj7ftfylnoFt3rAIRoWXpx2AoDfrT2qD2wtp7Dp+COajvs/Icb7uHqRW9m60f5iXZwdsJJO3kvb7w== + xss@^1.0.8: version "1.0.10" resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.10.tgz#5cd63a9b147a755a14cb0455c7db8866120eb4d2" diff --git a/database/Dockerfile b/database/Dockerfile index 8ffe8e432..4069ffcd8 100644 --- a/database/Dockerfile +++ b/database/Dockerfile @@ -18,7 +18,7 @@ ENV NODE_ENV="production" # Labels LABEL org.label-schema.build-date="${BUILD_DATE}" LABEL org.label-schema.name="gradido:database" -LABEL org.label-schema.description="Gradido GraphQL Backend" +LABEL org.label-schema.description="Gradido Database Migration Service" LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md" LABEL org.label-schema.url="https://gradido.net" LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/database" diff --git a/database/entity/0050-add_messageId_to_event_protocol/EventProtocol.ts b/database/entity/0050-add_messageId_to_event_protocol/EventProtocol.ts new file mode 100644 index 000000000..d4dbc526f --- /dev/null +++ b/database/entity/0050-add_messageId_to_event_protocol/EventProtocol.ts @@ -0,0 +1,42 @@ +import Decimal from 'decimal.js-light' +import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm' +import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer' + +@Entity('event_protocol') +export class EventProtocol extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ length: 100, nullable: false, collation: 'utf8mb4_unicode_ci' }) + type: string + + @Column({ name: 'created_at', type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date + + @Column({ name: 'user_id', unsigned: true, nullable: false }) + userId: number + + @Column({ name: 'x_user_id', unsigned: true, nullable: true }) + xUserId: number + + @Column({ name: 'x_community_id', unsigned: true, nullable: true }) + xCommunityId: number + + @Column({ name: 'transaction_id', unsigned: true, nullable: true }) + transactionId: number + + @Column({ name: 'contribution_id', unsigned: true, nullable: true }) + contributionId: number + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: true, + transformer: DecimalTransformer, + }) + amount: Decimal + + @Column({ name: 'message_id', unsigned: true, nullable: true }) + messageId: number +} diff --git a/database/entity/0051-add_delete_by_to_contributions/Contribution.ts b/database/entity/0051-add_delete_by_to_contributions/Contribution.ts new file mode 100644 index 000000000..32c6f32a3 --- /dev/null +++ b/database/entity/0051-add_delete_by_to_contributions/Contribution.ts @@ -0,0 +1,92 @@ +import Decimal from 'decimal.js-light' +import { + BaseEntity, + Column, + Entity, + PrimaryGeneratedColumn, + DeleteDateColumn, + JoinColumn, + ManyToOne, + OneToMany, +} from 'typeorm' +import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer' +import { User } from '../User' +import { ContributionMessage } from '../ContributionMessage' + +@Entity('contributions') +export class Contribution extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ unsigned: true, nullable: false, name: 'user_id' }) + userId: number + + @ManyToOne(() => User, (user) => user.contributions) + @JoinColumn({ name: 'user_id' }) + user: User + + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) + createdAt: Date + + @Column({ type: 'datetime', nullable: false, name: 'contribution_date' }) + contributionDate: Date + + @Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) + memo: string + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + amount: Decimal + + @Column({ unsigned: true, nullable: true, name: 'moderator_id' }) + moderatorId: number + + @Column({ unsigned: true, nullable: true, name: 'contribution_link_id' }) + contributionLinkId: number + + @Column({ unsigned: true, nullable: true, name: 'confirmed_by' }) + confirmedBy: number + + @Column({ nullable: true, name: 'confirmed_at' }) + confirmedAt: Date + + @Column({ unsigned: true, nullable: true, name: 'denied_by' }) + deniedBy: number + + @Column({ nullable: true, name: 'denied_at' }) + deniedAt: Date + + @Column({ + name: 'contribution_type', + length: 12, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + contributionType: string + + @Column({ + name: 'contribution_status', + length: 12, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + contributionStatus: string + + @Column({ unsigned: true, nullable: true, name: 'transaction_id' }) + transactionId: number + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date | null + + @DeleteDateColumn({ unsigned: true, nullable: true, name: 'deleted_by' }) + deletedBy: number + + @OneToMany(() => ContributionMessage, (message) => message.contribution) + @JoinColumn({ name: 'contribution_id' }) + messages?: ContributionMessage[] +} diff --git a/database/entity/0052-add_updated_at_to_contributions/Contribution.ts b/database/entity/0052-add_updated_at_to_contributions/Contribution.ts new file mode 100644 index 000000000..2242a753f --- /dev/null +++ b/database/entity/0052-add_updated_at_to_contributions/Contribution.ts @@ -0,0 +1,95 @@ +import Decimal from 'decimal.js-light' +import { + BaseEntity, + Column, + Entity, + PrimaryGeneratedColumn, + DeleteDateColumn, + JoinColumn, + ManyToOne, + OneToMany, +} from 'typeorm' +import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer' +import { User } from '../User' +import { ContributionMessage } from '../ContributionMessage' + +@Entity('contributions') +export class Contribution extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ unsigned: true, nullable: false, name: 'user_id' }) + userId: number + + @ManyToOne(() => User, (user) => user.contributions) + @JoinColumn({ name: 'user_id' }) + user: User + + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' }) + createdAt: Date + + @Column({ type: 'datetime', nullable: false, name: 'contribution_date' }) + contributionDate: Date + + @Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) + memo: string + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + amount: Decimal + + @Column({ unsigned: true, nullable: true, name: 'moderator_id' }) + moderatorId: number + + @Column({ unsigned: true, nullable: true, name: 'contribution_link_id' }) + contributionLinkId: number + + @Column({ unsigned: true, nullable: true, name: 'confirmed_by' }) + confirmedBy: number + + @Column({ nullable: true, name: 'confirmed_at' }) + confirmedAt: Date + + @Column({ unsigned: true, nullable: true, name: 'denied_by' }) + deniedBy: number + + @Column({ nullable: true, name: 'denied_at' }) + deniedAt: Date + + @Column({ + name: 'contribution_type', + length: 12, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + contributionType: string + + @Column({ + name: 'contribution_status', + length: 12, + nullable: false, + collation: 'utf8mb4_unicode_ci', + }) + contributionStatus: string + + @Column({ unsigned: true, nullable: true, name: 'transaction_id' }) + transactionId: number + + @Column({ nullable: true, name: 'updated_at' }) + updatedAt: Date + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt: Date | null + + @DeleteDateColumn({ unsigned: true, nullable: true, name: 'deleted_by' }) + deletedBy: number + + @OneToMany(() => ContributionMessage, (message) => message.contribution) + @JoinColumn({ name: 'contribution_id' }) + messages?: ContributionMessage[] +} diff --git a/database/entity/Contribution.ts b/database/entity/Contribution.ts index f6530f00b..0441e7a1f 100644 --- a/database/entity/Contribution.ts +++ b/database/entity/Contribution.ts @@ -1 +1 @@ -export { Contribution } from './0047-messages_tables/Contribution' +export { Contribution } from './0052-add_updated_at_to_contributions/Contribution' diff --git a/database/entity/EventProtocol.ts b/database/entity/EventProtocol.ts index 4a73f146c..6ebbd3d68 100644 --- a/database/entity/EventProtocol.ts +++ b/database/entity/EventProtocol.ts @@ -1 +1 @@ -export { EventProtocol } from './0043-add_event_protocol_table/EventProtocol' +export { EventProtocol } from './0050-add_messageId_to_event_protocol/EventProtocol' diff --git a/database/migrations/0050-add_messageId_to_event_protocol.ts b/database/migrations/0050-add_messageId_to_event_protocol.ts new file mode 100644 index 000000000..ccef98688 --- /dev/null +++ b/database/migrations/0050-add_messageId_to_event_protocol.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + `ALTER TABLE \`event_protocol\` ADD COLUMN \`message_id\` int(10) unsigned NULL DEFAULT NULL;`, + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(`ALTER TABLE \`event_protocol\` DROP COLUMN \`message_id\`;`) +} diff --git a/database/migrations/0051-add_delete_by_to_contributions.ts b/database/migrations/0051-add_delete_by_to_contributions.ts new file mode 100644 index 000000000..21d0eda97 --- /dev/null +++ b/database/migrations/0051-add_delete_by_to_contributions.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + `ALTER TABLE \`contributions\` ADD COLUMN \`deleted_by\` int(10) unsigned DEFAULT NULL;`, + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`deleted_by\`;`) +} diff --git a/database/migrations/0052-add_updated_at_to_contributions.ts b/database/migrations/0052-add_updated_at_to_contributions.ts new file mode 100644 index 000000000..e7cc5b7b4 --- /dev/null +++ b/database/migrations/0052-add_updated_at_to_contributions.ts @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + `ALTER TABLE \`contributions\` ADD COLUMN \`updated_at\` datetime DEFAULT NULL AFTER \`transaction_id\`;`, + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`updated_at\`;`) +} diff --git a/database/package.json b/database/package.json index cabb1b47a..096c7a9bd 100644 --- a/database/package.json +++ b/database/package.json @@ -1,6 +1,6 @@ { "name": "gradido-database", - "version": "1.12.1", + "version": "1.13.3", "description": "Gradido Database Tool to execute database migrations", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/database", diff --git a/deployment/bare_metal/.env.dist b/deployment/bare_metal/.env.dist index b090908e1..1d0e96455 100644 --- a/deployment/bare_metal/.env.dist +++ b/deployment/bare_metal/.env.dist @@ -26,7 +26,7 @@ COMMUNITY_REDEEM_CONTRIBUTION_URL=https://stage1.gradido.net/redeem/CL-{code} COMMUNITY_DESCRIPTION="Gradido Development Stage1 Test Community" # backend -BACKEND_CONFIG_VERSION=v10.2022-09-20 +BACKEND_CONFIG_VERSION=v11.2022-10-27 JWT_EXPIRES_IN=10m GDT_API_URL=https://gdt.gradido.net @@ -59,6 +59,10 @@ WEBHOOK_ELOPAGE_SECRET=secret # EventProtocol EVENT_PROTOCOL_DISABLED=false +## DHT +## if you set this value, the DHT hyperswarm will start to announce and listen +## on an hash created from this tpoic +# DHT_TOPIC=GRADIDO_HUB # database DATABASE_CONFIG_VERSION=v1.2022-03-18 @@ -86,4 +90,4 @@ SUPPORT_MAIL=support@supportmail.com ADMIN_CONFIG_VERSION=v1.2022-03-18 WALLET_AUTH_URL=https://stage1.gradido.net/authenticate?token={token} -WALLET_URL=https://stage1.gradido.net/login \ No newline at end of file +WALLET_URL=https://stage1.gradido.net/login diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 79ee46906..790bd468d 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,6 +1,25 @@ version: "3.4" services: + ######################################################## + # FRONTEND ############################################# + ######################################################## + frontend: + image: gradido/frontend:test + build: + target: test + environment: + - NODE_ENV="test" + + ######################################################## + # ADMIN INTERFACE ###################################### + ######################################################## + admin: + image: gradido/admin:test + build: + target: test + environment: + - NODE_ENV="test" ######################################################## # BACKEND ############################################## @@ -21,15 +40,19 @@ services: # DATABASE ############################################# ######################################################## database: + image: gradido/database:test_up build: context: ./database target: test_up + environment: + - NODE_ENV="test" # restart: always # this is very dangerous, but worth a test for the delayed mariadb startup at first run ######################################################### ## MARIADB ############################################## ######################################################### mariadb: + image: gradido/mariadb:test networks: - internal-net - external-net @@ -51,6 +74,12 @@ services: - external-net volumes: - /sessions + + ######################################################### + ## NGINX ################################################ + ######################################################### + nginx: + image: gradido/nginx:test networks: external-net: diff --git a/docu/Concepts/BusinessRequirements/UC_Manuel_User_Registration.md b/docu/Concepts/BusinessRequirements/UC_Manuel_User_Registration.md new file mode 100644 index 000000000..97dae661f --- /dev/null +++ b/docu/Concepts/BusinessRequirements/UC_Manuel_User_Registration.md @@ -0,0 +1,275 @@ +# Manuelle User-Registrierung + +## Motivation + +Bei einer Veranstaltung o.ä. sollen neue Mitglieder geworben werden. Dabei ist ungewiss, ob sie ein Endgerät dabei haben bzw. dieses korrekt bedienen können (QR-Code, E-Mail-Zugang etc.). Es soll nun ohne Einsatz zusätzlicher Technologien eine schnelle und unkomplizierte Möglichkeit geschaffen werden, dass ein Moderator im Admin-Interface zusätzliche Funktionen zur Unterstützung des User-Registrierungsprozesses erhält: + +1. manuelle Aktivierung eines User-Accounts ohne Email-Bestätigung und setzen eines (vorläufigen) Passworts +2. vollständige User-Registrierung mit Daten-Erfassung, Account-Aktivierung und setzen eines (vorläufigen) Passworts + +## 1. Unterstützung einer User-Registrierung + +Ein neuer User hat schon selbständig mit seiner Registrierung bei Gradido begonnen, aber in dem Moment keinen Zugriff auf seine Emails. Somit kann er seine erhaltene Bestätigungs-Email mit dem Link zur Konto-Aktivierung nicht abrufen und die Registrierung nicht abschließen. + +Für diesen Fall wird im Admin-Interface eine neue Funktionalität zur "manuellen Aktivierung eines User-Accounts" bereitgestellt. Diese "manuelle Aktivierung" durch den Admin soll den neuen User kurzfristig ermächtigen auf sein Konto zugreifen zu können. Das heißt, dieser Admin-Prozess muss + +* das Konto des neuen Users als aktiviert kennzeichnen +* für den User ein (vorläufiges) Passwort generieren +* die Kennzeichnung beibehalten, dass die Email-Adresse des Users noch nicht bestätigt ist +* dem User ein Login-Prozess ermöglichen, in dem der User das (vorläufige) Passwort verwenden kann +* den User nach dem erfolgreichen Login mit dem (vorläufigen) Passwort direkt zur Eingabe eines eigenen Passwortes bringen + +### 1.1 Starten des Registrierungsunterstützungs-Prozesses + +#### Vorbedingungen + +Nach dem der neue User für sich schon die Erfassung seiner persönlichen Daten im Registrierungsdialog durchgeführt und gespeichert hat, schickt die Anwendung dem User eine Confirmation-Email an seine angegebene Email-Adresse. Der User kommt aber aktuell nicht an seine Emails bzw. benötigt Unterstützung, wie er jetzt weiter machen soll, um sich anzumelden. Mit diesem Bedarf nach Unterstützung wendet der User sich an einen Moderator mit entsprechenden Admin-Rechten. + +#### Manuelle Aktivierung und One-Time-Passwort + +Der Admin navigiert in seinem angemeldeten Gradido-Account auf das Admin-Interface. Dort öffnet er den Dialog "Nutzersuche". + +Neu in diesem Dialog sind nun die neuen Checkboxen "noch nicht aktiviertes Konto" und "unbestätigte Email-Adresse". Im Normalfall sind diese beiden Checkboxen nicht selektiert, so dass mit der üblichen Nutzersuche alle User wie bisher ermittelt werden können. + +![img](./image/Admin-UserSearch.png) + +Um nun schneller einen neuen User mit "noch nicht aktiviertem Konto" und noch "unbestätigter Email-Adresse" für die Registrierungsunterstützung zu finden, kann der Admin die neuen Filter-Checkboxen selektieren. Diese schränken die User-Suche zusätzlich zur üblichen Namens-Eingabe ein, dh. ohne Eingabe eines einschränkenden Namens werden alle User-Accounts gelistet, die ein "noch nicht aktiviertes Konto" und noch eine "unbestätigte Email-Adresse" haben. + +![img](./image/Admin-UserSearch_inaktivAccount.png) + +Sobald der gewünschte User-Account in der Liste gefunden wurde, kann der Detail-Dialog zu diesem User per Klick geöffnet werden. + +![img](./image/Admin-UserAccount-Details.png) + +Der geöffnete Detail-Dialog zeigt einen neuen Reiter "Registrierung", in dem die Informationen über das User-Konto stehen: wann wurde es erzeugt und wie ist der Status der "Konto-Aktivierung" und der "Email-Bestätigung". + +Der Admin kann nun entweder manuell ein One-Time-Passwort in das Eingabefeld eingeben oder über den "erzeugen"-Button eines kreieren lassen. Dieses wird dann über den Button "speichern & Konto aktivieren" in die Datenbank geschrieben, wobei damit gleichzeitig der Status des User-Kontos auf *aktiviert* gesetzt wird. + +Der Admin kann nun das One-Time-Passwort dem User mitteilen, so dass dieser sich über den Login-Prozess in seinen Account ohne vorherige Email-Bestätigung anmelden kann. Der Login-Prozess mit einem One-Time-Passwort muss nach erfolgreicher Anmeldung den User sofort auf den Passwort-Ändern-Dialog führen, um den User direkt die Möglichkeit zu geben sein eigenes Passwort zu vergeben. + +Mit Öffnen des Passwort-Ändern-Dialogs für einen User-Account mit One-Time-Passwort kann nicht mit Sicherheit davon ausgegangen werden, dass der User selbst der Datenschutzerklärung zugestimmt hat - dies könnte durch die Unterstützung der Moderators beim User untergegangen sein. Daher muss in diesem Fall in dem Dialog eine Checkbox zur Bestätigung der Datenschutzerklärung eingeblendet sein. Erst wenn der User sein neues Passwort gemäß den Passwort-Richtlinien gesetzt und den Datenschutzbestimmungen zugestimmt hat, wird der "Speichern"-Button enabled und die Daten können gespeichert werden. + +#### One-Time-Passwort anzeigen oder ändern + +Falls ein neuer User sein erhaltenes One-Time-Passwort noch nicht für einen Login verwendet hat und dieses erneut vom Admin erfragen möchte, dann kann der Administrator dieses erneut im Admin-Interface über die Nutzer-Suche anzeigen lassen. Dazu kann er die Filter-Checkbox "noch nicht aktiviertes Konto" deselektieren, aber die Checkbox "unbestätigte Email-Adresse" selektiert lassen. Dann bekommt er alle User deren Email-Bestätigung noch offen ist und nur User-Konten, die schon aktiviert sind. + +![img](./image/Admin-UserAccount-ActivatedOneTimePasswort.png) + +Beim Öffnen der Userkonto-Details im Reiter "Registrierung" ist dann zu sehen, dass das "Konto schon aktiv", aber die "Email-Bestätigung noch offen" ist. Im Eingabefeld des One-Time-Passwortes ist das zuvor schon gespeicherte Passwort zu lesen, so dass der Admin dieses dem User erneut mitteilen kann. Der Admin kann aber auch über den "erzeugen"-Button oder manuell das vorhandene Passwort ändern. Über den "speichern"-Button, der aufgrund der vorherigen Konto-Aktivierung nun nicht mehr "speichern & Konto aktivieren" heißt, kann die Passwort-Änderung in die Datenbank geschrieben werden. + +### 1.2 Starten einer manuellen Admin-User-Registrierung + +Im Admin-Interface wird im Menü ein neuer Reiter "Registrierung" angezeigt. Mit Auswahl dieses Reiters kann der Moderator den Dialog zur "Manuellen User-Registrierung" öffnen. + +![img](./image/Admin-CreateUser.png) + +Dabei kann der Moderator die Attribute Vorname, Nachname, Email-Adresse und ein One-Time-Passwort eingeben. Mit dem "speichern & Konto aktivieren"-Button wird im Backend zunächst eine Prüfung durchgeführt, ob die eingegebene Email-Adresse ggf. schon von einem anderen existierenden User verwendet wird. Sollte dies der Fall sein, dann wird eine entsprechend aussagekräftige Fehlermeldung ausgegeben und die zuvor eingegebenen Daten werden in dem "Manuelle User-Registrierung" erneut angezeigt. Sind alle Daten soweit valide, dann werden die eingegebenen Daten in der Datenbank gespeichert und der Konto-Status auf *aktiviert* gesetzt. + +Es wird auch hier eine Email zur Emailadress-Bestätigung verschickt. Der Status "email_checked" bleibt auf false, weil der User seine Confirmation-Email zwar bekommen, aber noch nicht bestätigt hat oder eben nicht zeitnah bestätigen kann. Durch das One-Time-Passwort, das der Moderator dem User mitteilen kann, hat der User direkt die Möglichkeit sich über den Login-Prozess anzumelden, ohne vorher den Email-Bestätigungslink aktivieren zu müssen. + +### 1.3 User-Login mit One-Time-Passwort + +Sobald der User selbst oder durch den Moderator ein neues User-Konto angelegt und ein One-Time-Passwort vergeben ist, dann kann der User selbst sich über den üblichen Login-Prozess anmelden. + +Die Anwendung erkennt, dass der Login über ein One-Time-Passwort erfolgte, so dass der User direkt nach dem erfolgreichen Login auf die Passwort-Ändern-Seite geführt wird. + +![img](./image/One-Time-Passwort-Login.png) + +Auf dieser Seite muss der User dann sein neues, nur ihm persönlich bekanntes Passwort eingeben und zur Kontrolle wiederholen. Da der User-Account über eine One-Time-Passwort Registrierung erstellt wurde, hatte der User sehr wahrscheinlich nicht die Gelegenheit der Datenschutzerklärung selbst zuzustimmen. Daher wird hier im Passwort-Ändern-Dialog dies nachgeholt, indem erst mit der Zustimmung zur Datenschutzerklärung der "Passwort ändern"-Button aktiviert wird. + +## 2. Implementierung und Anpassungen + +### 2.1 Datenbank + +Für diese fachlichen Anforderungen müssen folgende Informationen neu in der Datenbank aufgenommen und gespeichert werden: + +* Merkmal, dass für den User ein Konto vorhanden, aber dieses noch nicht aktiviert ist +* Merkmal, dass für den User ein Konto vorhanden und dieses schon aktiviert ist +* One-Time-Passwort, das von der Anwendung im Original angezeigt werden kann - unverschlüsselt oder ohne Interaktion zu entschlüsseln +* Merkmal, ob bzw. wann der User der Datenschutzerklärung zugestimmt hat + +Es stellt sich die Frage, ob mit diesem UseCase gleich die schon sowieso geplante neue Tabelle `accounts `erstellt wird oder die obigen Merkmale erst einmal in die users-Tabelle einfließen? + +**Empfehlung:** erstellen der `accounts`-Tabelle + +In dieser `accounts`-Tabelle werden dann alle Account spezifischen Daten gespeichert und ein `accounts`-Eintrag ist über die Spalte `user_id` dem User in der `users`-Tabelle zugeordnet. + +Ansonsten werden aber keine weiteren Datenbank-Migrationen, wie Zuordnung der Transaktionen oder Contributions zur `accounts`-Tabelle durchgeführt. Dies muss in einem separaten Issue migriert werden. + +#### accounts-Tabelle + +| Column | Type | Description | +| ----------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- | +| id | unsigned int(10) | technical unique key | +| user_id | unsigned int(10) | foreign key to users entry | +| type | enum | account type: AGE (default), AGW, AUF | +| created_at | datetime(3) | the point of time the entry was created | +| activated | tinyint(4) | switch if account is active or inactive | +| creations_allowed | tinyint(4) | switch if account allows to create gradidos or not; necessary for type AGW and AUF | +| decay | tinyint(4) | switch if account supports decay or not; in case the GDT will be shiftet as a separate account type here in the application | +| balance | decimal(40, 20) | amount of gradidos at the updated_at point of time | +| updated_at | datetime(3) | the point of time the entry was updated, especially important for the balance | + +Die letzten vier Spalten sind ersteinmal rein informativ, was ein `accounts`-Eintrag zukünftig enthalten wird und für diesen Usecase optional. Sie könnten auch auf ein zukünftiges Migrations-Issue verschoben werden. + +#### users-tabelle + +| Column | Type | Description | +| ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------- | +| privacy_policy_at | datetime(3) | point of time the user agreed with the privacy policy - during registration or one-time-password change | +| password_encryption_type | enum | defines the type of encrypting the password: 0 = one-time, 1 = email (default), 2 = gradidoID, ... | + +Um zu vermeiden, dass in Bezug auf das One-Time-Passwort und der anstehenden Migration der Passwort-Verschlüsselung ohne Email und stattdessen per GradidoID, es hier zu unnötigen Tabellen-Migrationen kommt, wird mit diesem Usecase die Spalte *password_encryption_type* eingeführt. Damit ist dann erkennbar, ob es sich bei dem gespeicherten Passwort um ein One-Time-Passwort handelt oder um ein anderweitig verschlüsseltes Passwort. + +Sollte das Issue zur Migration der Passwort-Verschlüsselung schon vor diesem Usecase umgesetzt sein, dann existiert in der `users`-Tabelle schon die Spalte `passphrase_encryption_type`. Dann sollte diese in `password_encryption_type` umbenannt und dem Enum der Wert 0 für One-Time-Passwort hinzugefügt werden. Die Bezeichnung *passphrase_encryption_type* ist irreführend, da in der Tabelle eine Spalte `passphrase `existiert. Doch die Verschlüsselung wird auf die Spalte `password `und nicht auf `passphrase` angewendet. + +#### Migration + +Mit den zuvor beschriebenen Datenbankänderungen muss eine Datenbankmigration auf die bestehenden Daten durchgeführt werden. Nachdem die strukturellen Änderungen wie neue `accounts`-Tabelle anlegen und bestehende `users`-Tabelle ändern durchgeführt wurde, erfolgt nun die eigentliche Migration der Daten: + +* erzeugen der neuen `accounts`-Tabelle wie oben beschrieben +* ändern der bestehenden `users`-Tabelle wie oben beschrieben mit folgenden Default-Initialisierungen + * privacy_policy_at = created_at + * passwort_encryption_type = Enum `PasswordEncryptionType.EMAIL` oder Wert=1 +* Insert pro Eintrag aus der `users`-Tabelle jeweils einen Eintrag in die `accounts`-Tabelle mit folgenden Initialisierungen: + * `accounts.user_id` = `users.id` + * `accounts.type` = Enum `AccountType.AGE` + * `accounts.created_at` = `users.created_at` + * `accounts.activated` = `users.emailContact.email_checked` + * `accounts.creations_allowed` = TRUE (weil es ein account type = AGE ist) + * `accounts.decay` = TRUE (weil es ein account type = AGE ist) + * `accounts.balance` = null (dieses Attribut wird in separatem Issue "Update Account-Balance during writing a Transaction" bedient) + * `account.updated_at` = null (dieses Attribut wird in separatem Issue "Update Account-Balance during writing a Transaction" bedient) + +### 2.2 Admin-Interface + +#### searchUsers + +Der Service *AdminResolver.searchUsers* muss die Filterkriterien "aktiviertes Konto" und "bestätigte Email" getrennt von einander unterstützen. Bisher gibt es in den *SearchUserFilters* das Filterkriterium "byActivated", doch dieses wird auf das Flag `email_checked` in der `user_contacts`-Tabelle angewendet. Das entspricht aber dann dem FilterKriterium "bestätigte Email". + +Somit muss das schon existierende Fitlerkriterium "aktiviertes Konto" auf die Spalte "`activated`" in der `accounts`-Tabelle angewendet werden und ein zusätzliches Filterkriterium "bestätigte Email", das auf die Spalte `email_checked` in der `user_contacts`-Tabelle filtert. + +Der ErgebnisTyp `SearchUsersResult `des Service *searchUsers* muss um die Informationen erweitert werden, die in dem oben aufgezeigten Detail-Dialog der *Nutzer-Suche* auf dem Reiter "Registrierung" zur Anzeige gebracht werden: + +* Zeitpunkt der Konto-Erstellung (`accounts.created_at`) +* Status des Kontos (`accounts.activated`) +* Status der Email-Bestätigung (`user_contacts.email_checked`) +* falls `users.password_encryption_type` = 0, dann das One-Time-Passwort (`users.password`) + +#### adminCreateUser + +Im *AdminResolver* muss aus Berechtigungsgründen ein neuer Service *adminCreateUser* erstellt werden, da im *UserResolver* der Service *createUser* für jeden offen ist, ohne dass eine vorherige Authentifizierung per Login stattgefunden hat. + +Dieser neue Service benötigt folgende Signatur als Eingabeparameter: + +| Argument | Type | Bezeichnung | +| --------------- | ------ | ------------------------------------- | +| vorname | String | der Vorname des neuen Users | +| nachname | String | der Nachname des neuen Users | +| email | String | die Email-Adresse des neuen Users | +| oneTimePassword | String | das One-Time-Passwort des neuen Users | + + Der neue Service entspricht der internen Logik weitestgehend dem exitierenden Service `UserResolver.create`. + +* prüfen ob Email schon existiert und wenn ja, dann an diese Email eine Info-Nachricht und Abruch mit Fehlermeldung +* neues User-Objekt initialisieren mit + * GradidoID + * Vorname + * Nachname + * One-Time-Passwort mit gleichzeitigem Setzen von `password_encryption_type` = Enum `PasswordEncryptionType.ONETIME` +* das neue User-Objekt speichern +* neues UserContact-Objekt initialisieren mit + * Email + * vorherige userID +* das neue UserContact-Objekt speichern +* die erhaltene ID des neuen UserContact-Eintrags in den vorher erzeugten User-Eintrag als `emailId` schreiben +* einen EventProtokoll-Eintrag schreiben vom Typ *EventAdminRegister*, der neu anzulegen ist und von `EventBasicUserId `abgeleitet wird, aber zusätzlich die *UserId* des Moderators in das Attribut `xUserId `einträgt. +* die Confirmation-Email zur Bestätigung der Email-Adresse verschicken +* alle fachlich sonst notwendigen Eventprotokolle schreiben + +Alle logischen Schritte bzgl. einer PublisherID oder eines Redeem-Links bleiben hier in diesem Service aussen vor. + +Als Rückgabe sind erst einmal keine weiteren fachlichen Daten geplant, ausser einem Boolean=TRUE für eine evtl. Erfolgsmeldung. Im Fehlerfall wird der Service mit einer Exception beendet. + +#### adminUpdateUser + +Im *AdminResolver* wird der neue Service *adminUpdateUser* eingeführt, um für einen schon existierenden User das One-Time-Passwort zu aktualisieren. Über die vorher durchgeführte Nutzer-Suche sind die aktuell gespeicherten Userdaten schon ermittelt worden. Damit ergibt sich als Signatur für diesen Service folgendes: + +| Argument | Typ | Beschreibung | +| -------- | ------ | -------------------------------------------------------- | +| userId | number | der technisch eindeutige Identifer des betroffenen Users | +| password | String | das geänderte One-Time-Passwort | + +Dieser Service führt mit der übergebenen *userId* ein update auf dem *User* aus. Dazu wird bei der Aktualisierung das Kriterium `passwort_encryption_type` = Enum `PasswordEncryptionType.ONETIME` sichergestellt und das Attribut `password `mit dem übergebenen Parameter *password* sowie das Flag `activated `= TRUE gesetzt. Abschließend erfolgt das Schreiben eines EventProtokoll-Eintrags vom Typ *EventAdminPasswortChange*, der neu anzulegen ist und von `EventBasicUserId `abgeleitet wird, aber zusätzlich die *UserId* des Moderators in das Attribut `xUserId `einträgt. + +Als Rückgabe sind erst einmal keine weiteren fachlichen Daten geplant, ausser einem Boolean=TRUE für eine evtl. Erfolgsmeldung. Im Fehlerfall wird der Service mit einer Exception beendet. + +### 2.3 User-Interface + +#### login + +Im *UserResolver* muss der Service *login* angepasst werden, um eine Anmeldung per One-Time-Passwort zu erlauben. + +Dabei wird zuerst per übergebener *email* der User aus der Datenbank ermittelt. Bevor die Prüfung auf das Flag `user.emailContact.email_checked` erfolgt, muss eine Prüfung auf das Attribut `user.password_encryption_type` durchgeführt werden. Ist die Passwort-Verschlüsselung dieses Users auf dem Wert `PasswordEncryptionType.ONETIME`, dann wird die Prüfung des Flags `user.emailContact.email_checked` übersprungen. + +Durch den Wert des Attributs `user.password_encryption_type` wird die Passwort-Entschlüsselungsart und Prüfung gesteuert. Beim Wert `PasswordEncryptionType.ONETIME` ist das Passwort selbst für die Anwendung kein Geheimnis, da dieses durch einen Moderator und nicht geheim durch den User eingegeben wurde und jederzeit durch einen Moderator im Klartext wieder angezeigt werden kann. + +Wenn zuvor es sich um ein Login per One-Time-Passwort handelte, dann erfolgt keine Überprüfung des EloPage-Status und Aktuallisierung der PublisherId. + +Mit erfolgreicher Beendigung des Login-Service wird der User mit seinen aktuellen Attributwerten zurückgeliefert. Dabei ist nun im Frontend sicherzustellen, dass wenn im User das Attribut `user.password_encryption_type` den Wert `PasswordEncryptionType.ONETIME` hat, dass dann mit Verlassen des Login-Dialogs der Anwender direkt nur auf die Passwort-Ändern-Seite geführt wird. Dem Einstieg in den Passwort-Ändern-Dialog muss aus dem Login-Dialog die Information mitgeteilt werden, dass es sich hier um ein One-Time-Passwort Login handelte, damit der Passwort-Ändern-Dialog die entsprechenden Änderungen in Bezug auf diesen UseCase durchführen kann. + +#### changePassword + +Im *UserResolver* ist der neue Service *changePassword* zu erstellen. Dieser bekommt als Eingabesignatur folgende Parameter: + +| Argument | Type | Bezeichnung | +| ------------- | ------- | ----------------------------------------------------------------------------------------- | +| userId | number | eindeutiger Identifier des Users, der zuvor beim Login an das Frontend übermittelt wurde | +| oldPassword | String | altes Passwort des angemeldeten Users | +| newPassword | String | neues Passwort des angemeldeten Users | +| privacyPolicy | boolean | optional: Flag zur Zustimmung der Datenschutzerklärung | + +Die übergebenen Parameter müssen im ersten Schritt auf ihre Validität untersucht werden. Das heißt sind alle mandatorischen Parameter übergeben und entsprechen ihre Werte den formellen Anforderungen (z.B. sind die Passworte gemäß den Passwort-Regeln). + +Mit der übergebenen *userId* wird der zugehörige User aus der Datenbank ermittelt. Bevor die eigentliche Logik zur Passwort-Änderung durchgeführt wird, erfolgt zuvor noch eine Prüfung auf den Userkonto-Status im Attribut `accounts.activated`. Nur wenn das Userkonto im Status *aktiviert* - per One-Time-Passwort durch den Moderator oder per Email-Confirmation durch den User selbst - ist, kann das Passwort geändert werden. + +In Abhängigkeit des im User gespeicherten Typs der Passwort-Verschlüsselung, muss das übergebene *oldPassword* gegen die im User gespeicherten Passwort-Daten überprüft werden: + +* *EncryptionType = One-Time-Passwort:* das im User gespeicherte Passwort muss dem übergebenen *oldPassword* entsprechen. Je nach technischer Implementierung könnte ggf. eine Entschlüsselungslogik auf das gespeicherte Passwort notwendig sein, da ein Passwort niemals im Klartext in der Datenbank gespeichert sein soll. Das heißt das im User gespeicherte Passwort muss nach einer evtl. Entschlüsselung mit dem übergebenen Passwort übereinstimmen. Ein One-Way-Hashing ist zwar sicherer aber reicht hier nicht, da das One-Time-Passwort im Admin-Interface im Klartext angezeigt werden können muss. +* *EncryptionType = EMAIL:* das im User gespeicherte Passwort wurde mit der im User gespeicherten Email als Salt verschlüsselt und als EmailHash gespeichert. Daher muss mit der im User gespeichert Email und dem übergebenen *oldPassword* ein EmailHash erzeugt werden, um diesen mit dem im User gespeicherten Emailhash zu vergleichen. +* *EncryptionType = GradidoID:* das im User gespeicherte Passwort wurde mit der im User gespeicherten *GradidoID* als Salt verschlüsselt und als EmailHash gespeichert. Daher muss mit der im User gespeicherten *GradidoID* und dem übergebenen *oldPassword* ein EmailHash erzeugt werden, um diesen mit dem im User gespeicherten EmailHash zu vergleichen. + +Ist die Überprüfung des alten Passwortes in Abhängigkeit des Verschlüsselungstyps erfolgreich, dann kann mit der Verschlüsselung des *newPassword* weiter gemacht werden. Als Verschlüsselungstyp wird immer der höchsten Wert des Verschlüsselungs-Enums eingesetzt, auch wenn zuvor ein niedrigerer Typ verwendet wurde. Damit erfolgt immer ein implizites Upgrade auf den aktuellsten Verschlüsselungstyp. Somit wird das übergebene *newPassword* mit der *GradidoID* des Users zu einem EmailHash verschlüsselt und zusammen mit dem Attribut `user.password_encryption_type` = `PasswordEncryptionType.GRADIDO_ID` in die Datenbank geschrieben. Je nach Wert des übergebenen Flags `privacyPolicy` wird im User das Attribut `privacy_policy_at` mit dem aktuellen Zeitpunkt ebenfalls aktualisiert. + +War die Passwort-Änderung erfolgreich wird ein boolean=TRUE zurückgegeben, ansonsten eine Exception mit aussagekräftiger Fehlermeldung. + +## Brainstorming von Bernd + +Damit wir ohne zusätzliche Technologie möglichst schnell und unkompliziert eine Lösung bekommen, dass wir neue User direkt vor Ort registrieren können, schlage ich folgende zwei Funktionen im Admin-Bereich vor: + +1. Manuell bestätigen und (vorläufiges) Passwort setzen +2. Neuen User registrieren + +### Usecase + +Bei einer Veranstaltung o.ä. sollen neue Mitglieder geworben werden. Dabei ist ungewiss, ob sie ein Endgerät dabei haben bzw. dieses korrekt bedienen können (QR-Code, E-Mail-Zugang etc.) + +#### Lösung: + +Bei der Veranstaltung ist ein Moderator vor Ort, oder der Veranstalter bekommt vorübergehend Moderatoren-Rechte. + +Der Moderator hat auf einem Browser sein Gradido-Konto (Admin-Interface) laufen. Auf einem anderen Browser (oder einem anderen Gerät) können sich ggf. User einloggen. + +##### Variante 1: + +Der Interessent registriert sich über Link/QR-Code, hat aber keinen Zugang zu seinen E-Mails. Der Moderator bestätigt ihn und gibt ihm ein vorläufiges Passwort (oder lässt den User im Backend selbst ein Passwort eintippen). + +##### Variante 2: + +Der Moderator registriert den Interessenten und gibt ihm ein vorläufiges Passwort (oder lässt den User im Backend selbst ein Passwort eintippen). + +Das vorläufige Passwort kann so lange vom Moderator geändert werden, bis der User über die Mail sein Passwort neu gesetzt hat. Dadurch wird erreicht, dass der Moderator den User so lange unterstützen kann (z.B. wenn er sein PW vergessen hat), bis er Mail-Zugang hat und sein Passwort selbst setzen kann. + +##### Weitere Anwendungsfälle: + +Wenn eine (zukünftige) Community beschließt, dass neue Mitglieder nur durch persönliche Einladung aufgenommen werden. Für diesen Fall müsste dann noch die User-Registrierung abgeschaltet werden können. diff --git a/docu/Concepts/BusinessRequirements/UC_Send_Contribution.md b/docu/Concepts/BusinessRequirements/UC_Send_Contribution.md index ab04efc7f..ac982fa53 100644 --- a/docu/Concepts/BusinessRequirements/UC_Send_Contribution.md +++ b/docu/Concepts/BusinessRequirements/UC_Send_Contribution.md @@ -2,6 +2,18 @@ Die Idee besteht darin, dass ein Administrator eine Contribution mit all seinen Attributen und Regeln im System erfasst. Dabei kann er unter anderem festlegen, ob für diese ein Link oder ein QR-Code generiert und über andere Medien wie Email oder Messenger versendet werden kann. Der Empfänger kann diesen Link bzw QR-Code dann über die Gradido-Anwendung einlösen und bekommt dann den Betrag der Contribution als Schöpfung auf seinem Konto gutgeschrieben. +## Ausbaustufen + +Die beschriebenen Anforderungen werden in mehrere Ausbaustufen eingeteilt. Damit können nach und nach die Dialoge und Businesslogik schrittweise in verschiedene Releases gegossen und ausgeliefert werden. + +### Ausbaustufe 1 + +Diese Ausbaustufe wird gezielt für die "Dokumenta" im Juni 2022 zusammengestellt. Details siehe weiter unten im speziellen Kapitel "Ausbaustufe 1". + +### Ausbaustufe 2 + +Diese Ausbaustufe wird gezielt für die Anforderungen für das Medidationsportal von "Abraham" zusammegestellt. Details siehe weiter unten im speziellen Kapitel "Ausbaustufe 2". + ## Logischer Ablauf Der logische Ablauf für das Szenario "Activity-Confirmation and booking of Creations " wird in der nachfolgenden Grafik dargestellt. Dabei wird links das Szenario der "interactive Confirmation and booking of Creations" und rechts "automatic Confirmation and booking of Creations" dargestellt. Ziel dieser Grafik ist neben der logischen Ablaufsübersicht auch die Gemeinsamkeiten und Unterschiede der beiden Szenarien herauszuarbeiten. @@ -28,11 +40,11 @@ Der Gültigkeitsstart wird als Default mit dem aktuellen Erfassungszeitpunkt vor Wie häufig ein User für diese Contribution eine Schöpfung gutgeschrieben bekommen kann, wird über die Auswahl eines Zyklus - stündlich, 2-stündlich, 4-stündlich, etc. - und innerhalb dieses Zyklus eine Anzahl an Wiederholungen definiert. Voreinstellung sind 1x täglich. -![Zyklus](./image/UC_Send_Contribution_Admin-new ContributionZyklus.png) +![img](./image/UC_Send_Contribution_Admin-new_ContributionZyklus.png) Ob die Contribution über einen versendeten Link bzw. QR-Code geschöpft werden kann, wird mittels der Auswahl "Versenden möglich als" bestimmt. -![send](./image/UC_Send_Contribution_Admin-new ContributionSend.png) +![img](./image/UC_Send_Contribution_Admin-new_ContributionSend.png) Für die Schöpfung der Contribution können weitere Regeln definiert werden: @@ -44,11 +56,11 @@ Für die Schöpfung der Contribution können weitere Regeln definiert werden: ![new](./image/UC_Send_Contribution_Admin-newContribution.png) -### Ausbaustufe-1: +## Ausbaustufe-1: -Die Ausbaustufe-1 wird gezielt auf die Anforderungen der "Dokumenta" im Juni 2022 abgestimmt. +Die Ausbaustufe-1 wird gezielt auf die Anforderungen der "Dokumenta" im Juni 2022 abgestimmt. -#### Contribution-Erfassungsdialog (Adminbereich) +### Contribution-Erfassungsdialog (Adminbereich) Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gestellt: @@ -64,14 +76,12 @@ Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gest | VersendenMöglich | - hier wird "als Link / QR-Code" voreingestellt | | alle weiteren Attribute | - entfallen für diese Ausbaustufe
- die GUI-Komponenten können optional schon im Dialog eingebaut und angezeigt werden
- diese GUI-Komponenten müssen wenn sichtbar disabled sein und dürfen damit keine Eingaben entgegen nehmen | - -#### Ablauflogik +### Ablauflogik Für die Ausbaustufe-1 wird gemäß der Beschreibung aus dem Kapitel "Logischer Ablauf" nur die "automatic Confirmation and booking of Creations" umgesetzt. Die interaktive Variante - sprich Ablösung des EloPage Prozesses - mit "interactive Confirmation and booking of Creations" bleibt für eine spätere Ausbaustufe aussen vor. Das Regelwerk in der Businesslogik wird gemäß der reduzierten Contribution-Attribute aus dem Erfassungsdialog, den vordefinierten Initialwerten und der daraus resultierenden Variantenvielfalt vereinfacht. - #### Kriterien "Dokumenta" * Es soll eine "Dokumenta"-Contribution im Admin-Bereich erfassbar sein und in der Datenbank als ContributionLink gespeichert werden. @@ -91,6 +101,66 @@ Das Regelwerk in der Businesslogik wird gemäß der reduzierten Contribution-Att * es erfolgt eine übliche Schöpfungstransaktion nach der Bestätigung der Contribution * die Schöpfungstransaktion schreibt den Betrag der Contribution dem Kontostand des Users gut +## Ausbaustufe-2 + +Die Ausbaustufe-2 wird gezielt auf die Anforderungen zur Anbindung des Meditationsportals von Abraham im Oktober 2022 abgestimmt. + +### Contribution-Erfassungsdialog (Adminbereich) + +Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gestellt: + +| Attribut | Beschreibung | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GültigBis | - das Datum, wie lange die Contribution gültig und damit einlösbar ist
- für diese Ausbaustufe soll ein offenes Ende möglich sein, daher bleibt dieses Attribut leer | +| Zyklus | - Angabe wie häufig eine Contribution gutgeschrieben werden kann
- als Auswahlliste (Combobox) geplant, aber für diese Ausbaustufe nur mit dem Wert "täglich" vorbelegt | +| Wiederholungen | - Anzahl an Wiederholungen pro Zyklus
- für diese Ausbaustufe wird der Wert "1" vorbelegt -> somit gilt: ein User kann diese Contribution nur 1x täglich einlösen | +| alle weiteren noch nicht vorhandenen Attribute | - entfallen für diese Ausbaustufe
- die GUI-Komponenten können optional schon im Dialog eingebaut und angezeigt werden
- diese GUI-Komponenten müssen wenn sichtbar disabled sein und dürfen damit keine Eingaben entgegen nehmen | + +### Ablauflogik + +Für die Ausbaustufe-2 und der inzwischen umgesetzten Ablösung des "EloPage Contribution Erfassungsprozesses" wird gemäß der Beschreibung aus dem Kapitel "Logischer Ablauf" die "automatic Confirmation and booking of Creations" sowie die interaktive Variante "interactive Confirmation and booking of Creations" mit berücksichtigt. + +Das Regelwerk in der Businesslogik wird gemäß der noch nicht vollumfänglich geplanten Contribution-Attribute aus dem Erfassungsdialog, den vordefinierten Initialwerten und der daraus resultierenden Variantenvielfalt vereinfacht. + +#### Kriterien "Meditationsportal (Abraham)" + +* Es soll eine "GlobalMeditation"-Contribution nur im Admin-Bereich erfassbar sein und in der Datenbank als ContributionLink gespeichert werden. +* Es wird ein offenes Ende als Gesamtlaufzeit dieser Contribution benötigt, was durch ein leeres GültigBis-Datum ausgedrückt bzw. erfasst werden soll. +* Die "Meditationsportal"-Contribution kann von einem User maximal 1x täglich aktiviert werden. Dies wird über die Erfassung des Attributes "Zyklus" = täglich und des Attributes "Wiederholungen" = 1 ermöglicht. +* Ein User kann mit diesem Link nur die Menge an GDDs schöpfen, die in der Contribution als "Betrag" festgelegt ist +* Die "GlobalMeditation"-Contribution kann als Link / QR-Code erzeugt, angezeigt und in die Zwischenablage kopiert werden +* Jeder beliebige User kann den Link / QR-Code aktivieren +* der Link führt auf eine Gradido-Seite, wo der User sich anmelden oder registrieren kann +* mit erfolgreichem Login bzw. Registrierung wird der automatische Bestätigungs- und Schöpfungsprozess getriggert +* es erfolgt eine Überprüfung der definierten Contribution-Regeln für den angemeldeten User: + * Gültigkeit: liegt die Aktivierung im Gültigkeitszeitraum der Contribution + * Zyklus und WIederholungen: bei einem Zyklus-Wert = "täglich" und einem Wiederholungswert = 1 darf der User den Betrag dieser Contribution nur einmal am Tag schöpfen. Es gibt keine Überprüfung eines zeitlichen Mindestabstandes zwischen zwei Schöpfungen an zwei aufeinanderfolgenden Tagen. + * max. schöpfbarer Gradido-Betrag pro Monat: wenn der Betrag der Contribution plus der Betrag, den der User in diesem Monat schon geschöpft hat, den maximal schöpfbaren Betrag pro Monat von 1000 GDD übersteigt, dann wird die Schöpfung dieser Contribution abgelehnt +* mit erfolgreich durchlaufenen Regelprüfungen wird ein "besätigter" aber "noch nicht gebuchten" Eintrag in der "Contributions"-Tabelle erzeugt +* ein "bestätigter" aber "noch nicht gebuchter" "Contributions"-Eintrag stößt eine Schöpfungstransaktion für den User an +* es erfolgt eine übliche Schöpfungstransaktion mit automatischer Bestätigung der Contribution +* die Schöpfungstransaktion schreibt den Betrag der Contribution dem Kontostand des Users gut + +## Ausbaustufe-3 + +### Änderungen im Registrierungsprozess + +Aktuell treten Probleme mit der Aktivierung des ContributionLinks während des Registrierungsprozesses auf. Sobald der User bei der Registrierung sein Konto zwar angelegt, aber die erhaltene Email-Confirmation nicht abgeschlossen und damit sein Konto noch nicht aktiviert hat, kann derzeit der Redeem-Link nicht als Transaktion durchgeführt werden. Die Gültigkeitsdauer des Redeemlink reicht meist nicht bis der User sein Konto aktiviert. Daher wird nun die Idee verfolgt die Einlösung des Redeemlinks schon während der Anlage des inaktiven Kontos als "pendingRedeem Contribution" anzulegen. Sobald dann der User sein Konto per Email-Confirmation aktiviert, soll die "pendingRedeem Contribution" automatisch zu einer Tranaktion überführt und der Betrag des Redeemlinks auf das Konto des Users gebucht werden. + +Folgende Schritte und Änderungen sind dabei vorgesehen (siehe in der Grafik rechts im orange markierten Bereich im Vergleich zur Grafik im Kapitel "Logischer Ablauf"): + +![img](./image/Ablauf_manuelle_auto_Creations_2.png) + +* Der User landet mit Aktivierung eines Redeem-Links wie bisher auf der Login/Registrierungsseite, wobei wie bisher schon der Redeemlink als Parameter in den Registrierungsprozess übergeben wird. +* Mit der Anlage des neuen aber noch inaktiven User-Kontos und einer Übergabe eines Redeemlinks wird der Redeemlink zu einer "pendingRedeem Contribution" für den neuen User angelegt, aber noch nicht als Transaktion gebucht + * nach Anlage des inaktiven User-Kontos und bevor die Confirmation-Email abgeschickt wird, erfolgt das Schreiben eines neuen Contribution-Eintrages mit den Daten des Redeem-Links. + * Die neu angelegte Contribution wird im Status "pendingRedeem" gespeichert. Dieser neue Status ist notwendig, um im AdminInterface die normalen "pending Contributions" von den "pendingRedeem Contributions" zu unterscheiden. Denn der Admin soll zum Einen diese "pendingRedeem Contributions" weder bestätigen noch ablehnen können und zum Anderen sollen die "pendingRedeem Contributions" automatisiert bestätigt und gebucht werden können. Daher wird eine Unterscheidung zwischen den interaktiv angelegten Contributions im Status pending und den per Redeem-Link angelegten Contributions im Status pending benötigt. + * Damit endet erst einmal die weitere Verarbeitung der Redeem-Link-Aktivierung +* Mit Aktivierung des Links in der Email-Confirmation und damit der Aktivierung des User-Kontos erfolgt automatisch die Buchung der "pendingRedeem Contribution" und führt damit zur eigentlichen Buchung des Redeem-Betrages auf das User Konto. + * mit Erhalt der Email-Confirmation Aktivierung wird das User-Konto aktiviert + * Nach der Aktivierung des User-Kontos erfolgt eine Prüfung auf schon vorhandene "pendingRedeem Contributions" aus vorherigen Redeem-Link-Aktivierungen + * Jede vorhandene "pendingRedeem Contribution" wird jetzt automatisch bestätigt und zu einer Transaktion überführt + * Mit der bestätigten Contribution und daraus überführten Transaktion erhält der User eine Bestätigungsemail mit den Contribution spezifischen Daten. ## Datenbank-Modell @@ -100,34 +170,36 @@ Das nachfolgende Bild zeigt das Datenmodell vor der Einführung und Migration au ![Datenbankmodell](./image/DB-Diagramm_20220518.png) -### Datenbank-Änderungen +### Ausbaustufe-1 + +#### Datenbank-Änderungen Die Datenbank wird in ihrer vollständigen Ausprägung trotz Ausbaustufe-1 wie folgt beschrieben umgesetzt. -#### neue Tabellen +##### neue Tabellen -##### contribution_links - Tabelle +###### contribution_links - Tabelle | Name | Typ | Nullable | Default | Kommentar | | ------------------------------- | ------------ | :------: | :------------: | -------------------------------------------------------------------------------------------------------------------------------------- | | id | INT UNSIGNED | NOT NULL | auto increment | PrimaryKey | -| name | varchar(100) | NOT NULL | | unique Name | -| description | varchar(255) | | | | +| name | varchar(100) | NOT NULL | | unique Name | +| description | varchar(255) | | | | | valid_from | DATETIME | NOT NULL | NOW | | -| valid_to | DATETIME | | NULL | | -| amount | DECIMAL | NOT NULL | | | +| valid_to | DATETIME | | NULL | | +| amount | DECIMAL | NOT NULL | | | | cycle | ENUM | NOT NULL | ONCE | ONCE, HOUR, 2HOUR, 4HOUR, 8HOUR, HALFDAY, DAY, 2DAYS, 3DAYS, 4DAYS, 5DAYS, 6DAYS, WEEK, 2WEEKS, MONTH, 2MONTH, QUARTER, HALFYEAR, YEAR | | max_per_cycle | INT UNSIGNED | NOT NULL | 1 | | -| max_amount_per_month | DECIMAL | | NULL | | -| total_max_count_of_contribution | INT UNSIGNED | | NULL | | -| max_account_balance | DECIMAL | | NULL | | -| min_gap_hours | INT UNSIGNED | | NULL | | -| created_at | DATETIME | | NOW | | -| deleted_at | DATETIMEBOOL | | NULL | | -| code | varchar(24) | | NULL | | -| link_enabled | BOOL | | NULL | | +| max_amount_per_month | DECIMAL | | NULL | | +| total_max_count_of_contribution | INT UNSIGNED | | NULL | | +| max_account_balance | DECIMAL | | NULL | | +| min_gap_hours | INT UNSIGNED | | NULL | | +| created_at | DATETIME | | NOW | | +| deleted_at | DATETIMEBOOL | | NULL | | +| code | varchar(24) | | NULL | | +| link_enabled | BOOL | | NULL | | -##### contributions -Tabelle +###### contributions -Tabelle | Name | Typ | Nullable | Default | Kommentar | | --------------------- | ------------ | -------- | -------------- | -------------------------------------------------------------------------------- | @@ -145,9 +217,9 @@ Die Datenbank wird in ihrer vollständigen Ausprägung trotz Ausbaustufe-1 wie f | booked_at | DATETIME | | NULL | date, when the system has booked the amount of the activity on the users account | | deleted_at | DATETIME | | NULL | soft delete | -#### zu migrierende Tabellen +##### zu migrierende Tabellen -##### Tabelle admin_pending_creations +###### Tabelle admin_pending_creations Diese Tabelle wird im Rahmen dieses UseCase migriert in die neue Tabelle contributions... @@ -168,6 +240,18 @@ Diese Tabelle wird im Rahmen dieses UseCase migriert in die neue Tabelle contrib ...und kann nach Übernahme der Daten in die neue Tabelle gelöscht werden oder es erfolgen die Änderungen sofort auf der Ursprungstabelle. -### Zielmodell +#### Zielmodell ![Contributions-DB](./image/DB-Diagramm_Contributions.png) + +### Ausbaustufe-2 + +Für die Ausbaustufe-2 sind keine Datenbank-Änderungen notwendig. Gemäß dem Zielmodell sind alle notwendigen Tabellen und Attribute schon vorhanden. + +#### Zielmodell + +![img](./image/DB-Diagramm_Contributions_Stufe_2.png) + +### Ausbaustufe-3 + +Für die Ausbaustufe-3 dürften im Grunde ebenfalls keine zusätzlichen Datenbankänderungen notwendig sein. Denn für eine "pending Contribution" und deren Confirmation mit Tranaktionsüberführng sind ebenfalls schon alle Attribute vorhanden. diff --git a/docu/Concepts/BusinessRequirements/UC_Set_UserAlias.md b/docu/Concepts/BusinessRequirements/UC_Set_UserAlias.md new file mode 100644 index 000000000..a5cb07e0e --- /dev/null +++ b/docu/Concepts/BusinessRequirements/UC_Set_UserAlias.md @@ -0,0 +1,198 @@ +# User Alias + +Mit dem *Alias* für ein User wird zusätzlich ein eindeutiger menschenlesbarer Identifier neben der GradidoID als technischer Identifier eingeführt. Mit beiden Identifiern kann ein User eindeutig identifziert werden und beide können als Key nach aussen gegeben bzw. für Schnittstellen als Eingabe-Parameter in die Anwendung verwendet werden. + +Ziel dieser beiden Identifier ist von dem bisherig verwendeten Email-Identifier wegzukommen. Denn eine Email-Adresse hat für einen User einen anderen schützenswerten Privatsphären-Level als ein *Alias* oder die *GradidoID*, die beide rein auf die Gradido-Anwendung begrenzt sind. Über Email-Adressen wird gerne eine Social Engineering betrieben, um über einen User ein Profil aus den Social-Media-Netzwerken zu erstellen. Dies gilt es so weit wie möglich zu verhindern bzw. Gradido aus diesem Kreis der möglichen Datenquellen für Ausspähungen von Privatdaten herauszuhalten. + +## Identifizierung eines Users + +In der Gradido-Anwendung muss ein User eindeutig identifzierbar sein. Zur Identifikation eines Users gibt es unterschiedliche Anforderungen: + +* eindeutiger Schlüsselwert für einen User +* leicht für den Anwender zu merken +* einfache maschinelle Verarbeitung im System +* leichte Weitergabe des Schlüsselwertes ausserhalb des Systems +* u.a. + +### Schlüsselwerte + +Die hier aufgeführten Schlüsselwerte dienen in der Gradido-Anwendung zur eindeutigen Identifzierung eines Users: + +#### UserID + +Dies ist ein rein technischer Key und wird nur **innerhalb** der Anwendung zur Identifikation eines Users verwendet. Dieser Key wird niemals nach aussen gereicht und auch niemals zwischen mehreren Communities als Schlüsselwert eingesetzt oder ausgetauscht. Die UserID wird innerhalb des Systems bei der Registrierung mit dem Speichern eines neuen Users in der Datenbank erzeugt. Die Eindeutigkeit der UserID ist damit nur innerhalb dieser einen Datenbank der Gradido-Community sichergestellt. + +#### GradidoID + +Die GradidoID ist zwar auch ein rein technischer Key, doch wird dieser als eine UUID der Version 4 erstellt. Dies basiert auf einer (pseudo)zufällig generierten Zahl aus 16 Bytes mit einer theoretischen Konfliktfreiheit von ![2^{{122}}\approx 5{,}3169\cdot 10^{{36}}](https://wikimedia.org/api/rest_v1/media/math/render/svg/1924927d783e2d3969734633e134f643b6f9a8cd) in hexadezimaler Notation nach einem Pattern von fünf Gruppen durch Bindestrich getrennt - z.B. `550e8400-e29b-41d4-a716-446655440000` + +Somit kann die GradidoID auch System übergreifend zwischen Communities ausgetauscht werden und bietet dennoch eine weitestgehende eindeutige theoretisch konfliktfreie Identifikation des Users. System intern ist die Eindeutigkeit bei der Erstellung eines neuen Users auf jedenfall sichergestellt. Sollte ein User den Wechsel von einer Community in eine andere gradido-Community wünschen, so soll falls möglich die GradidoID für den User erhalten bleiben und übernommen werden können. Dies muss beim Umzug in der Ziel-Community geprüft werden. Falls diese GradidoID aus der Quell-Community wider erwarten existieren sollte, dann muss doch einen neue GradidoID für den User erzeugt werden. + +#### Alias + +Der Alias eines Users ist als rein fachlicher Key ausgelegt, der frei vom User definiert werden kann. Bei der Definition dieses frei definierbaren und menschenlesbaren Schlüsselwertes stellt die Gradido-Anwendung sicher, dass der vom User eingegebene Wert nicht schon von einem anderen User dieser Community verwendet wird. Für die Anlage eines Alias gelten folgende Konventionen: + +- mindestens 5 Zeichen + * alphanumerisch + * keine Umlaute + * nach folgender Regel erlaubt (RegEx: [a-zA-Z0-9]-|_[a-zA-Z0-9]) +- Blacklist für Schlüsselworte, die frei definiert werden können +- vordefinierte/reservierte System relevante Namen dürfen maximal aus 4 Zeichen bestehen + +#### Email + +Die Email eines Users als fachlicher Key bleibt zwar weiterhin bestehen, doch wird diese schrittweise durch die GradidoID und den Alias in den verschiedenen Anwendungsfällen des Gradido-Systems ersetzt. Das bedeutet zum Beispiel, dass die bisher alleinige Verwendung der Email für die Registrierung bzw. den Login nun und durch die GradidoID bzw. den Alias ergänzt wird. + +Die Email wird weiterhin als Kommunikationskanal ausserhalb der Gradido-Anwendung mit dem User benötigt. Es soll aber zukünftig möglich sein, dass ein User ggf. mehrere Email-Adressen für unterschiedliche fachliche Kommunikationskanäle angeben kann. Eine dieser Email-Adressen muss aber als primäre Email-Adresse gekennzeichnet sein, da diese wie bisher auch als Identifier beim Login bzw. der Registrierung erhalten bleiben soll. + +## Erfassung des Alias + +Die Erfassung des Alias erfolgt als zusätzliche Eingabe direkt bei der Registrierung eines neuen Users oder als weiterer Schritt direkt nach dem Login. + +Dieser UseCase ist in die **Ausbaustufe-1** und **Ausbaustufe-x** unterteilt. + +Alle beschriebenen Anforderungen der **Ausbaustufe-1** können mit Produktivsetzung des Issues #1798 - [GradidoID 1: adapt and migrate database schema](https://github.com/gradido/gradido/issues/1798) und dem [PR #2058 - GradidoID 1: adapt and migrate database schema](https://github.com/gradido/gradido/pull/2058) umgesetzt werden. + +Die beschriebenen Anforderungen der Ausbaustufe-x müssen solange verschoben werden, bis der UseCase "GradidoID 2: changeRegisterLoginProzess" konzeptioniert und umgesetzt ist. Erst dann kann auf der Profil-Seite die Email zur Bearbeitung durch den User freigegeben werden. + +### Registrierung + +#### Ausbaustufe-1 + +In der Eingabemaske der Registrierung wird nun zusätzlich das Feld *Alias* angezeigt, das der User als Pflichtfeld ausfüllen muss. + +![img](./image/RegisterWithAlias.png) + +Mit dem (optionalen ?) Button "Eindeutigkeit prüfen" wird dem User die Möglichkeit gegeben vorab die Eindeutigkeit seiner *Alias*-Eingabe zu verifizieren ohne den Dialog über den "Registrieren"-Button zu verlassen. Denn es muss sichergestellt sein, dass noch kein existierender User der Community genau diesen *Alias* evtl. schon verwendet. + +Wird diese Prüfung vom User nicht ausgeführt bevor er den Dialog mit dem "Registrieren"-Button abschließt, so erfolgt die *Alias*-Eindeutigkeitsprüfung als erster Schritt bevor die anderen Eingaben als neuer User geprüft und angelegt werden. + +Wird bei der Eindeutigkeitsprüfung des *Alias* festgestellt, dass es schon einen exitierenden User mit dem gleichen *Alias* gibt, dann wird wieder zurück in den Registrierungsdialog gesprungen, damit der User seine *Alias*-Eingabe korrigieren kann. Das *Alias*-Feld wird als fehlerhaft optisch markiert und mit einer aussagekräftigen Fehlermeldung dem User der *Alias*-Konflikt mitgeteilt. Dabei bleiben alle vorher eingegebenen Daten in den Eingabefeldern erhalten und es muss nur der *Alias* geändert werden. + +Wurde vom User nun eine konfliktfreie *Alias*-Eingabe und alle Angaben der Registrierung ordnungsgemäß ausgefüllt, so kann der Registrierungsprozess wie bisher ausgeführt werden. Einziger Unterschied ist der zusätzliche *Alias*-Parameter, der nun an das Backend zur Erzeugung des Users übergeben und dann in der Users-Tabelle gespeichert wird. + +Falls über ein Redeem-Link in den Registrierungsdialog eingestiegen wurde, so bleiben die existierenden Schritte zur Redeem-Link-Verarbeitung durch die *Alias*-Eingabe erhalten und werden unverändert durchlaufen. + +### Login + +#### Ausbaustufe-1 + +Meldet sich ein schon registrierter User erfolgreich an - das passiert wie bisher noch mit seiner Email-Adresse - dann wird geprüft, ob für diesen User schon ein *Alias* gespeichert, sprich im aktuellen Context nach dem Login im User-Objekt das Attribut *alias* initialisiert ist. Wenn nicht dann erfolgt direkt nach dem Schließen des Login-Dialoges die Anzeige der User-Profilseite. + +![img](./image/LoginProfileWithAlias.png) + +Auf der erweiterten User-Profil-Seite sind folgende Elemente neu hinzugekommen bzw. erweitert: + +* *Alias*: **neu hinzugekommen** ist die Gruppe Alias mit dem Label, dem Eingabefeld und dem Link "Alias ändern" mit Stift-Icon +* E-Mail: **ergänzt** wurde die Gruppe E-Mail, in dem das Label "E-Mail", das Eingabefeld, das Label "bestätigt" mit zugehörigem Icon darunter und dem Link "E-Mail ändern" mit Stift-Icon. In der **Ausbaustufe-1** ist der Link "E-Mail ändern" und das Stift-Icon **immer disabled**, so dass das Eingabefeld der E-Mail lediglich zur Anzeige der aktuell gesetzten E-Mail-Addresse dient. Da mit der Änderungsmöglichkeit der E-Mail gleichzeitig auch der Login-Prozess und die Passwort-Verschlüsselung angepasst werden muss, wird dieses Feature auf eine spätere Ausbaustufe verschoben. **Neu hinzugekommen** ist die Status-Anzeige der Email-Bestätigung ausgedrückt durch das Häckchen-Icon, wenn die Email-Adresse durch die Email-Confirmation-Mail vom User schon bestätigt ist und durch ein Kreuz-Icon, wenn die Email-Adress-Bestätigung noch aussteht. + +Der Sprung nach der Login-Seite nach erfolgreichem Login auf die Profil-Seite öffnet diese schon direkt im Bearbeitungs-Modus des Alias, so dass der User direkt seine Eingabe des Alias vornehmen kann. + +![img](./image/LoginProfileEditAlias.png) + +Im Eingabe-Modus der Alias-Gruppe hat das Eingabefeld den Fokus und darin wird: + +* wenn noch kein Alias für den User in der Datenbank vorhanden ist, vom System ein Vorschlag unterbreitet. Der Vorschlag basiert auf dem Vornamen des Users und wird durch folgende Logik ermittelt: + * es wird mit dem Vorname des Users eine Datenbankabfrage durchgeführt, die zählt, wieviele User-Aliase es schon mit diesem Vornamen gibt und falls notwendig direkt mit einer nachfolgenden Nummer als Postfix versehen sind. + * Aufgrund der Konvention, dass ein Alias mindestens 5 Zeichen lang sein muss, sind ggf. führende Nullen mitzuberücksichten. + * **Beispiel-1**: *Max* als Vorname + * in der Datenbank gibt es schon mehrer User mit den Aliasen: *Maximilian*, *Max01*, *Max_M*, *Max-M*, *MaxMu* und *Max02*. + * Dann schlägt das System den Alias *Max03* vor, da *Max* nur 3 Zeichen lang ist und es schon zwei Aliase *Max* gefolgt mit einer Nummer gibt (*Max01* und *Max02*) + * Die Aliase *Maximilian*, *Max_M*, *Max-M* und *MaxMu* werden nicht mitgezählt, das diese nach *Max* keine direkt folgende Ziffern haben + * **Beispiel-2**: *August* als Vorname + * in der Datenbank gibt es schon mehrer User mit den Aliasen: *Augusta*, *Augustus*, *Augustinus* + * Dann schlägt das System den Alias *August* vor, da *August* schon 6 Zeichen lang ist und es noch keinen anderen User mit Alias *August* gibt + * die Aliase *Augusta*, *Augustus* und *Augustinus* werden nicht mit gezählt, da diese länger als 5 Zeichen sind und sich von *August* unterscheiden + * **Beispiel-3**: *Nick* als Vorname + * in der Datenbank gibt es schon mehrer User mit den Aliasen: *Nicko*, *Nickodemus* + * Dann schlägt das System den Alias *Nick1* vor, da *Nick* kürzer als 5 Zeichen ist und es noch keinen anderen User mit dem Alias *Nick1* gibt + * die Aliase *Nicko* und *Nickodemus* werden nicht mit gezählt, da diese länger als 5 Zeichen sind und sich von *Nick* unterscheiden +* wenn schon ein Alias für den User in der Datenbank vorhanden ist, dann wird dieser unverändert aus der Datenbank und ohne Systemvorschlag einfach angezeigt. + +Der User kann nun den im Eingabefeld angezeigten Alias verändern, wobei die Alias-Konventionen, wie oben im ersten Kapitel beschrieben einzuhalten und zu validieren sind. + +Mit dem Button "Eindeutigkeit prüfen" kann der im Eingabefeld stehende *Alias* auf Eindeutigkeit verifziert werden. Dabei wird dieser als Parameter einem Datenbank-Statement übergeben, das auf das Feld *Alias* in der *Users*-Tabelle ein Count mit dem übergebenen Parameter durchführt. Kommt als Ergebnis =0 zurück, ist der eingegebene *Alias* noch nicht vorhanden und kann genutzt werden. Liefert das Count-Statement einen Wert >0, dann ist dieser *Alias* schon von einem anderen User in Gebrauch und darf nicht gespeichert werden. Der User muss also seinen *Alias* erneut ändern. + +Mit dem "Speichern"-Button wird die Eindeutigkeitsprüfung erneut implizit durchgeführt, um sicherzustellen, dass keine *Alias*-Konflikte in der Datenbank gespeichert werden. Sollte wider erwarten doch ein Konflikt bei der Eindeutigkeitsprüfung auftauchen, so bleibt der Dialog im Eingabe-Modus des *Alias* geöffnet und zeigt dem User eine aussagekräftige Fehlermeldung an. + +Über das rote Icon (x) hinter dem Label "Alias ändern" kann die Eingabe bzw. das Ändern des Alias abgebrochen werden. + +Die erweiterte Gruppe E-Mail bleibt immer im Anzeige-Modus und kann selbst über den Link "E-Mail ändern" und das Stift-Icon, die beide disabled sind, nicht in den Bearbeitungsmodus versetzt werden. Die aktuell gesetzte E-Mail des Users wird im disabled Eingabefeld nur angezeigt. Das Icon unter dem Label "bestätigt" zeigt den Status der E-Mail, ob diese schon vom User bestätigt wurde oder nicht. Der Schalter für das Label "Informationen per E-Mail" bleibt von dem Switch zwischen Anzeige-Modus und Bearbeitungs-Modus unberührt, dh. es kann zu jeder Zeit vom User definiert werden, ob er über die gesetzte E-Mail Informationen erhält oder nicht. + +Es gibt in dieser Ausbaustufe-1 noch keine Möglichkeit seine E-Mail-Adresse zu ändern, da vorher die Passwort-Verschlüsselung mit allen Auswirkungen auf den Registrierungs- und Login-Prozess umgebaut werden müssen. + +#### Ausbaustufe-x + +In der weiteren Ausbaustufe, die erst möglich ist, sobald der Login-Prozess und die Passwort-Verschlüsselung darauf umgestellt ist, wird der Link "E-Mail ändern" und das Sift-Icon enabled. Damit kann der User dann das E-Mail Eingabefeld in den Bearbeitungs-Modus versetzen. + +![img](./image/LoginProfileEditEmail.png) + +Im Eingabe-Modus des E-Mail-Feldes kann der User seine E-Mail-Adresse ändern. Sobald der User die vorhandene und schon bestätigte Email-Adresse ändert, wechselt die Anzeige des Icons unter dem Label "bestätigt" vom Icon (Hacken) zum Icon (X), um die Änderung dem User gleich sichtbar zu machen. Über den Button "Speichern & Bestätigen" wird die veränderte E-Mail gegenüber den bisher gespeicherten E-Mails aller User verifiziert, dass es keine Dupletten gibt. + +Ist diese Eindeutigkeits-Prüfung erfolgreich, dann wird die geänderte E-Mail-Adresse in der Datenbank gespeichert, das Flag E-Mail-Checked auf FALSE gesetzt, damit das Bestätigt-Icon von "bestätigt" auf "unbestätigt" dem User angezeigt wird und zurück in den Anzeige-Modus der Gruppe E-Mail gewechselt. Mit der Speicherung der geänderten E-Mail wird eine Comfirmation-Email an diese E-Mail-Adresse zur Bestätigung durch den User gesendet. + +Ist diese Prüfung fehlgeschlagen, sprich es gibt die zuspeichernde E-Mail-Adresse schon in der Datenbank, dann wird das Speichern der geänderten E-Mail abgebrochen und es bleibt die zuvor gespeicherte E-Mail gültig und auch das E-Mail-Checked Flag bleibt auf dem vorherigen Status. Ob und welche Meldung dem User in dieser Situation angezeigt wird, ist noch zu definieren, um kein Ausspionieren von anderen E-Mail-Adressen zu unterstützen. Ebenfalls noch offen ist, ob an die gefundene E-Mail-Duplette eine Info-Email geschickt wird, um den User, der diese bestätigte E-Mail-Adresse besitzt, zu informieren, dass es einen Versuch gab seine E-Mail zu verwenden. + +## Backend-Services + +### UserResolver.createUser - erweitern + +#### Ausbaustufe-1 + +Der Service *createUser* wird um den Pflicht-Parameter *alias: String* erweitert. Der Wert wurde, wie oben beschrieben, im Dialog Register erfasst und gemäß den Konventionen für das Feld *alias* auch validiert - Länge und erlaubte Zeichen. + +Es wird vor jeder anderen Aktion die Eindeutigkeitsprüfung des übergebenen alias-Wertes geprüft. Dazu wird der neue Service verifyUniqueAlias() im UserResolver aufgerufen, der auch direkt vom Frontend aufgerufen werden kann. + +Liefert diese Prüfung den Wert FALSE, dann wird das Anlegen und Speichern des neuen Users abgebrochen und mit entsprechend aussagekräftiger Fehlermeldung, dass der Alias nicht eindeutig ist, an das Frontend zurückgegeben. + +Ist die Eindeutigkeitsprüfung hingegen erfolgreich, dann wird die existierende Logik zur Anlage eines neuen Users weiter ausgeführt. Dabei ist der neue Parameter *alias* in den neu angelegten User zu übertragen und in der Datenbank zu speichern. + +Alle weiteren Ausgabe-Kanäle wie Logging, EventProtokoll und Emails sind entsprechend einzubauen, aber mindestens um das neue Attribut *alias* zu ergänzen. + +### UserResolver.verifyUniqueAlias - neu + +#### Ausbaustufe-1 + +Dieser neue Service bekommt als Parameter das Attribut *alias: String* übergeben und liefert im Ergebnis TRUE, wenn der übergebene *alias* noch nicht in der Datenbank von einem anderen User verwendet wird, andernfalls FALSE. + +Dabei wird ein einfaches Datenbank-Statement auf die *Users* Tabelle abgesetzt mit einem casesensitiven Vergleich auf den Parameter mit den Werten aus der Spalte *alias* + + `SELECT count(*) FROM users where BINARY users.alias = {alias}` + +### UserResolver.updateUserInfos - erweitern + +#### Ausbaustufe-1 + +Der schon existierende Service *updateUserInfos()* wird erweitert um den Parameter *alias: String*. Sobald der User nach dem Login automatisch oder selbst interaktiv auf die Profil-Seite navigiert und dort sein Profil, insbesonderen neu das Attribut *alias* erfasst oder ändert, wird dieser Service aufgerufen. + +Die Parameter *firstName, lastName, language, password, passwordNew, alias* werden alle als optional definiert, da der User auf der Profil-Seite auswählen kann, welche Profil-Parameter er verändern möchte und somit meist nie alle Parameter gleichzeitig dieses Service initialisiert sind. + +Sobald der *alias*-Parameter gesetzt ist, wird für diesen der Service *verifyUniqueAlias()* zur Eindeutigekeitsprüfung aufgerufen, um sicherzustellen, dass der übergebene *alias* wirklich nicht schon in der Datenbank existiert. Liefert das Ergebnis von *verifyUniqueAlias()* den Wert TRUE, dann kann der übergebene *alias* in der Datenbank gespeichert werden. Anderfalls muss mit einer aussagekräftigen Fehlermeldung abgebrochen werden und es wird keiner der übergebenen Parameter in die Datenbank geschrieben. + +Alle weiteren Ausgabe-Kanäle wie Logging, EventProtokoll sind entsprechend einzubauen, aber mindestens um das neue Attribut *alias* zu ergänzen. + +#### Ausbaustufe-x + +Sobald in einer weiteren Ausbaustufe die Email auf der Profil-Seite vom User verändert werden kann, dann wird dieser Service um den optionalen Parameter *email: String* erweitert. + +Sobald der *email*-Parameter gesetzt ist, wird für diesen der Service *verifyUniqueEmail()* zur Eindeutigekeitsprüfung aufgerufen, um sicherzustellen, dass die übergebene *email* wirklich nicht schon für einen anderen User in der Datenbank existiert. Liefert das Ergebnis von *verifyUniqueEmail()* den Wert TRUE, dann kann die übergebene *email* in der Datenbank gespeichert werden. Anderfalls muss mit einer aussagekräftigen Fehlermeldung abgebrochen werden und es wird keiner der übergebenen Parameter in die Datenbank geschrieben. + +Mit dem Speichern der geänderten Email muss auch das Flag *emailChecked* auf FALSE gesetzt und gespeichert werden. Damit wird sichergestellt, dass die veränderte Email-Adresse erst noch vom User bestätigt werden muss. Dies wird direkt nach dem Speichern der Email-Adresse mit dem Versenden einer *confirmChangedEmail* an die neue Email-Adresse initiiert. Der darin enthaltene Bestätigungs-Link wird analog dem Aktivierungs-Link bei der Registrierung der Email gehandhabt. Die *confirmChangedEmail* muss nur inhaltlich vom Text anders formuliert werden als die *AccountActivation*-Email, aber bzgl. der Parameter und des enthaltenen Bestätigungslinks unterscheiden sich beide nicht. + +Sobald der User in seiner erhaltenen *confirmChangedEmail* den Link aktiviert, erfolgt der Aufruf des Service *UserResolver.queryOptIn*, um zu prüfen, ob der in dem Link enthaltene OptInCode valide und gültig ist. Falls ja, dann wird das Flag *emailChecked* auf TRUE gesetzt, anderfalls bleibt es auf FALSE und es wird mit einer aussagekräftigen Fehlermeldung abgebrochen. + +Alle weiteren Ausgabe-Kanäle wie Logging, EventProtokoll sind entsprechend einzubauen, aber mindestens um das neue Attribut *email* zu ergänzen. + +### UserResolver.verifyUniqueEmail - neu + +#### Ausbaustufe-x + +Dieser neue Service bekommt als Parameter das Attribut *email: String* übergeben und liefert im Ergebnis TRUE, wenn die übergebene *email* noch nicht in der Datenbank von einem anderen User verwendet wird, andernfalls FALSE. + +Dabei wird ein einfaches Datenbank-Statement auf die *Users* Tabelle abgesetzt mit einem casesensitiven Vergleich auf den Parameter mit den Werten aus der Spalte *email* + + `SELECT count(*) FROM users where BINARY users.email = {email}` + +## Datenbank-Migration + +Es ist für diesen UseCase keine Datenbank-Migration notwendig, da im Rahmen der Einführung der GradidoID die Spalte *alias* schon in die *Users*-Tabelle mit aufgenommen wurde. diff --git a/docu/Concepts/BusinessRequirements/graphics/Ablauf_manuelle_auto_Creations.drawio b/docu/Concepts/BusinessRequirements/graphics/Ablauf_manuelle_auto_Creations.drawio index b4f1f45ea..3c2ae0559 100644 --- a/docu/Concepts/BusinessRequirements/graphics/Ablauf_manuelle_auto_Creations.drawio +++ b/docu/Concepts/BusinessRequirements/graphics/Ablauf_manuelle_auto_Creations.drawio @@ -1,6 +1,6 @@ - + @@ -183,31 +183,31 @@ - + - + - + - + - + - + @@ -317,15 +317,15 @@ - + - + - + @@ -334,10 +334,10 @@ - + - + @@ -351,7 +351,7 @@ - + @@ -359,8 +359,76 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docu/Concepts/BusinessRequirements/graphics/GradidoÜbersichtMaske.bmpr b/docu/Concepts/BusinessRequirements/graphics/GradidoÜbersichtMaske.bmpr index 19be09a9c..86ffadba4 100644 Binary files a/docu/Concepts/BusinessRequirements/graphics/GradidoÜbersichtMaske.bmpr and b/docu/Concepts/BusinessRequirements/graphics/GradidoÜbersichtMaske.bmpr differ diff --git a/docu/Concepts/BusinessRequirements/graphics/UC_ManuelleUserRegistrierung.bmpr b/docu/Concepts/BusinessRequirements/graphics/UC_ManuelleUserRegistrierung.bmpr new file mode 100644 index 000000000..885ce6762 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/graphics/UC_ManuelleUserRegistrierung.bmpr differ diff --git a/docu/Concepts/BusinessRequirements/graphics/UC_Set_UserAlias.bmpr b/docu/Concepts/BusinessRequirements/graphics/UC_Set_UserAlias.bmpr new file mode 100644 index 000000000..f06dc22c8 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/graphics/UC_Set_UserAlias.bmpr differ diff --git a/docu/Concepts/BusinessRequirements/image/Ablauf_manuelle_auto_Creations_2.png b/docu/Concepts/BusinessRequirements/image/Ablauf_manuelle_auto_Creations_2.png new file mode 100644 index 000000000..4211f65cf Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Ablauf_manuelle_auto_Creations_2.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-CreateUser.png b/docu/Concepts/BusinessRequirements/image/Admin-CreateUser.png new file mode 100644 index 000000000..5b15b27e2 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-CreateUser.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-ActivatedOneTimePasswort.png b/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-ActivatedOneTimePasswort.png new file mode 100644 index 000000000..1f6ad0b73 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-ActivatedOneTimePasswort.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-Details.png b/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-Details.png new file mode 100644 index 000000000..ce2cd2ede Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-UserAccount-Details.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-UserSearch.png b/docu/Concepts/BusinessRequirements/image/Admin-UserSearch.png new file mode 100644 index 000000000..3d59dd7cb Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-UserSearch.png differ diff --git a/docu/Concepts/BusinessRequirements/image/Admin-UserSearch_inaktivAccount.png b/docu/Concepts/BusinessRequirements/image/Admin-UserSearch_inaktivAccount.png new file mode 100644 index 000000000..e8ddb5f36 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/Admin-UserSearch_inaktivAccount.png differ diff --git a/docu/Concepts/BusinessRequirements/image/DB-Diagramm_Contributions_Stufe_2.png b/docu/Concepts/BusinessRequirements/image/DB-Diagramm_Contributions_Stufe_2.png new file mode 100644 index 000000000..eee7fa23f Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/DB-Diagramm_Contributions_Stufe_2.png differ diff --git a/docu/Concepts/BusinessRequirements/image/LoginProfileEditAlias.png b/docu/Concepts/BusinessRequirements/image/LoginProfileEditAlias.png new file mode 100644 index 000000000..b516c825e Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/LoginProfileEditAlias.png differ diff --git a/docu/Concepts/BusinessRequirements/image/LoginProfileEditEmail.png b/docu/Concepts/BusinessRequirements/image/LoginProfileEditEmail.png new file mode 100644 index 000000000..b96462784 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/LoginProfileEditEmail.png differ diff --git a/docu/Concepts/BusinessRequirements/image/LoginProfileWithAlias.png b/docu/Concepts/BusinessRequirements/image/LoginProfileWithAlias.png new file mode 100644 index 000000000..5a3fc29cb Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/LoginProfileWithAlias.png differ diff --git a/docu/Concepts/BusinessRequirements/image/LoginWithAlias.png b/docu/Concepts/BusinessRequirements/image/LoginWithAlias.png new file mode 100644 index 000000000..1f5852795 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/LoginWithAlias.png differ diff --git a/docu/Concepts/BusinessRequirements/image/One-Time-Passwort-Login.png b/docu/Concepts/BusinessRequirements/image/One-Time-Passwort-Login.png new file mode 100644 index 000000000..3e275db94 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/One-Time-Passwort-Login.png differ diff --git a/docu/Concepts/BusinessRequirements/image/RegisterWithAlias.png b/docu/Concepts/BusinessRequirements/image/RegisterWithAlias.png new file mode 100644 index 000000000..bb25b7ff6 Binary files /dev/null and b/docu/Concepts/BusinessRequirements/image/RegisterWithAlias.png differ diff --git a/docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new ContributionSend.png b/docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new_ContributionSend.png similarity index 100% rename from docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new ContributionSend.png rename to docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new_ContributionSend.png diff --git a/docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new ContributionZyklus.png b/docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new_ContributionZyklus.png similarity index 100% rename from docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new ContributionZyklus.png rename to docu/Concepts/BusinessRequirements/image/UC_Send_Contribution_Admin-new_ContributionZyklus.png diff --git a/docu/Concepts/TechnicalRequirements/BusinessEventProtocol.md b/docu/Concepts/TechnicalRequirements/BusinessEventProtocol.md index ecdf8df34..dfafe11dd 100644 --- a/docu/Concepts/TechnicalRequirements/BusinessEventProtocol.md +++ b/docu/Concepts/TechnicalRequirements/BusinessEventProtocol.md @@ -62,13 +62,13 @@ The business events will be stored in database in the new table `EventProtocol`. The following table lists for each event type the mapping between old and new key, the mandatory attributes, which have to be initialized at event occurence and to be written in the database event protocol table: -| EventType - old key | EventType - new key | id | type | createdAt | userID | XuserID | XCommunityID | transactionID | contribID | amount | +| EventKey | EventType | id | type | createdAt | userID | XuserID | XCommunityID | transactionID | contribID | amount | | :-------------------------------- | :------------------------------------- | :-: | :--: | :-------: | :----: | :-----: | :----------: | :-----------: | :-------: | :----: | | BASIC | BasicEvent | x | x | x | | | | | | | | VISIT_GRADIDO | VisitGradidoEvent | x | x | x | | | | | | | | REGISTER | RegisterEvent | x | x | x | x | | | | | | | LOGIN | LoginEvent | x | x | x | x | | | | | | -| | VerifyRedeemEvent | | | | | | | | | | +| VERIFY_REDEEM | VerifyRedeemEvent | x | x | x | x | | | (x) | (x) | | | REDEEM_REGISTER | RedeemRegisterEvent | x | x | x | x | | | (x) | (x) | | | REDEEM_LOGIN | RedeemLoginEvent | x | x | x | x | | | (x) | (x) | | | ACTIVATE_ACCOUNT | ActivateAccountEvent | x | x | x | x | | | | | | @@ -82,20 +82,20 @@ The following table lists for each event type the mapping between old and new ke | TRANSACTION_SEND_REDEEM | TransactionLinkRedeemEvent | x | x | x | x | x | x | x | | x | | CONTRIBUTION_CREATE | ContributionCreateEvent | x | x | x | x | | | | x | x | | CONTRIBUTION_CONFIRM | ContributionConfirmEvent | x | x | x | x | x | x | | x | x | -| | ContributionDenyEvent | x | x | x | x | x | x | | x | x | +| CONTRIBUTION_DENY | ContributionDenyEvent | x | x | x | x | x | x | | x | x | | CONTRIBUTION_LINK_DEFINE | ContributionLinkDefineEvent | x | x | x | x | | | | | x | | CONTRIBUTION_LINK_ACTIVATE_REDEEM | ContributionLinkRedeemEvent | x | x | x | x | | | | x | x | -| | UserCreateContributionMessageEvent | x | x | x | x | | | | x | x | -| | AdminCreateContributionMessageEvent | x | x | x | x | | | | x | x | -| | LogoutEvent | x | x | x | x | | | | x | x | +| USER_CREATES_CONTRIBUTION_MESSAGE | UserCreateContributionMessageEvent | x | x | x | x | | | | x | x | +| ADMIN_CREATES_CONTRIBUTION_MESSAGE | AdminCreateContributionMessageEvent | x | x | x | x | | | | x | x | +| LOGOUT | LogoutEvent | x | x | x | x | | | | | | | SEND_CONFIRMATION_EMAIL | SendConfirmEmailEvent | x | x | x | x | | | | | | -| | SendAccountMultiRegistrationEmailEvent | x | x | x | x | | | | | | -| | SendForgotPasswordEmailEvent | x | x | x | x | | | | | | -| | SendTransactionSendEmailEvent | x | x | x | x | x | x | x | | x | -| | SendTransactionReceiveEmailEvent | x | x | x | x | x | x | x | | x | -| | SendAddedContributionEmailEvent | x | x | x | x | | | | x | x | -| | SendContributionConfirmEmailEvent | x | x | x | x | | | | x | x | -| | SendTransactionLinkRedeemEmailEvent | x | x | x | x | x | x | x | | x | +| SEND_ACCOUNT_MULTIREGISTRATION_EMAIL | SendAccountMultiRegistrationEmailEvent | x | x | x | x | | | | | | +| SEND_FORGOT_PASSWORD_EMAIL | SendForgotPasswordEmailEvent | x | x | x | x | | | | | | +| SEND_TRANSACTION_SEND_EMAIL | SendTransactionSendEmailEvent | x | x | x | x | x | x | x | | x | +| SEND_TRANSACTION_RECEIVE_EMAIL | SendTransactionReceiveEmailEvent | x | x | x | x | x | x | x | | x | +| SEND_ADDED_CONTRIBUTION_EMAIL | SendAddedContributionEmailEvent | x | x | x | x | | | | x | x | +| SEND_CONTRIBUTION_CONFIRM_EMAIL | SendContributionConfirmEmailEvent | x | x | x | x | | | | x | x | +| SEND_TRANSACTION_LINK_REDEEM_EMAIL | SendTransactionLinkRedeemEmailEvent | x | x | x | x | x | x | x | | x | | TRANSACTION_REPEATE_REDEEM | - | | | | | | | | | | | TRANSACTION_RECEIVE_REDEEM | - | | | | | | | | | | diff --git a/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md b/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md index 9d607ba97..a6ca83bfc 100644 --- a/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md +++ b/docu/Concepts/TechnicalRequirements/UC_Introduction_of_Gradido-ID.md @@ -10,30 +10,27 @@ Additionally the Gradido-ID allows to administrade any user account data like ch The formalized definition of the Gradido-ID can be found in the document [BenutzerVerwaltung#Gradido-ID](../BusinessRequirements/BenutzerVerwaltung#Gradido-ID). -## Steps of Introduction +## 1st Stage -To Introduce the Gradido-ID there are several steps necessary. The first step is to define a proper database schema with additional columns and tables followed by data migration steps to add or initialize the new columns and tables by keeping valid data at all. +The 1st stage of introducing the Gradido-ID contains several steps. The first step is to define a proper database schema with additional columns and tables followed by data migration steps to add or initialize the new columns and tables by keeping valid data at all. -The second step is to decribe all concerning business logic processes, which have to be adapted by introducing the Gradido-ID. +The second step is to decribe all concerning business logic processes, which have to be adapted by introducing the Gradido-ID and handling the attributes of the new user_contacts table. ### Database-Schema #### Users-Table -The entity users has to be changed by adding the following columns. +The entity users has to be changed by adding the following columns. The column State gives a hint about the working state including the ticket number. -| Column | Type | Description | -| ------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------- | -| gradidoID | String | technical unique key of the user as UUID (version 4) | -| alias | String | a business unique key of the user | -| passphraseEncryptionType | int | defines the type of encrypting the passphrase: 1 = email (default), 2 = gradidoID, ... | -| emailID | int | technical foreign key to the entry with type Email and contactChannel=maincontact of the new entity UserContacts | +| State | Column | Type | Description | +| -------------- | --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| *done #2125* | gradidoID | String | technical unique key of the user as UUID (version 4) | +| *done #2125* | alias | String | a business unique key of the user | +| *done #2165* | emailID | int | technical foreign key to the UserContacts-Table with the entry of type Email, which will be interpreted as the maincontact from the Users table point of view | ##### Email vs emailID -The existing column `email`, will now be changed to the primary email contact, which will be stored as a contact entry in the new `UserContacts` table. It is necessary to decide if the content of the `email `will be changed to the foreign key `emailID `to the contact entry with the email address or if the email itself will be kept as a denormalized and duplicate value in the `users `table. - -The preferred and proper solution will be to add a new column `Users.emailId `as foreign key to the `UsersContact `entry and delete the `Users.email` column after the migration of the email address in the `UsersContact `table. +The existing column `email`, will now be changed to the primary email contact, which will be stored as a contact entry in the new `UserContacts` table. #### new UserContacts-Table @@ -55,17 +52,21 @@ A new entity `UserContacts `is introduced to store several contacts of different | phone | String | defines the address of a contact entry of type Phone | | contactChannels | String | define the contact channel as comma separated list for which this entry is confirmed by the user e.g. main contact (default), infomail, contracting, advertisings, ... | +##### ToDo: + +The UserContacts, expecially the email contacts, will for future be categorized to communication channels for example to allow the user to define which information he will get on which email-contact (aspects of administration, contract, advertising, etc.) + ### Database-Migration After the adaption of the database schema and to keep valid consistent data, there must be several steps of data migration to initialize the new and changed columns and tables. -#### Initialize GradidoID +#### Initialize GradidoID (done #2125) In a one-time migration create for each entry of the `Users `tabel an unique UUID (version4). -#### Primary Email Contact +#### Primary Email Contact (done #1798) -In a one-time migration read for each entry of the `Users `table the `Users.id` and `Users.email`, select from the table `login_email_opt_in` the entry with the `login_email_opt_in.user_id` = `Users.id` and create a new entry in the `UsersContact `table, by initializing the contact-values with: +In a one-time migration read for each entry of the `Users `table the `Users.id` and `Users.email` and create for it a new entry in the `UserContacts `table, by initializing the contact-values with: * id = new technical key * type = Enum-Email @@ -88,72 +89,130 @@ After this one-time migration and a verification, which ensures that all data ar The following logic or business processes has to be adapted for introducing the Gradido-ID -#### Read-Write Access of Users-Table especially Email +#### Capturing of alias + +To avoid using the email as primary identifier it is necessary to introduce a capturing of the alias. It is not a good solution to create for existing users an individual alias by a migration. So each user should capture his own alias during registration- and/or login-process. + +These requirements are described in the concept document [../BusinessRequirements/UC_Set_UserAlias.md]() **(done #2144)** and the implementation of these requirements will be the prerequisite for changing the login-process from single email-identifier to the future identifiers alias / gradidoID / email. + +#### Read-Write Access of Users-Table especially Email (done #1798) The ORM mapping has to be adapted to the changed and new database schema. -#### Registration Process +#### Create and Update User Processes -The logic of the registration process has to be adapted by +The logic of the create and update user process has to be adapted by -* initializing the `Users.userID` with a unique UUID -* creating a new `UsersContact `entry with the given email address and *maincontact* as `usedChannel ` -* set `emailID `in the `Users `table as foreign key to the new `UsersContact `entry -* set `Users.passphraseEncrpytionType = 2` and encrypt the passphrase with the `Users.userID` instead of the `UsersContact.email` +* creating a new User including with a unique UUID-V4 **(done #2125)** +* creating a new `UserContacts `entry with the given email address **(#2165)** +* set `emailID `in the `Users `table as foreign key to the new `UserContacts `entry **(#2165)** +* handling the new emailXXX attributes in the `user_contacts `table previously in the `email_opt_in `table **(#2165)** -#### Login Process +#### Search User Processes (#2165) -The logic of the login process has to be adapted by +The logic of all processes where the user is searched has to be adapted by -* search the users data by reading the `Users `and the `UsersContact` table with the email (or alias as soon as the user can maintain his profil with an alias) as input -* depending on the `Users.passphraseEncryptionType` decrypt the stored password - * = 1 : with the email - * = 2 : with the userID +* always search a *user* with its relation "emailContact" to load the associated userContact with his email +* a search user by *email* has to be implemented by searching a `userContact `for the given *email* and its relation "user" to load the associated user to this email + +#### Password Processes (#2165) + +The logic of all password processes has to be adapted by + +* read the *emailXXX* attributes out of the `user_contacts `table instead of previoulsy from the `email_opt_in `table +* writing or updating the *emailXXX* attributes now in the `user_contact `table instead of previously in the `email_opt_in `table +* the logic how to de/encrypt the password will not part of this 1st stage of introduction of the gradidoID. This will be part of the 2nd stage + +## 2nd Stage + +In the 2nd stage of this topic the password handling during registration and login process will be changed. These change must keep the current active password handling where the email is part of the encryption as long as all users are shifted to the new logic of password handling where the gradidoID will part of the encryption. This means there must be a kind of versioning which type of password encryption is used. Because some users will not login for a long time, which causes to use the old password encryption at their login process or in the future there could be the requirement to change the password handling to newer and safer algorithms. + +### Database-Schema + +#### Users-Table + +The entity *users* has to be changed by + +| Action | Column | Type | Description | +| :----: | ---------------------- | ---------- | ----------------------------------------------------------------------------------- | +| add | passwordEncryptionType | int | defines the type of encrypting the password: default 1 = email, 2 = gradidoID, ... | +| delete | public_key | binary(32) | before deletion verify and ensure that realy not in use even for encryption type 1 | +| delete | privkey | binary(80) | before deletion verify and ensure that realy not in use even for encryption type 1 | +| delete | email_hash | binary(32) | before deletion verify and ensure that realy not in use even for encryption type 1 | +| delete | passphrase | text | before deletion verify and ensure that realy not in use even for encryption type 1 | + +### Adaption of BusinessLogic #### Password En/Decryption -The logic of the password en/decryption has to be adapted by encapsulate the logic to be controlled with an input parameter. The input parameter can be the email or the userID. +The logic of the existing password en/decryption has to be shifted out of the ***UserResolver.js*** file in separated file(s). This separated file will be placed in the package-directory `backend/src/password` and named ***emailEncryptor.js***. As the name express the password encryption uses the `email `attribute. + +For the new password encryption logic a new file named ***gradidoIDEncryptor.js*** has to be created in the package-directory `backend/src/password`, which uses the *gradidoID* instead of the *email* for the password encryption. As soon as a user is changed to this encryption type with the *gradidoID*, it will be possible for him to change his *email* in his gradido-profile without any effect on his password encryption. + +For possible future requirements of newer and safer encryption logic additional files can be placed in the same directory with an expressiv file name for the new encryption type. + +All these `xxxEncryptor `files has to implement the following API, but with possibly different parameter types, depending on the encryption requirements: + +| API | emailEncryptor | gradidoIDEncryptor | return | description | +| ------------------------- | ---------------- | ------------------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| **encryptPassword** | dbUser, password | dbUser, password | encrypted password | process the encryption with
the encryptor specific attributs
out of the dbUser and the original 
password entered by the user | +| **verifyPassword** | dbUser, password | dbUser, password | boolean | process the decryption with
the encryptor specific attributs
out of the dbUser and the original
password entrered by the user | +| **isPassword** | password | password | boolean | verifiy the formal rules of the original
password entered by the user | + +Which of the *xxxEncryptor* implementations will be used, depends on the value of the attribute `user.passwordEncryptionType`, which has to be interpreted before. To encapsulate this logic from the general business logic the ***Encryptor.js*** will be created with the same API as the specific *encryptor* classes, but it will interpret the attribute `dbUser.passwordEncryptionType` to select and invoke the correct *encryptor* implementation and to decide if an upgrade to a newer *encryptor* class should be done. + +The new Enum `PasswordEncryptionType `with the increasing values: + +* 1 = emailEncryptor +* 2 = gradidoIDEncryptor +* ... = ? + +will be used to define the order, which encryptor implementation is the oldest and the newest. That means if a user is still not using the newest *encryptor* for his password encryption the logic will implicit start a change to the newest *encryptor*. In all business processes, where the user enters his password the invokation of the ***Encryptor.js*** has to be introduced, because without the original entered password from the user no *encryptor* upgrade can be done. + +#### Registration Process + +The backend logic of the registration process has to be adapted + +* the ***UserResolver.createUser*** logic has to be changed by setting for a new user the attribut `Users.passwordEncrpytionType = 2` +* As soon as the user activates the email-confirmation link `https://gradido.net/checkEmail/` the application frontend invokes + + * at first the ***UserResolver.queryOptIn*** method, which will not be necessary, because the same checks about the given *emailOptIn*-code will be done a 2nd time in the invocation of *UserResolver.setPassword* + * at second the ***UserResolver.setPassword*** method, which has to be changed + * to use the new ***Encryptor.isPassword*** to validate the formal rules of the given password + * to remove all cryptographic logic like passphrase and key pair generation and password hashing to the new ***emailEncryptor.js*** + * to introduce the invocation of the new ***Encryptor.encryptPassword*** in the existing logic flow + +#### Login Process + +The logic of the login process has to be adapted in frontend and backend + +* Frontend + * The login dialog has to be changed at the email input component + * the new label contains "Email / Alias / GradidoID" + * the validation of the input field has to be changed to accept the input of one of these three possible values + * in case of failed validation an expressiv error message for the specific given input has to be shown (for more details about the rules for alias and gradidoID see the concepts [UC_SetUserAlias.md](../BusinessRequirements/UC_SetUserAlias.md) and [BenutzerVerwaltung#Gradido-ID](../BusinessRequirements/BenutzerVerwaltung#Gradido-ID)). + * The signature of the backend invocation ***UserResolver.login*** has to be changed to accept all three variants of identifiers + * depending on the implemented backend solution the frontend has to detect and initialize the correct parameter settings +* Backend + * The signature of the backend invocation ***UserResolver.login*** has to be changed to accept all three variants of identifiers + * solution-A: the first parameter *email* is renamed to *identifier* and the backend has to detect which type of identifier is given + * solution-B: two additional parameters *alias* and *gradidoID* are inserted in the type ***UnsecureLoginArgs*** and the frontend has to decide, which type of identifier is given and initialize the correct parameter + * **TODO**: solution-A is preferred? + * The logic of ***UserResolver.login*** has to be changed by + * in case of solution-A for the signature, the given identifier has to be detected for the correct user searching + * the user to be searched by the given identifier (email / alias / gradidoID) + * if a user could be found all the existing checks will be done as is, except the public and private key check, which will be removed + * for the password check the new ***Encryptor.isPassword*** and ***Encryptor.verifyPassword*** has to be invoked; all existing cryptographic logic has to be deleted #### Change Password Process -The logic of change password has to be adapted by +There are two ways to change a user password. -* if the `Users.passphraseEncryptionType` = 1, then +The first one is the *Forget-Password process*, which will use the same backend invocation with activating the email link like the *Registration Process* to set the password; for details see description above. - * read the users email address from the `UsersContact `table - * give the email address as input for the password decryption of the existing password - * use the `Users.userID` as input for the password encryption for the new password - * change the `Users.passphraseEnrycptionType` to the new value =2 -* if the `Users.passphraseEncryptionType` = 2, then +The second one is the *Update-Userinfo process*, which invokes the ***UserResolver.updateUserInfos***. This method has to be changed in the *password check block* by - * give the `Users.userID` as input for the password decryption of the existing password - * use the `Users.userID` as input for the password encryption fo the new password - -#### Search- and Access Logic - -A new logic has to be introduced to search the user identity per different input values. That means searching the user data must be possible by - -* searching per email (only with maincontact as contactchannel) -* searching per userID -* searching per alias - -#### Identity-Mapping - -A new mapping logic will be necessary to allow using unmigrated APIs like GDT-servers api. So it must be possible to give this identity-mapping logic the following input to get the respective output: - -* email -> userID -* email -> gradidoID -* email -> alias -* userID -> gradidoID -* userID -> email -* userID -> alias -* alias -> gradidoID -* alias -> email -* alias -> userID -* gradidoID -> email -* gradidoID -> userID -* gradidoID -> alias - -#### GDT-Access - -To use the GDT-servers api the used identifier for GDT has to be switch from email to userID. +* removing all the cryptographic logic and +* invoke the new ***Encryptor.isPassword*** for the given *newPassword* and if valid then +* invoke the new ***Encryptor.verifyPassword*** for the given *oldPassword* and if valid then +* invoke the new ***Encryptor.encryptPassword*** for the given *newPassword* diff --git a/e2e-tests/cypress/tests/cypress.config.ts b/e2e-tests/cypress/tests/cypress.config.ts index ad6a8d7de..9621b7a00 100644 --- a/e2e-tests/cypress/tests/cypress.config.ts +++ b/e2e-tests/cypress/tests/cypress.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ supportFile: "cypress/support/index.ts", viewportHeight: 720, viewportWidth: 1280, + video: false, retries: { runMode: 2, openMode: 0, diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a9d7572f2..a93199fad 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -20,10 +20,10 @@ ENV PORT="3000" # Labels LABEL org.label-schema.build-date="${BUILD_DATE}" LABEL org.label-schema.name="gradido:frontend" -LABEL org.label-schema.description="Gradido Vue Webwallet" -LABEL org.label-schema.usage="https://github.com/gradido/gradido_vue_wallet/blob/master/README.md" +LABEL org.label-schema.description="Gradido Wallet Interface" +LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md" LABEL org.label-schema.url="https://gradido.net" -LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido_vue_wallet/tree/master/backend" +LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/frontend" LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}" LABEL org.label-schema.vendor="gradido Community" LABEL org.label-schema.version="${BUILD_VERSION}" diff --git a/frontend/package.json b/frontend/package.json index 011193b58..4e983d716 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bootstrap-vue-gradido-wallet", - "version": "1.12.1", + "version": "1.13.3", "private": true, "scripts": { "start": "node run/server.js", diff --git a/frontend/src/App.spec.js b/frontend/src/App.spec.js index 79467e2a8..77adc7084 100644 --- a/frontend/src/App.spec.js +++ b/frontend/src/App.spec.js @@ -25,6 +25,7 @@ describe('App', () => { meta: { requiresAuth: false, }, + params: {}, }, } diff --git a/frontend/src/assets/scss/custom/gradido-custom/_color.scss b/frontend/src/assets/scss/custom/gradido-custom/_color.scss index 20fcbefd6..f42555adf 100644 --- a/frontend/src/assets/scss/custom/gradido-custom/_color.scss +++ b/frontend/src/assets/scss/custom/gradido-custom/_color.scss @@ -33,7 +33,9 @@ $indigo: #5603ad !default; $purple: #8965e0 !default; $pink: #f3a4b5 !default; $red: #f5365c !default; -$orange: #fb6340 !default; + +// $orange: #fb6340 !default; +$orange: #8c0505 !default; $yellow: #ffd600 !default; $green: #2dce89 !default; $teal: #11cdef !default; diff --git a/frontend/src/components/Auth/AuthNavbar.vue b/frontend/src/components/Auth/AuthNavbar.vue index 73602c48a..ade6340a6 100644 --- a/frontend/src/components/Auth/AuthNavbar.vue +++ b/frontend/src/components/Auth/AuthNavbar.vue @@ -18,9 +18,9 @@ - {{ $t('signup') }} + {{ $t('signup') }} {{ $t('math.pipe') }} - {{ $t('signin') }} + {{ $t('signin') }} @@ -28,8 +28,11 @@ diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js index 7504854d0..1a918747f 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js +++ b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.spec.js @@ -5,9 +5,11 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue' const localVue = global.localVue let wrapper +const dateMock = jest.fn((d) => d) + const mocks = { $t: jest.fn((t) => t), - $d: jest.fn((d) => d), + $d: dateMock, $store: { state: { firstName: 'Peter', @@ -175,4 +177,127 @@ describe('ContributionMessagesListItem', () => { }) }) }) + + describe('links in contribtion message', () => { + const propsData = { + message: { + id: 111, + message: 'Lorem ipsum?', + createdAt: '2022-08-29T12:23:27.000Z', + updatedAt: null, + type: 'DIALOG', + userFirstName: 'Peter', + userLastName: 'Lustig', + userId: 107, + __typename: 'ContributionMessage', + }, + } + + const ModeratorItemWrapper = () => { + return mount(ContributionMessagesListItem, { + localVue, + mocks, + propsData, + }) + } + + let messageField + + describe('message of only one link', () => { + beforeEach(() => { + propsData.message.message = 'https://gradido.net/de/' + wrapper = ModeratorItemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)') + }) + + it('contains the link as text', () => { + expect(messageField.text()).toBe('https://gradido.net/de/') + }) + + it('contains a link to the given address', () => { + expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/') + }) + }) + + describe('message with text and two links', () => { + beforeEach(() => { + propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/ +and here is the link to the repository: https://github.com/gradido/gradido` + wrapper = ModeratorItemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)') + }) + + it('contains the whole text', () => { + expect(messageField.text()) + .toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/ +and here is the link to the repository: https://github.com/gradido/gradido`) + }) + + it('contains the two links', () => { + expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/') + expect(messageField.findAll('a').at(1).attributes('href')).toBe( + 'https://github.com/gradido/gradido', + ) + }) + }) + }) + + describe('contribution message type HISTORY', () => { + const propsData = { + message: { + id: 111, + message: `Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time) +--- +This message also contains a link: https://gradido.net/de/ +--- +350.00`, + createdAt: '2022-08-29T12:23:27.000Z', + updatedAt: null, + type: 'HISTORY', + userFirstName: 'Peter', + userLastName: 'Lustig', + userId: 107, + __typename: 'ContributionMessage', + }, + } + + const itemWrapper = () => { + return mount(ContributionMessagesListItem, { + localVue, + mocks, + propsData, + }) + } + + let messageField + + describe('render HISTORY message', () => { + beforeEach(() => { + jest.clearAllMocks() + wrapper = itemWrapper() + messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)') + }) + + it('renders the date', () => { + expect(dateMock).toBeCalledWith( + new Date('Sun Nov 13 2022 13:05:48 GMT+0100 (Central European Standard Time'), + 'short', + ) + }) + + it('renders the amount', () => { + expect(messageField.text()).toContain('350.00 GDD') + }) + + it('contains the link as text', () => { + expect(messageField.text()).toContain( + 'This message also contains a link: https://gradido.net/de/', + ) + }) + + it('contains a link to the given address', () => { + expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/') + }) + }) + }) }) diff --git a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue index 6c2e555f2..5862f97f5 100644 --- a/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue +++ b/frontend/src/components/ContributionMessages/ContributionMessagesListItem.vue @@ -1,24 +1,29 @@ diff --git a/frontend/src/components/Contributions/ContributionForm.spec.js b/frontend/src/components/Contributions/ContributionForm.spec.js index cf47577a3..3af716d36 100644 --- a/frontend/src/components/Contributions/ContributionForm.spec.js +++ b/frontend/src/components/Contributions/ContributionForm.spec.js @@ -198,6 +198,75 @@ describe('ContributionForm', () => { }) }) }) + + describe('date with the 31st day of the month', () => { + describe('same month', () => { + beforeEach(async () => { + await wrapper.setData({ + maximalDate: new Date('2022-10-31T00:00:00.000Z'), + form: { date: new Date('2022-10-31T00:00:00.000Z') }, + }) + }) + + describe('minimalDate', () => { + it('has "2022-09-01T00:00:00.000Z"', () => { + expect(wrapper.vm.minimalDate.toISOString()).toBe('2022-09-01T00:00:00.000Z') + }) + }) + + describe('isThisMonth', () => { + it('has true', () => { + expect(wrapper.vm.isThisMonth).toBe(true) + }) + }) + }) + }) + + describe('date with the 28th day of the month', () => { + describe('same month', () => { + beforeEach(async () => { + await wrapper.setData({ + maximalDate: new Date('2023-02-28T00:00:00.000Z'), + form: { date: new Date('2023-02-28T00:00:00.000Z') }, + }) + }) + + describe('minimalDate', () => { + it('has "2023-01-01T00:00:00.000Z"', () => { + expect(wrapper.vm.minimalDate.toISOString()).toBe('2023-01-01T00:00:00.000Z') + }) + }) + + describe('isThisMonth', () => { + it('has true', () => { + expect(wrapper.vm.isThisMonth).toBe(true) + }) + }) + }) + }) + + describe('date with 29.02.2024 leap year', () => { + describe('same month', () => { + beforeEach(async () => { + await wrapper.setData({ + maximalDate: new Date('2024-02-29T00:00:00.000Z'), + form: { date: new Date('2024-02-29T00:00:00.000Z') }, + }) + }) + + describe('minimalDate', () => { + it('has "2024-01-01T00:00:00.000Z"', () => { + expect(wrapper.vm.minimalDate.toISOString()).toBe('2024-01-01T00:00:00.000Z') + }) + }) + + describe('isThisMonth', () => { + it('has true', () => { + expect(wrapper.vm.isThisMonth).toBe(true) + }) + }) + }) + }) }) describe('set contrubtion', () => { @@ -329,7 +398,8 @@ describe('ContributionForm', () => { describe('invalid form data', () => { beforeEach(async () => { - await wrapper.findComponent({ name: 'BFormDatepicker' }).vm.$emit('input', now) + // skip this precondition as long as the datepicker is disabled in the component + // await wrapper.findComponent({ name: 'BFormDatepicker' }).vm.$emit('input', now) await wrapper.find('#contribution-amount').find('input').setValue('200') }) diff --git a/frontend/src/components/Contributions/ContributionForm.vue b/frontend/src/components/Contributions/ContributionForm.vue index 47f2be4c4..3884fd5b4 100644 --- a/frontend/src/components/Contributions/ContributionForm.vue +++ b/frontend/src/components/Contributions/ContributionForm.vue @@ -25,6 +25,7 @@ reset-value="" :label-no-date-selected="$t('contribution.noDateSelected')" required + :disabled="this.form.id !== null" > @@ -87,6 +88,8 @@ diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index 9846784d5..3156c2861 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -136,3 +136,27 @@ export const createContributionMessage = gql` } } ` + +export const login = gql` + mutation($email: String!, $password: String!, $publisherId: Int) { + login(email: $email, password: $password, publisherId: $publisherId) { + email + firstName + lastName + language + klickTipp { + newsletterState + } + hasElopage + publisherId + isAdmin + creation + } + } +` + +export const logout = gql` + mutation { + logout + } +` diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index 07b016d0a..1c910a23e 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -1,23 +1,5 @@ import gql from 'graphql-tag' -export const login = gql` - query($email: String!, $password: String!, $publisherId: Int) { - login(email: $email, password: $password, publisherId: $publisherId) { - email - firstName - lastName - language - klickTipp { - newsletterState - } - hasElopage - publisherId - isAdmin - creation - } - } -` - export const verifyLogin = gql` query { verifyLogin { @@ -36,12 +18,6 @@ export const verifyLogin = gql` } ` -export const logout = gql` - query { - logout - } -` - export const transactionsQuery = gql` query($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) { transactionList(currentPage: $currentPage, pageSize: $pageSize, order: $order) { @@ -61,6 +37,7 @@ export const transactionsQuery = gql` linkedUser { firstName lastName + email } decay { decay @@ -68,9 +45,6 @@ export const transactionsQuery = gql` end duration } - linkedUser { - email - } transactionLinkId } } diff --git a/frontend/src/layouts/AuthLayout.spec.js b/frontend/src/layouts/AuthLayout.spec.js index 4a1d7fef0..8d9411a71 100644 --- a/frontend/src/layouts/AuthLayout.spec.js +++ b/frontend/src/layouts/AuthLayout.spec.js @@ -19,6 +19,7 @@ describe('AuthLayout', () => { meta: { requiresAuth: false, }, + params: {}, }, } diff --git a/frontend/src/layouts/DashboardLayout.spec.js b/frontend/src/layouts/DashboardLayout.spec.js index 398724201..846974781 100644 --- a/frontend/src/layouts/DashboardLayout.spec.js +++ b/frontend/src/layouts/DashboardLayout.spec.js @@ -18,6 +18,7 @@ const apolloMock = jest.fn().mockResolvedValue({ logout: 'success', }, }) +const apolloQueryMock = jest.fn() describe('DashboardLayout', () => { let wrapper @@ -40,7 +41,8 @@ describe('DashboardLayout', () => { }, }, $apollo: { - query: apolloMock, + mutate: apolloMock, + query: apolloQueryMock, }, $store: { state: { @@ -142,7 +144,7 @@ describe('DashboardLayout', () => { describe('update transactions', () => { beforeEach(async () => { - apolloMock.mockResolvedValue({ + apolloQueryMock.mockResolvedValue({ data: { transactionList: { balance: { @@ -163,7 +165,7 @@ describe('DashboardLayout', () => { }) it('calls the API', () => { - expect(apolloMock).toBeCalledWith( + expect(apolloQueryMock).toBeCalledWith( expect.objectContaining({ variables: { currentPage: 2, @@ -201,7 +203,7 @@ describe('DashboardLayout', () => { describe('update transactions returns error', () => { beforeEach(async () => { - apolloMock.mockRejectedValue({ + apolloQueryMock.mockRejectedValue({ message: 'Ouch!', }) await wrapper diff --git a/frontend/src/layouts/DashboardLayout.vue b/frontend/src/layouts/DashboardLayout.vue index 8e778ab01..2a103a574 100755 --- a/frontend/src/layouts/DashboardLayout.vue +++ b/frontend/src/layouts/DashboardLayout.vue @@ -41,7 +41,8 @@ import Navbar from '@/components/Menu/Navbar.vue' import Sidebar from '@/components/Menu/Sidebar.vue' import SessionLogoutTimeout from '@/components/SessionLogoutTimeout.vue' -import { logout, transactionsQuery } from '@/graphql/queries' +import { transactionsQuery } from '@/graphql/queries' +import { logout } from '@/graphql/mutations' import ContentFooter from '@/components/ContentFooter.vue' import { FadeTransition } from 'vue2-transitions' import CONFIG from '@/config' @@ -75,8 +76,8 @@ export default { methods: { async logout() { this.$apollo - .query({ - query: logout, + .mutate({ + mutation: logout, }) .then(() => { this.$store.dispatch('logout') diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 1ed8af19f..aa1b02871 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -24,16 +24,18 @@ "moderator": "Moderator", "moderators": "Moderatoren", "myContributions": "Meine Beiträge zum Gemeinwohl", - "openContributionLinks": "öffentliche Beitrags-Linkliste", + "noOpenContributionLinkText": "Zur Zeit gibt es keine automatischen Schöpfungen.", + "openContributionLinks": "Öffentliche Beitrags-Linkliste", "openContributionLinkText": "Folgende {count} automatische Schöpfungen werden zur Zeit durch die Gemeinschaft „{name}“ bereitgestellt.", "other-communities": "Weitere Gemeinschaften", "submitContribution": "Beitrag einreichen", "switch-to-this-community": "zu dieser Gemeinschaft wechseln" }, + "contact": "Kontakt", "contribution": { "activity": "Tätigkeit", "alert": { - "answerQuestion": "Bitte beantworte die Nachfrage", + "answerQuestion": "Bitte beantworte die Rückfrage!", "communityNoteList": "Hier findest du alle eingereichten und bestätigten Beiträge von allen Mitgliedern aus dieser Gemeinschaft.", "confirm": "bestätigt", "in_progress": "Es gibt eine Rückfrage der Moderatoren.", @@ -205,7 +207,7 @@ }, "language": "Sprache", "link-load": "den letzten Link nachladen | die letzten {n} Links nachladen | weitere {n} Links nachladen", - "login": "Anmeldung", + "login": "Anmelden", "math": { "aprox": "~", "asterisk": "*", @@ -286,16 +288,12 @@ "forgotPassword": { "heading": "Bitte gib deine E-Mail an mit der du bei Gradido angemeldet bist." }, - "login": { - "heading": "Melde dich mit deinen Zugangsdaten an. Bewahre sie stets sicher auf!" - }, "resetPassword": { "heading": "Trage bitte dein Passwort ein und wiederhole es." }, "signup": { "agree": "Ich stimme der Datenschutzerklärung zu.", "dont_match": "Die Passwörter stimmen nicht überein.", - "heading": "Registriere dich indem du alle Daten vollständig und in die richtigen Felder eingibst.", "lowercase": "Ein Kleinbuchstabe erforderlich.", "minimum": "Mindestens 8 Zeichen.", "no-whitespace": "Keine Leerzeichen und Tabulatoren", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 113fa1cb9..2b455d599 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -24,12 +24,14 @@ "moderator": "Moderator", "moderators": "Moderators", "myContributions": "My contributions to the common good", - "openContributionLinks": "open Contribution links list", + "noOpenContributionLinkText": "Currently there are no automatic creations.", + "openContributionLinks": "Open contribution-link list", "openContributionLinkText": "The following {count} automatic creations are currently provided by the \"{name}\" community.", "other-communities": "Other communities", "submitContribution": "Submit contribution", "switch-to-this-community": "Switch to this community" }, + "contact": "Contact", "contribution": { "activity": "Activity", "alert": { @@ -205,7 +207,7 @@ }, "language": "Language", "link-load": "Load the last link | Load the last {n} links | Load more {n} links", - "login": "Login", + "login": "Sign in", "math": { "aprox": "~", "asterisk": "*", @@ -286,16 +288,12 @@ "forgotPassword": { "heading": "Please enter the email address by which you're registered here." }, - "login": { - "heading": "Log in with your access data. Keep them safe!" - }, "resetPassword": { "heading": "Please enter your password and repeat it." }, "signup": { "agree": "I agree to the privacy policy.", "dont_match": "Passwords don't match.", - "heading": "Register by entering all data completely and in the correct fields.", "lowercase": "One lowercase letter required.", "minimum": "8 characters minimum.", "no-whitespace": "No white spaces and tabs", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index b2a229f1c..2c7e4c4ba 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -288,16 +288,12 @@ "forgotPassword": { "heading": "Por favor, introduce la dirección de correo electrónico con la que estas registrado en Gradido." }, - "login": { - "heading": "Inicia sesión con tus datos de acceso. Manténlos seguros en todo momento!" - }, "resetPassword": { "heading": "Por favor, introduce tu contraseña y repítela." }, "signup": { "agree": "Acepto la Política de privacidad.", "dont_match": "Las contraseñas no coinciden.", - "heading": "Regístrate introduciendo todos los datos completos y en los campos correctos.", "lowercase": "Se requiere una letra minúscula.", "minimum": "Al menos 8 caracteres.", "no-whitespace": "Sin espacios ni tabulaciones.", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index e7ac18a18..98d2e5a10 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -288,16 +288,12 @@ "forgotPassword": { "heading": "Veuillez entrer l´adresse email sous laquelle vous êtes enregistré ici svp." }, - "login": { - "heading": "Vous connecter avec vos données d´accès. Gardez les en sécurité!" - }, "resetPassword": { "heading": "Entrez votre mot de passe et répétez l´action svp." }, "signup": { "agree": "J´accepte le politique de confidentialité .", "dont_match": "Les mots de passe ne correspondent pas.", - "heading": "Vous enregistrer en entrant toutes les données demandées dans les champs requis.", "lowercase": "Une lettre minuscule est requise.", "minimum": "8 caractères minimum.", "no-whitespace": "Pas d´espace ni d´onglet", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 34f484fd7..eb28615c6 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -288,16 +288,12 @@ "forgotPassword": { "heading": "Geef alsjeblieft jouw email, waarmee je bij Gradido aangemeld bent." }, - "login": { - "heading": "Meld je met jouw inloggegevens aan. Sla deze altijd veilig op!" - }, "resetPassword": { "heading": "Vul alsjeblieft jouw wachtwoord in, en herhaal het." }, "signup": { "agree": "Ik ga akkoord met Datenschutzerklärung.", "dont_match": "De wachtwoorden zijn niet gelijk.", - "heading": "Schrijf je in door alle gegevens volledig en in de juiste velden in te vullen.", "lowercase": "Een kleine letter is noodzakelijk.", "minimum": "Minstens 8 tekens.", "no-whitespace": "Geen spaties en tabs", diff --git a/frontend/src/mixins/authLinks.js b/frontend/src/mixins/authLinks.js new file mode 100644 index 000000000..6dedd8612 --- /dev/null +++ b/frontend/src/mixins/authLinks.js @@ -0,0 +1,12 @@ +export const authLinks = { + computed: { + login() { + if (this.$route.params.code) return '/login/' + this.$route.params.code + return '/login' + }, + register() { + if (this.$route.params.code) return '/register/' + this.$route.params.code + return '/register' + }, + }, +} diff --git a/frontend/src/pages/Community.vue b/frontend/src/pages/Community.vue index c98bfff2d..786307405 100644 --- a/frontend/src/pages/Community.vue +++ b/frontend/src/pages/Community.vue @@ -231,8 +231,6 @@ export default { this.items = listContributions.contributionList if (this.items.find((item) => item.state === 'IN_PROGRESS')) { this.tabIndex = 1 - } else { - this.tabIndex = 0 } }) .catch((err) => { diff --git a/frontend/src/pages/InfoStatistic.vue b/frontend/src/pages/InfoStatistic.vue index 1e09f83ed..254a895e0 100644 --- a/frontend/src/pages/InfoStatistic.vue +++ b/frontend/src/pages/InfoStatistic.vue @@ -14,7 +14,7 @@
{{ $t('community.openContributionLinks') }}
- + {{ $t('community.openContributionLinkText', { name: CONFIG.COMMUNITY_NAME, @@ -22,6 +22,9 @@ }) }} + + {{ $t('community.noOpenContributionLinkText') }} +
  • {{ item.name }}
    @@ -41,7 +44,10 @@ {{ item.firstName }} {{ item.lastName }}
- {{ supportMail }} +
+ +
{{ $t('contact') }}
+ {{ supportMail }}