Merge branch 'master' into 2267-feature-gradido-roadmap

This commit is contained in:
clauspeterhuebner 2022-11-24 21:22:45 +01:00 committed by GitHub
commit 1314ed5263
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
152 changed files with 5815 additions and 976 deletions

72
.github/workflows/lint_pr.yml vendored Normal file
View File

@ -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

View File

@ -1,6 +1,6 @@
name: gradido test CI name: gradido test CI
on: [push] on: push
jobs: jobs:
############################################################################## ##############################################################################
@ -15,7 +15,7 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# FRONTEND ############################################################### # FRONTEND ###############################################################
########################################################################## ##########################################################################
@ -24,7 +24,7 @@ jobs:
docker build --target test -t "gradido/frontend:test" frontend/ docker build --target test -t "gradido/frontend:test" frontend/
docker save "gradido/frontend:test" > /tmp/frontend.tar docker save "gradido/frontend:test" > /tmp/frontend.tar
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: docker-frontend-test name: docker-frontend-test
path: /tmp/frontend.tar path: /tmp/frontend.tar
@ -41,7 +41,7 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# ADMIN INTERFACE ######################################################## # ADMIN INTERFACE ########################################################
########################################################################## ##########################################################################
@ -50,7 +50,7 @@ jobs:
docker build --target test -t "gradido/admin:test" admin/ --build-arg NODE_ENV="test" docker build --target test -t "gradido/admin:test" admin/ --build-arg NODE_ENV="test"
docker save "gradido/admin:test" > /tmp/admin.tar docker save "gradido/admin:test" > /tmp/admin.tar
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: docker-admin-test name: docker-admin-test
path: /tmp/admin.tar path: /tmp/admin.tar
@ -67,7 +67,7 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# BACKEND ################################################################ # BACKEND ################################################################
########################################################################## ##########################################################################
@ -76,7 +76,7 @@ jobs:
docker build -f ./backend/Dockerfile --target test -t "gradido/backend:test" . docker build -f ./backend/Dockerfile --target test -t "gradido/backend:test" .
docker save "gradido/backend:test" > /tmp/backend.tar docker save "gradido/backend:test" > /tmp/backend.tar
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: docker-backend-test name: docker-backend-test
path: /tmp/backend.tar path: /tmp/backend.tar
@ -93,7 +93,7 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DATABASE UP ############################################################ # DATABASE UP ############################################################
########################################################################## ##########################################################################
@ -102,7 +102,7 @@ jobs:
docker build --target test_up -t "gradido/database:test_up" database/ docker build --target test_up -t "gradido/database:test_up" database/
docker save "gradido/database:test_up" > /tmp/database_up.tar docker save "gradido/database:test_up" > /tmp/database_up.tar
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: docker-database-test_up name: docker-database-test_up
path: /tmp/database_up.tar path: /tmp/database_up.tar
@ -119,7 +119,7 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# BUILD MARIADB DOCKER IMAGE ############################################# # BUILD MARIADB DOCKER IMAGE #############################################
########################################################################## ##########################################################################
@ -128,7 +128,7 @@ jobs:
docker build --target mariadb_server -t "gradido/mariadb:test" -f ./mariadb/Dockerfile ./ docker build --target mariadb_server -t "gradido/mariadb:test" -f ./mariadb/Dockerfile ./
docker save "gradido/mariadb:test" > /tmp/mariadb.tar docker save "gradido/mariadb:test" > /tmp/mariadb.tar
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: docker-mariadb-test name: docker-mariadb-test
path: /tmp/mariadb.tar path: /tmp/mariadb.tar
@ -139,13 +139,13 @@ jobs:
build_test_nginx: build_test_nginx:
name: Docker Build Test - Nginx name: Docker Build Test - Nginx
runs-on: ubuntu-latest runs-on: ubuntu-latest
#needs: [nothing] needs: [build_test_backend, build_test_admin, build_test_frontend]
steps: steps:
########################################################################## ##########################################################################
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# BUILD NGINX DOCKER IMAGE ############################################### # BUILD NGINX DOCKER IMAGE ###############################################
########################################################################## ##########################################################################
@ -154,7 +154,7 @@ jobs:
docker build -t "gradido/nginx:test" nginx/ docker build -t "gradido/nginx:test" nginx/
docker save "gradido/nginx:test" > /tmp/nginx.tar docker save "gradido/nginx:test" > /tmp/nginx.tar
- name: Upload Artifact - name: Upload Artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: docker-nginx-test name: docker-nginx-test
path: /tmp/nginx.tar path: /tmp/nginx.tar
@ -171,12 +171,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGE ################################################## # DOWNLOAD DOCKER IMAGE ##################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Frontend) - name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-frontend-test name: docker-frontend-test
path: /tmp path: /tmp
@ -200,12 +200,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGE ################################################## # DOWNLOAD DOCKER IMAGE ##################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Frontend) - name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-frontend-test name: docker-frontend-test
path: /tmp path: /tmp
@ -229,12 +229,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGE ################################################## # DOWNLOAD DOCKER IMAGE ##################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Frontend) - name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-frontend-test name: docker-frontend-test
path: /tmp path: /tmp
@ -258,12 +258,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGE ################################################## # DOWNLOAD DOCKER IMAGE ##################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Admin Interface) - name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-admin-test name: docker-admin-test
path: /tmp path: /tmp
@ -287,12 +287,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGE ################################################## # DOWNLOAD DOCKER IMAGE ##################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Admin Interface) - name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-admin-test name: docker-admin-test
path: /tmp path: /tmp
@ -308,7 +308,7 @@ jobs:
# JOB: LOCALES ADMIN ######################################################### # JOB: LOCALES ADMIN #########################################################
############################################################################## ##############################################################################
locales_admin: locales_admin:
name: Locales - Admin name: Locales - Admin Interface
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [build_test_admin] needs: [build_test_admin]
steps: steps:
@ -316,12 +316,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGE ################################################## # DOWNLOAD DOCKER IMAGE ##################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Admin Interface) - name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-admin-test name: docker-admin-test
path: /tmp path: /tmp
@ -345,12 +345,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGE ################################################## # DOWNLOAD DOCKER IMAGE ##################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Backend) - name: Download Docker Image (Backend)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-backend-test name: docker-backend-test
path: /tmp path: /tmp
@ -374,12 +374,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGE ################################################## # DOWNLOAD DOCKER IMAGE ##################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Backend) - name: Download Docker Image (Backend)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-database-test_up name: docker-database-test_up
path: /tmp path: /tmp
@ -403,12 +403,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGES ################################################# # DOWNLOAD DOCKER IMAGES #################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Frontend) - name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-frontend-test name: docker-frontend-test
path: /tmp path: /tmp
@ -453,12 +453,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGES ################################################# # DOWNLOAD DOCKER IMAGES #################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Admin Interface) - name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-admin-test name: docker-admin-test
path: /tmp path: /tmp
@ -495,12 +495,12 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOWNLOAD DOCKER IMAGES ################################################# # DOWNLOAD DOCKER IMAGES #################################################
########################################################################## ##########################################################################
- name: Download Docker Image (Mariadb) - name: Download Docker Image (Mariadb)
uses: actions/download-artifact@v2 uses: actions/download-artifact@v3
with: with:
name: docker-mariadb-test name: docker-mariadb-test
path: /tmp path: /tmp
@ -543,7 +543,7 @@ jobs:
# CHECKOUT CODE ########################################################## # CHECKOUT CODE ##########################################################
########################################################################## ##########################################################################
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@v3
########################################################################## ##########################################################################
# DOCKER COMPOSE DATABASE UP + RESET ##################################### # DOCKER COMPOSE DATABASE UP + RESET #####################################
########################################################################## ##########################################################################
@ -553,3 +553,108 @@ jobs:
run: docker-compose -f docker-compose.yml run -T database yarn up run: docker-compose -f docker-compose.yml run -T database yarn up
- name: database | reset - name: database | reset
run: docker-compose -f docker-compose.yml run -T database yarn 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/

View File

@ -4,8 +4,73 @@ 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). Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.14.1](https://github.com/gradido/gradido/compare/1.14.0...1.14.1)
- fix(frontend): load contributionMessages is fixed [`#2390`](https://github.com/gradido/gradido/pull/2390)
#### [1.14.0](https://github.com/gradido/gradido/compare/1.13.3...1.14.0)
> 14 November 2022
- chore(release): version 1.14.0 [`#2389`](https://github.com/gradido/gradido/pull/2389)
- fix(frontend): close all open collapse by change tabs in community [`#2388`](https://github.com/gradido/gradido/pull/2388)
- fix(backend): corrected E-Mail texts [`#2386`](https://github.com/gradido/gradido/pull/2386)
- fix(frontend): better history messages [`#2381`](https://github.com/gradido/gradido/pull/2381)
- fix(frontend): mailto link [`#2383`](https://github.com/gradido/gradido/pull/2383)
- fix(admin): fix text in admin area to uppercase [`#2365`](https://github.com/gradido/gradido/pull/2365)
- feat(frontend): move the information about gradido being free to the auth layout [`#2349`](https://github.com/gradido/gradido/pull/2349)
- fix(admin): load error fixed for contribution link [`#2364`](https://github.com/gradido/gradido/pull/2364)
- fix(admin): edit contribution link does not take old values [`#2362`](https://github.com/gradido/gradido/pull/2362)
- fix(other): corrected dockerfile descriptions [`#2346`](https://github.com/gradido/gradido/pull/2346)
- feat(backend): 🍰 Send email for rejected contributions [`#2340`](https://github.com/gradido/gradido/pull/2340)
- feat(admin): edit automatic contribution link [`#2309`](https://github.com/gradido/gradido/pull/2309)
- refactor(backend): fix logger mocks [`#2308`](https://github.com/gradido/gradido/pull/2308)
- fix(admin): update contribution list after admin updates contribution [`#2330`](https://github.com/gradido/gradido/pull/2330)
- fix(frontend): inconsistent labeling on login register [`#2350`](https://github.com/gradido/gradido/pull/2350)
- feat(backend): setup hyperswarm [`#1874`](https://github.com/gradido/gradido/pull/1874)
- feat(other): lint pull request workflow [`#2338`](https://github.com/gradido/gradido/pull/2338)
- Feature: 🍰 add updated at to contributions [`#2237`](https://github.com/gradido/gradido/pull/2237)
- Refactor: GitHub test workflow - disable video recording and reduce wait time [`#2336`](https://github.com/gradido/gradido/pull/2336)
- 2274 feature concept manuel user registration for admins [`#2289`](https://github.com/gradido/gradido/pull/2289)
- 1574 concept to introduce gradidoID and change password encryption [`#2252`](https://github.com/gradido/gradido/pull/2252)
- contributionlink stage-2 and stage-3 of capturing and activation [`#2241`](https://github.com/gradido/gradido/pull/2241)
- Github workflow: update actions to the current API version using Node v 16 [`#2323`](https://github.com/gradido/gradido/pull/2323)
- feature: Fullstack tests in GitHub workflow [`#2319`](https://github.com/gradido/gradido/pull/2319)
#### [1.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3)
> 1 November 2022
- release: Version 1.13.3 [`#2322`](https://github.com/gradido/gradido/pull/2322)
- 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) #### [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) - 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) - Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231)

View File

@ -20,10 +20,10 @@ ENV PORT="8080"
# Labels # Labels
LABEL org.label-schema.build-date="${BUILD_DATE}" LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="gradido:admin" LABEL org.label-schema.name="gradido:admin"
LABEL org.label-schema.description="Gradido Vue Admin Interface" LABEL org.label-schema.description="Gradido Admin Interface"
LABEL org.label-schema.usage="https://github.com/gradido/gradido/admin/README.md" 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.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.vcs-ref="${BUILD_COMMIT}"
LABEL org.label-schema.vendor="gradido Community" LABEL org.label-schema.vendor="gradido Community"
LABEL org.label-schema.version="${BUILD_VERSION}" LABEL org.label-schema.version="${BUILD_VERSION}"

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido", "description": "Administraion Interface for Gradido",
"main": "index.js", "main": "index.js",
"author": "Moriz Wahl", "author": "Moriz Wahl",
"version": "1.13.1", "version": "1.14.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"private": false, "private": false,
"scripts": { "scripts": {
@ -53,6 +53,7 @@
"vuex-persistedstate": "^4.1.0" "vuex-persistedstate": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@apollo/client": "^3.7.1",
"@babel/eslint-parser": "^7.15.8", "@babel/eslint-parser": "^7.15.8",
"@intlify/eslint-plugin-vue-i18n": "^1.4.0", "@intlify/eslint-plugin-vue-i18n": "^1.4.0",
"@vue/cli-plugin-babel": "~4.5.0", "@vue/cli-plugin-babel": "~4.5.0",
@ -71,6 +72,7 @@
"eslint-plugin-prettier": "3.3.1", "eslint-plugin-prettier": "3.3.1",
"eslint-plugin-promise": "^5.1.1", "eslint-plugin-promise": "^5.1.1",
"eslint-plugin-vue": "^7.20.0", "eslint-plugin-vue": "^7.20.0",
"mock-apollo-client": "^1.2.1",
"postcss": "^8.4.8", "postcss": "^8.4.8",
"postcss-html": "^1.3.0", "postcss-html": "^1.3.0",
"postcss-scss": "^4.0.3", "postcss-scss": "^4.0.3",

View File

@ -1,39 +0,0 @@
import { mount } from '@vue/test-utils'
import CommunityStatistic from './CommunityStatistic'
const localVue = global.localVue
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
}
const propsData = {
value: {
totalUsers: '123',
activeUsers: '100',
deletedUsers: '5',
totalGradidoCreated: '2500',
totalGradidoDecayed: '200',
totalGradidoAvailable: '500',
totalGradidoUnbookedDecayed: '111',
},
}
describe('CommunityStatistic', () => {
let wrapper
const Wrapper = () => {
return mount(CommunityStatistic, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".community-statistic"', () => {
expect(wrapper.find('div.community-statistic').exists()).toBe(true)
})
})
})

View File

@ -1,59 +0,0 @@
<template>
<div class="community-statistic">
<div>
<b-jumbotron bg-variant="info" text-variant="white" border-variant="dark">
<template #header>{{ $t('statistic.name') }}</template>
<hr class="my-4" />
<div>
{{ $t('statistic.totalUsers') }}{{ $t('math.colon') }}
<b>{{ value.totalUsers }}</b>
</div>
<div>
{{ $t('statistic.activeUsers') }}{{ $t('math.colon') }}
<b>{{ value.activeUsers }}</b>
</div>
<div>
{{ $t('statistic.deletedUsers') }}{{ $t('math.colon') }}
<b>{{ value.deletedUsers }}</b>
</div>
<div>
{{ $t('statistic.totalGradidoCreated') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoCreated, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoCreated }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoDecayed') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoDecayed, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoDecayed }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoAvailable') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoAvailable, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoAvailable }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoUnbookedDecayed') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoUnbookedDecayed, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoUnbookedDecayed }}</small>
</div>
</b-jumbotron>
</div>
</div>
</template>
<script>
import CONFIG from '@/config'
export default {
name: 'CommunityStatistic',
props: {
value: { type: Object },
},
data() {
return {
CONFIG,
}
},
}
</script>

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="content-footer"> <div class="content-footer">
<hr /> <hr />
<b-row align-v="center" class="mt-4 justify-content-lg-between"> <div align-v="center" class="mt-4 mb-4 justify-content-lg-between">
<b-col> <b-col>
<div class="copyright text-center text-lg-center text-muted"> <div class="copyright text-center text-lg-center text-muted">
{{ $t('footer.copyright.year', { year }) }} {{ $t('footer.copyright.year', { year }) }}
@ -25,7 +25,7 @@
</a> </a>
</div> </div>
</b-col> </b-col>
</b-row> </div>
</div> </div>
</template> </template>
<script> <script>

View File

@ -46,5 +46,10 @@ describe('ContributionLink', () => {
wrapper.vm.editContributionLinkData() wrapper.vm.editContributionLinkData()
expect(wrapper.vm.$root.$emit('bv::toggle::collapse', 'newContribution')).toBeTruthy() expect(wrapper.vm.$root.$emit('bv::toggle::collapse', 'newContribution')).toBeTruthy()
}) })
it('emits toggle::collapse close Contribution-Form ', async () => {
wrapper.vm.closeContributionForm()
expect(wrapper.vm.$root.$emit('bv::toggle::collapse', 'newContribution')).toBeTruthy()
})
}) })
}) })

View File

@ -8,7 +8,11 @@
header-class="text-center" header-class="text-center"
class="mt-5" class="mt-5"
> >
<b-button v-b-toggle.newContribution class="my-3 d-flex justify-content-left"> <b-button
v-if="!editContributionLink"
v-b-toggle.newContribution
class="my-3 d-flex justify-content-left"
>
{{ $t('math.plus') }} {{ $t('contributionLink.newContributionLink') }} {{ $t('math.plus') }} {{ $t('contributionLink.newContributionLink') }}
</b-button> </b-button>
@ -17,7 +21,9 @@
<p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p> <p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p>
<contribution-link-form <contribution-link-form
:contributionLinkData="contributionLinkData" :contributionLinkData="contributionLinkData"
:editContributionLink="editContributionLink"
@get-contribution-links="$emit('get-contribution-links')" @get-contribution-links="$emit('get-contribution-links')"
@closeContributionForm="closeContributionForm"
/> />
</b-card> </b-card>
</b-collapse> </b-collapse>
@ -28,6 +34,7 @@
:items="items" :items="items"
@editContributionLinkData="editContributionLinkData" @editContributionLinkData="editContributionLinkData"
@get-contribution-links="$emit('get-contribution-links')" @get-contribution-links="$emit('get-contribution-links')"
@closeContributionForm="closeContributionForm"
/> />
<div v-else>{{ $t('contributionLink.noContributionLinks') }}</div> <div v-else>{{ $t('contributionLink.noContributionLinks') }}</div>
</b-card-text> </b-card-text>
@ -35,8 +42,8 @@
</div> </div>
</template> </template>
<script> <script>
import ContributionLinkForm from './ContributionLinkForm.vue' import ContributionLinkForm from '../ContributionLink/ContributionLinkForm.vue'
import ContributionLinkList from './ContributionLinkList.vue' import ContributionLinkList from '../ContributionLink/ContributionLinkList.vue'
export default { export default {
name: 'ContributionLink', name: 'ContributionLink',
@ -58,12 +65,23 @@ export default {
return { return {
visible: false, visible: false,
contributionLinkData: {}, contributionLinkData: {},
editContributionLink: false,
} }
}, },
methods: { methods: {
closeContributionForm() {
if (this.visible) {
this.$root.$emit('bv::toggle::collapse', 'newContribution')
this.editContributionLink = false
this.contributionLinkData = {}
}
},
editContributionLinkData(data) { editContributionLinkData(data) {
if (!this.visible) this.$root.$emit('bv::toggle::collapse', 'newContribution') if (!this.visible) {
this.$root.$emit('bv::toggle::collapse', 'newContribution')
}
this.contributionLinkData = data this.contributionLinkData = data
this.editContributionLink = true
}, },
}, },
} }

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import ContributionLinkForm from './ContributionLinkForm.vue' import ContributionLinkForm from './ContributionLinkForm.vue'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' import { toastErrorSpy, toastSuccessSpy } from '../../../test/testSetup'
import { createContributionLink } from '@/graphql/createContributionLink.js' import { createContributionLink } from '@/graphql/createContributionLink.js'
const localVue = global.localVue const localVue = global.localVue
@ -9,6 +9,7 @@ global.alert = jest.fn()
const propsData = { const propsData = {
contributionLinkData: {}, contributionLinkData: {},
editContributionLink: false,
} }
const apolloMutateMock = jest.fn().mockResolvedValue() const apolloMutateMock = jest.fn().mockResolvedValue()
@ -108,6 +109,7 @@ describe('ContributionLinkForm', () => {
cycle: 'ONCE', cycle: 'ONCE',
maxPerCycle: 1, maxPerCycle: 1,
maxAmountPerMonth: '0', maxAmountPerMonth: '0',
id: null,
}, },
}) })
}) })

View File

@ -6,6 +6,7 @@
<b-col> <b-col>
<b-form-group :label="$t('contributionLink.validFrom')"> <b-form-group :label="$t('contributionLink.validFrom')">
<b-form-datepicker <b-form-datepicker
reset-button
v-model="form.validFrom" v-model="form.validFrom"
size="lg" size="lg"
:min="min" :min="min"
@ -19,6 +20,7 @@
<b-col> <b-col>
<b-form-group :label="$t('contributionLink.validTo')"> <b-form-group :label="$t('contributionLink.validTo')">
<b-form-datepicker <b-form-datepicker
reset-button
v-model="form.validTo" v-model="form.validTo"
size="lg" size="lg"
:min="form.validFrom ? form.validFrom : min" :min="form.validFrom ? form.validFrom : min"
@ -102,16 +104,25 @@
</b-form-group> </b-form-group>
--> -->
<div class="mt-6"> <div class="mt-6">
<b-button type="submit" variant="primary">{{ $t('contributionLink.create') }}</b-button> <b-button type="submit" variant="primary">
{{
editContributionLink ? $t('contributionLink.saveChange') : $t('contributionLink.create')
}}
</b-button>
<b-button type="reset" variant="danger" @click.prevent="onReset"> <b-button type="reset" variant="danger" @click.prevent="onReset">
{{ $t('contributionLink.clear') }} {{ $t('contributionLink.clear') }}
</b-button> </b-button>
<b-button @click.prevent="$emit('closeContributionForm')">
{{ $t('contributionLink.close') }}
</b-button>
</div> </div>
</b-form> </b-form>
</div> </div>
</template> </template>
<script> <script>
import { createContributionLink } from '@/graphql/createContributionLink.js' import { createContributionLink } from '@/graphql/createContributionLink.js'
import { updateContributionLink } from '@/graphql/updateContributionLink.js'
export default { export default {
name: 'ContributionLinkForm', name: 'ContributionLinkForm',
props: { props: {
@ -121,6 +132,7 @@ export default {
return {} return {}
}, },
}, },
editContributionLink: { type: Boolean, required: true },
}, },
data() { data() {
return { return {
@ -157,23 +169,24 @@ export default {
if (this.form.validFrom === null) if (this.form.validFrom === null)
return this.toastError(this.$t('contributionLink.noStartDate')) return this.toastError(this.$t('contributionLink.noStartDate'))
if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate')) if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate'))
const variables = {
...this.form,
id: this.contributionLinkData.id ? this.contributionLinkData.id : null,
}
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: createContributionLink, mutation: this.editContributionLink ? updateContributionLink : createContributionLink,
variables: { variables: variables,
validFrom: this.form.validFrom,
validTo: this.form.validTo,
name: this.form.name,
amount: this.form.amount,
memo: this.form.memo,
cycle: this.form.cycle,
maxPerCycle: this.form.maxPerCycle,
maxAmountPerMonth: this.form.maxAmountPerMonth,
},
}) })
.then((result) => { .then((result) => {
this.link = result.data.createContributionLink.link const link = this.editContributionLink
this.toastSuccess(this.link) ? result.data.updateContributionLink.link
: result.data.createContributionLink.link
this.toastSuccess(
this.editContributionLink ? this.$t('contributionLink.changeSaved') : link,
)
this.onReset() this.onReset()
this.$root.$emit('bv::toggle::collapse', 'newContribution') this.$root.$emit('bv::toggle::collapse', 'newContribution')
this.$emit('get-contribution-links') this.$emit('get-contribution-links')
@ -184,6 +197,7 @@ export default {
}, },
onReset() { onReset() {
this.$refs.contributionLinkForm.reset() this.$refs.contributionLinkForm.reset()
this.form = {}
this.form.validFrom = null this.form.validFrom = null
this.form.validTo = null this.form.validTo = null
}, },
@ -195,14 +209,7 @@ export default {
}, },
watch: { watch: {
contributionLinkData() { contributionLinkData() {
this.form.name = this.contributionLinkData.name this.form = this.contributionLinkData
this.form.memo = this.contributionLinkData.memo
this.form.amount = this.contributionLinkData.amount
this.form.validFrom = this.contributionLinkData.validFrom
this.form.validTo = this.contributionLinkData.validTo
this.form.cycle = this.contributionLinkData.cycle
this.form.maxPerCycle = this.contributionLinkData.maxPerCycle
this.form.maxAmountPerMonth = this.contributionLinkData.maxAmountPerMonth
}, },
}, },
} }

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import ContributionLinkList from './ContributionLinkList.vue' import ContributionLinkList from './ContributionLinkList.vue'
import { toastSuccessSpy, toastErrorSpy } from '../../test/testSetup' import { toastSuccessSpy, toastErrorSpy } from '../../../test/testSetup'
// import { deleteContributionLink } from '../graphql/deleteContributionLink' // import { deleteContributionLink } from '../graphql/deleteContributionLink'
const localVue = global.localVue const localVue = global.localVue

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="contribution-link-list"> <div class="contribution-link-list">
<b-table striped hover :items="items" :fields="fields"> <b-table :items="items" :fields="fields" striped hover stacked="lg">
<template #cell(delete)="data"> <template #cell(delete)="data">
<b-button <b-button
variant="danger" variant="danger"
@ -46,7 +46,7 @@
</template> </template>
<script> <script>
import { deleteContributionLink } from '@/graphql/deleteContributionLink.js' import { deleteContributionLink } from '@/graphql/deleteContributionLink.js'
import FigureQrCode from './FigureQrCode.vue' import FigureQrCode from '../FigureQrCode.vue'
export default { export default {
name: 'ContributionLinkList', name: 'ContributionLinkList',
@ -108,6 +108,7 @@ export default {
}) })
.then(() => { .then(() => {
this.toastSuccess(this.$t('contributionLink.deleted')) this.toastSuccess(this.$t('contributionLink.deleted'))
this.$emit('closeContributionForm')
this.$emit('get-contribution-links') this.$emit('get-contribution-links')
}) })
.catch((err) => { .catch((err) => {

View File

@ -1,7 +1,15 @@
<template> <template>
<div class="mt-2"> <div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index"> <span v-for="({ type, text }, index) in parsedMessage" :key="index">
<b-link v-if="type === 'link'" :to="text">{{ text }}</b-link> <b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else-if="type === 'date'">
{{ $d(new Date(text), 'short') }}
<br />
</span>
<span v-else-if="type === 'amount'">
<br />
{{ `${$n(Number(text), 'decimal')} GDD` }}
</span>
<span v-else>{{ text }}</span> <span v-else>{{ text }}</span>
</span> </span>
</div> </div>
@ -12,17 +20,28 @@ const LINK_REGEX_PATTERN =
/(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i /(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*))/i
export default { export default {
name: 'LinkifyMessage', name: 'ParseMessage',
props: { props: {
message: { message: {
type: String, type: String,
required: true, required: true,
}, },
type: {
type: String,
reuired: true,
},
}, },
computed: { computed: {
linkifiedMessage() { parsedMessage() {
const linkified = []
let string = this.message let string = this.message
const linkified = []
let amount
if (this.type === 'HISTORY') {
const split = string.split(/\n\s*---\n\s*/)
string = split[1]
linkified.push({ type: 'date', text: split[0].trim() })
amount = split[2].trim()
}
let match let match
while ((match = string.match(LINK_REGEX_PATTERN))) { while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0) if (match.index > 0)
@ -31,6 +50,7 @@ export default {
string = string.substring(match.index + match[0].length) string = string.substring(match.index + match[0].length)
} }
if (string.length > 0) linkified.push({ type: 'text', text: string }) if (string.length > 0) linkified.push({ type: 'text', text: string })
if (amount) linkified.push({ type: 'amount', text: amount })
return linkified return linkified
}, },
}, },

View File

@ -3,12 +3,16 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
const localVue = global.localVue const localVue = global.localVue
const dateMock = jest.fn((d) => d)
const numberMock = jest.fn((n) => n)
describe('ContributionMessagesListItem', () => { describe('ContributionMessagesListItem', () => {
let wrapper let wrapper
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$d: jest.fn((d) => d), $d: dateMock,
$n: numberMock,
} }
describe('if message author has moderator role', () => { describe('if message author has moderator role', () => {
@ -189,4 +193,64 @@ and here is the link to the repository: 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/')
})
})
})
}) })

View File

@ -5,23 +5,23 @@
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<small class="ml-4 text-success">{{ $t('moderator') }}</small> <small class="ml-4 text-success">{{ $t('moderator') }}</small>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
<div v-else class="text-left is-not-moderator"> <div v-else class="text-left is-not-moderator">
<b-avatar variant="info"></b-avatar> <b-avatar variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span> <span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span> <span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
<linkify-message :message="message.message"></linkify-message> <parse-message v-bind="message"></parse-message>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue' import ParseMessage from '@/components/ContributionMessages/ParseMessage.vue'
export default { export default {
name: 'ContributionMessagesListItem', name: 'ContributionMessagesListItem',
components: { components: {
LinkifyMessage, ParseMessage,
}, },
props: { props: {
message: { message: {

View File

@ -7,6 +7,10 @@ const apolloMutateMock = jest.fn()
const storeDispatchMock = jest.fn() const storeDispatchMock = jest.fn()
const routerPushMock = jest.fn() const routerPushMock = jest.fn()
const stubs = {
RouterLink: true,
}
const mocks = { const mocks = {
$t: jest.fn((t) => t), $t: jest.fn((t) => t),
$apollo: { $apollo: {
@ -28,7 +32,7 @@ describe('NavBar', () => {
let wrapper let wrapper
const Wrapper = () => { const Wrapper = () => {
return mount(NavBar, { mocks, localVue }) return mount(NavBar, { mocks, localVue, stubs })
} }
describe('mount', () => { describe('mount', () => {
@ -41,13 +45,43 @@ describe('NavBar', () => {
}) })
}) })
describe('Navbar Menu', () => {
it('has a link to overview', () => {
expect(wrapper.findAll('.nav-item').at(0).find('a').attributes('href')).toBe('/')
})
it('has a link to /user', () => {
expect(wrapper.findAll('.nav-item').at(1).find('a').attributes('href')).toBe('/user')
})
it('has a link to /creation', () => {
expect(wrapper.findAll('.nav-item').at(2).find('a').attributes('href')).toBe('/creation')
})
it('has a link to /creation-confirm', () => {
expect(wrapper.findAll('.nav-item').at(3).find('a').attributes('href')).toBe(
'/creation-confirm',
)
})
it('has a link to /contribution-links', () => {
expect(wrapper.findAll('.nav-item').at(4).find('a').attributes('href')).toBe(
'/contribution-links',
)
})
it('has a link to /statistic', () => {
expect(wrapper.findAll('.nav-item').at(5).find('a').attributes('href')).toBe('/statistic')
})
})
describe('wallet', () => { describe('wallet', () => {
const assignLocationSpy = jest.fn() const assignLocationSpy = jest.fn()
beforeEach(async () => { beforeEach(async () => {
await wrapper.findAll('a').at(5).trigger('click') await wrapper.findAll('.nav-item').at(6).find('a').trigger('click')
}) })
it.skip('changes widnow location to wallet', () => { it.skip('changes window location to wallet', () => {
expect(assignLocationSpy).toBeCalledWith('valid-token') expect(assignLocationSpy).toBeCalledWith('valid-token')
}) })
@ -63,7 +97,7 @@ describe('NavBar', () => {
window.location = { window.location = {
assign: windowLocationMock, assign: windowLocationMock,
} }
await wrapper.findAll('a').at(6).trigger('click') await wrapper.findAll('.nav-item').at(7).find('a').trigger('click')
}) })
it('redirects to /logout', () => { it('redirects to /logout', () => {

View File

@ -19,6 +19,10 @@
> >
{{ $store.state.openCreations }} {{ $t('navbar.open_creation') }} {{ $store.state.openCreations }} {{ $t('navbar.open_creation') }}
</b-nav-item> </b-nav-item>
<b-nav-item to="/contribution-links">
{{ $t('navbar.automaticContributions') }}
</b-nav-item>
<b-nav-item to="/statistic">{{ $t('navbar.statistic') }}</b-nav-item>
<b-nav-item @click="wallet">{{ $t('navbar.my-account') }}</b-nav-item> <b-nav-item @click="wallet">{{ $t('navbar.my-account') }}</b-nav-item>
<b-nav-item @click="logout">{{ $t('navbar.logout') }}</b-nav-item> <b-nav-item @click="logout">{{ $t('navbar.logout') }}</b-nav-item>
</b-navbar-nav> </b-navbar-nav>

View File

@ -118,12 +118,11 @@ export default {
}, },
methods: { methods: {
updateCreationData(data) { updateCreationData(data) {
this.creationUserData = data const row = data.row
// this.creationUserData.amount = data.amount this.$emit('update-contributions', data)
// this.creationUserData.date = data.date delete data.row
// this.creationUserData.memo = data.memo this.creationUserData = { ...this.creationUserData, ...data }
// this.creationUserData.moderator = data.moderator row.toggleDetails()
data.row.toggleDetails()
}, },
updateUserData(rowItem, newCreation) { updateUserData(rowItem, newCreation) {
rowItem.creation = newCreation rowItem.creation = newCreation

View File

@ -0,0 +1,50 @@
import { mount } from '@vue/test-utils'
import StatisticTable from './StatisticTable.vue'
const localVue = global.localVue
const propsData = {
value: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
}
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
$d: jest.fn((d) => d),
}
describe('StatisticTable', () => {
let wrapper
const Wrapper = () => {
return mount(StatisticTable, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class .statistic-table', () => {
expect(wrapper.find('div.statistic-table').exists()).toBe(true)
})
describe('renders the table', () => {
it('with three colunms', () => {
expect(wrapper.findAll('thead > tr > th')).toHaveLength(3)
})
it('with seven rows', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(7)
})
})
})
})

View File

@ -0,0 +1,84 @@
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="statistic-table">
<b-table-simple style="width: auto" class="mt-5" striped stacked="md">
<b-thead>
<b-tr>
<b-th></b-th>
<b-th class="text-right">{{ $t('statistic.count') }}</b-th>
<b-th class="text-right">{{ $t('statistic.details') }}</b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.totalUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.activeUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.activeUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.deletedUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.deletedUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoCreated') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoCreated, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoCreated }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoDecayed') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoDecayed, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoDecayed }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoAvailable') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoAvailable, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoAvailable }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoUnbookedDecayed') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoUnbookedDecayed, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoUnbookedDecayed }}</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
</div>
</template>
<script>
export default {
name: 'StatisticTable',
props: {
value: {
type: Object,
required: true,
},
},
}
</script>

View File

@ -0,0 +1,40 @@
import gql from 'graphql-tag'
export const updateContributionLink = gql`
mutation (
$amount: Decimal!
$name: String!
$memo: String!
$cycle: String!
$validFrom: String
$validTo: String
$maxAmountPerMonth: Decimal
$maxPerCycle: Int! = 1
$id: Int!
) {
updateContributionLink(
amount: $amount
name: $name
memo: $memo
cycle: $cycle
validFrom: $validFrom
validTo: $validTo
maxAmountPerMonth: $maxAmountPerMonth
maxPerCycle: $maxPerCycle
id: $id
) {
id
amount
name
memo
code
link
createdAt
validFrom
validTo
maxAmountPerMonth
cycle
maxPerCycle
}
}
`

View File

@ -3,7 +3,9 @@
"back": "zurück", "back": "zurück",
"contributionLink": { "contributionLink": {
"amount": "Betrag", "amount": "Betrag",
"changeSaved": "Änderungen gespeichert",
"clear": "Löschen", "clear": "Löschen",
"close": "Schließen",
"contributionLinks": "Beitragslinks", "contributionLinks": "Beitragslinks",
"create": "Anlegen", "create": "Anlegen",
"cycle": "Zyklus", "cycle": "Zyklus",
@ -23,6 +25,7 @@
"once": "einmalig" "once": "einmalig"
} }
}, },
"saveChange": "Änderungen speichern",
"validFrom": "Startdatum", "validFrom": "Startdatum",
"validTo": "Enddatum" "validTo": "Enddatum"
}, },
@ -82,7 +85,6 @@
"hide_details": "Details verbergen", "hide_details": "Details verbergen",
"lastname": "Nachname", "lastname": "Nachname",
"math": { "math": {
"colon": ":",
"equals": "=", "equals": "=",
"exclaim": "!", "exclaim": "!",
"pipe": "|", "pipe": "|",
@ -95,11 +97,13 @@
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.", "multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
"name": "Name", "name": "Name",
"navbar": { "navbar": {
"automaticContributions": "Automatische Beiträge",
"logout": "Abmelden", "logout": "Abmelden",
"multi_creation": "Mehrfachschöpfung", "multi_creation": "Mehrfachschöpfung",
"my-account": "Mein Konto", "my-account": "Mein Konto",
"open_creation": "Offene Schöpfungen", "open_creation": "Offene Schöpfungen",
"overview": "Übersicht", "overview": "Übersicht",
"statistic": "Statistik",
"user_search": "Nutzersuche" "user_search": "Nutzersuche"
}, },
"not_open_creations": "Keine offenen Schöpfungen", "not_open_creations": "Keine offenen Schöpfungen",
@ -121,8 +125,9 @@
"save": "Speichern", "save": "Speichern",
"statistic": { "statistic": {
"activeUsers": "Aktive Mitglieder", "activeUsers": "Aktive Mitglieder",
"count": "Menge",
"deletedUsers": "Gelöschte Mitglieder", "deletedUsers": "Gelöschte Mitglieder",
"name": "Statistik", "details": "Details",
"totalGradidoAvailable": "GDD insgesamt im Umlauf", "totalGradidoAvailable": "GDD insgesamt im Umlauf",
"totalGradidoCreated": "GDD insgesamt geschöpft", "totalGradidoCreated": "GDD insgesamt geschöpft",
"totalGradidoDecayed": "GDD insgesamt verfallen", "totalGradidoDecayed": "GDD insgesamt verfallen",

View File

@ -3,7 +3,9 @@
"back": "back", "back": "back",
"contributionLink": { "contributionLink": {
"amount": "Amount", "amount": "Amount",
"changeSaved": "Changes saved",
"clear": "Clear", "clear": "Clear",
"close": "Close",
"contributionLinks": "Contribution Links", "contributionLinks": "Contribution Links",
"create": "Create", "create": "Create",
"cycle": "Cycle", "cycle": "Cycle",
@ -23,6 +25,7 @@
"once": "once" "once": "once"
} }
}, },
"saveChange": "Save Changes",
"validFrom": "Start-date", "validFrom": "Start-date",
"validTo": "End-Date" "validTo": "End-Date"
}, },
@ -82,7 +85,6 @@
"hide_details": "Hide details", "hide_details": "Hide details",
"lastname": "Lastname", "lastname": "Lastname",
"math": { "math": {
"colon": ":",
"equals": "=", "equals": "=",
"exclaim": "!", "exclaim": "!",
"pipe": "|", "pipe": "|",
@ -95,11 +97,13 @@
"multiple_creation_text": "Please select one or more members for which you would like to perform creations.", "multiple_creation_text": "Please select one or more members for which you would like to perform creations.",
"name": "Name", "name": "Name",
"navbar": { "navbar": {
"automaticContributions": "Automatic Contributions",
"logout": "Logout", "logout": "Logout",
"multi_creation": "Multiple creation", "multi_creation": "Multiple creation",
"my-account": "My Account", "my-account": "My Account",
"open_creation": "Open creations", "open_creation": "Open creations",
"overview": "Overview", "overview": "Overview",
"statistic": "Statistic",
"user_search": "User search" "user_search": "User search"
}, },
"not_open_creations": "No open creations", "not_open_creations": "No open creations",
@ -121,8 +125,9 @@
"save": "Speichern", "save": "Speichern",
"statistic": { "statistic": {
"activeUsers": "Active members", "activeUsers": "Active members",
"count": "Count",
"deletedUsers": "Deleted members", "deletedUsers": "Deleted members",
"name": "Statistic", "details": "Details",
"totalGradidoAvailable": "Total GDD in circulation", "totalGradidoAvailable": "Total GDD in circulation",
"totalGradidoCreated": "Total created GDD", "totalGradidoCreated": "Total created GDD",
"totalGradidoDecayed": "Total GDD decay", "totalGradidoDecayed": "Total GDD decay",

View File

@ -0,0 +1,98 @@
import { mount } from '@vue/test-utils'
import CommunityStatistic from './CommunityStatistic.vue'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { toastErrorSpy } 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
localVue.use(VueApollo)
const defaultData = () => {
return {
communityStatistics: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
}
}
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
}
describe('CommunityStatistic', () => {
let wrapper
const communityStatisticsMock = jest.fn()
mockClient.setRequestHandler(
communityStatistics,
communityStatisticsMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: defaultData() }),
)
const Wrapper = () => {
return mount(CommunityStatistic, { localVue, mocks, apolloProvider })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".community-statistic"', () => {
expect(wrapper.find('div.community-statistic').exists()).toBe(true)
})
describe('server response for get statistics is an error', () => {
it('toast an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
})
describe('server response for getting statistics is success', () => {
it('renders the data correctly', () => {
expect(wrapper.findAll('tr').at(1).findAll('td').at(1).text()).toEqual('3113')
expect(wrapper.findAll('tr').at(2).findAll('td').at(1).text()).toEqual('1057')
expect(wrapper.findAll('tr').at(3).findAll('td').at(1).text()).toEqual('35')
expect(wrapper.findAll('tr').at(4).findAll('td').at(1).text()).toEqual(
'4083774.05000000000000000000 GDD',
)
expect(wrapper.findAll('tr').at(4).findAll('td').at(2).text()).toEqual(
'4083774.05000000000000000000',
)
expect(wrapper.findAll('tr').at(5).findAll('td').at(1).text()).toEqual(
'-1062639.13634129622923372197 GDD',
)
expect(wrapper.findAll('tr').at(5).findAll('td').at(2).text()).toEqual(
'-1062639.13634129622923372197',
)
expect(wrapper.findAll('tr').at(6).findAll('td').at(1).text()).toEqual(
'2513565.869444365732411569 GDD',
)
expect(wrapper.findAll('tr').at(6).findAll('td').at(2).text()).toEqual(
'2513565.869444365732411569',
)
expect(wrapper.findAll('tr').at(7).findAll('td').at(1).text()).toEqual(
'-500474.6738366222166261272 GDD',
)
expect(wrapper.findAll('tr').at(7).findAll('td').at(2).text()).toEqual(
'-500474.6738366222166261272',
)
})
})
})
})

View File

@ -0,0 +1,42 @@
<template>
<div class="community-statistic">
<statistic-table v-model="statistics" />
</div>
</template>
<script>
import { communityStatistics } from '@/graphql/communityStatistics.js'
import StatisticTable from '../components/Tables/StatisticTable.vue'
export default {
name: 'CommunityStatistic',
components: {
StatisticTable,
},
data() {
return {
statistics: {
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
},
}
},
apollo: {
CommunityStatistics: {
query() {
return communityStatistics
},
update({ communityStatistics }) {
this.statistics = communityStatistics
},
error({ message }) {
this.toastError(message)
},
},
},
}
</script>

View File

@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils'
import ContributionLinks from './ContributionLinks.vue'
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
const localVue = global.localVue
const apolloQueryMock = jest.fn().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,
},
},
})
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$apollo: {
query: apolloQueryMock,
},
}
describe('ContributionLinks', () => {
// eslint-disable-next-line no-unused-vars
let wrapper
const Wrapper = () => {
return mount(ContributionLinks, { localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('calls listContributionLinks', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: listContributionLinks,
}),
)
})
})
})

View File

@ -0,0 +1,45 @@
<template>
<div class="contribution-link">
<contribution-link
:items="items"
:count="count"
@get-contribution-links="getContributionLinks"
/>
</div>
</template>
<script>
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import ContributionLink from '../components/ContributionLink/ContributionLink.vue'
export default {
name: 'ContributionLinks',
components: {
ContributionLink,
},
data() {
return {
items: [],
count: 0,
}
},
methods: {
getContributionLinks() {
this.$apollo
.query({
query: listContributionLinks,
fetchPolicy: 'network-only',
})
.then((result) => {
this.count = result.data.listContributionLinks.count
this.items = result.data.listContributionLinks.links
})
.catch(() => {
this.toastError('listContributionLinks has no result, use default data')
})
},
},
created() {
this.getContributionLinks()
},
}
</script>

View File

@ -1,42 +1,22 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import CreationConfirm from './CreationConfirm.vue' import CreationConfirm from './CreationConfirm.vue'
import { adminDeleteContribution } from '../graphql/adminDeleteContribution' import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
import { confirmContribution } from '../graphql/confirmContribution' import { confirmContribution } from '../graphql/confirmContribution'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup' 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 localVue = global.localVue
const storeCommitMock = jest.fn() localVue.use(VueApollo)
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,
},
],
},
})
const apolloMutateMock = jest.fn().mockResolvedValue({}) const storeCommitMock = jest.fn()
const mocks = { const mocks = {
$t: jest.fn((t) => t), $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', () => { describe('CreationConfirm', () => {
let wrapper 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 = () => { const Wrapper = () => {
return mount(CreationConfirm, { localVue, mocks }) return mount(CreationConfirm, { localVue, mocks, apolloProvider })
} }
describe('mount', () => { describe('mount', () => {
@ -72,6 +104,13 @@ describe('CreationConfirm', () => {
wrapper = Wrapper() wrapper = Wrapper()
}) })
describe('server response for get pending creations is error', () => {
it('toast an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
})
describe('server response is succes', () => {
it('has a DIV element with the class.creation-confirm', () => { it('has a DIV element with the class.creation-confirm', () => {
expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy() expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy()
}) })
@ -79,6 +118,7 @@ describe('CreationConfirm', () => {
it('has two pending creations', () => { it('has two pending creations', () => {
expect(wrapper.vm.pendingCreations).toHaveLength(2) expect(wrapper.vm.pendingCreations).toHaveLength(2)
}) })
})
describe('store', () => { describe('store', () => {
it('commits resetOpenCreations to store', () => { it('commits resetOpenCreations to store', () => {
@ -105,10 +145,7 @@ describe('CreationConfirm', () => {
}) })
it('calls the adminDeleteContribution mutation', () => { it('calls the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({ expect(adminDeleteContributionMock).toBeCalledWith({ id: 1 })
mutation: adminDeleteContribution,
variables: { id: 1 },
})
}) })
it('commits openCreationsMinus to store', () => { it('commits openCreationsMinus to store', () => {
@ -128,7 +165,7 @@ describe('CreationConfirm', () => {
}) })
it('does not call the adminDeleteContribution mutation', () => { it('does not call the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).not.toBeCalled() expect(adminDeleteContributionMock).not.toBeCalled()
}) })
}) })
}) })
@ -139,7 +176,7 @@ describe('CreationConfirm', () => {
beforeEach(async () => { beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm') spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve('some value')) 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') await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
}) })
@ -150,7 +187,6 @@ describe('CreationConfirm', () => {
describe('confirm creation with success', () => { describe('confirm creation with success', () => {
beforeEach(async () => { beforeEach(async () => {
apolloMutateMock.mockResolvedValue({})
await wrapper.findAll('tr').at(2).findAll('button').at(2).trigger('click') await wrapper.findAll('tr').at(2).findAll('button').at(2).trigger('click')
}) })
@ -179,10 +215,7 @@ describe('CreationConfirm', () => {
}) })
it('calls the confirmContribution mutation', () => { it('calls the confirmContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({ expect(confirmContributionMock).toBeCalledWith({ id: 2 })
mutation: confirmContribution,
variables: { id: 2 },
})
}) })
it('commits openCreationsMinus to store', () => { it('commits openCreationsMinus to store', () => {
@ -200,7 +233,7 @@ describe('CreationConfirm', () => {
describe('confirm creation with error', () => { describe('confirm creation with error', () => {
beforeEach(async () => { beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' }) confirmContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
await wrapper.find('#overlay').findAll('button').at(1).trigger('click') 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!')
})
})
}) })
}) })

View File

@ -10,6 +10,7 @@
@remove-creation="removeCreation" @remove-creation="removeCreation"
@show-overlay="showOverlay" @show-overlay="showOverlay"
@update-state="updateState" @update-state="updateState"
@update-contributions="$apollo.queries.PendingContributions.refetch()"
/> />
</div> </div>
</template> </template>
@ -71,21 +72,6 @@ export default {
this.toastError(error.message) 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) { updatePendingCreations(id) {
this.pendingCreations = this.pendingCreations.filter((obj) => obj.id !== id) this.pendingCreations = this.pendingCreations.filter((obj) => obj.id !== id)
this.$store.commit('openCreationsMinus', 1) this.$store.commit('openCreationsMinus', 1)
@ -127,8 +113,24 @@ export default {
] ]
}, },
}, },
async created() { apollo: {
await this.getPendingCreations() 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)
},
},
}, },
} }
</script> </script>

View File

@ -1,7 +1,5 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import Overview from './Overview.vue' import Overview from './Overview.vue'
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js' import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
const localVue = global.localVue const localVue = global.localVue
@ -23,40 +21,6 @@ const apolloQueryMock = jest
], ],
}, },
}) })
.mockResolvedValueOnce({
data: {
communityStatistics: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
},
})
.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({ .mockResolvedValue({
data: { data: {
listUnconfirmedContributions: [ listUnconfirmedContributions: [
@ -110,22 +74,6 @@ describe('Overview', () => {
) )
}) })
it('calls communityStatistics', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: communityStatistics,
}),
)
})
it('calls listContributionLinks', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: listContributionLinks,
}),
)
})
it('commits three pending creations to store', () => { it('commits three pending creations to store', () => {
expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3) expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3)
}) })

View File

@ -28,42 +28,13 @@
</b-link> </b-link>
</b-card-text> </b-card-text>
</b-card> </b-card>
<contribution-link
:items="items"
:count="count"
@get-contribution-links="getContributionLinks"
/>
<community-statistic class="mt-5" v-model="statistics" />
</div> </div>
</template> </template>
<script> <script>
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import ContributionLink from '../components/ContributionLink.vue'
import CommunityStatistic from '../components/CommunityStatistic.vue'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js' import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
export default { export default {
name: 'overview', name: 'overview',
components: {
ContributionLink,
CommunityStatistic,
},
data() {
return {
items: [],
count: 0,
statistics: {
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
},
}
},
methods: { methods: {
getPendingCreations() { getPendingCreations() {
this.$apollo this.$apollo
@ -75,45 +46,9 @@ export default {
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length) this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
}) })
}, },
getContributionLinks() {
this.$apollo
.query({
query: listContributionLinks,
fetchPolicy: 'network-only',
})
.then((result) => {
this.count = result.data.listContributionLinks.count
this.items = result.data.listContributionLinks.links
})
.catch(() => {
this.toastError('listContributionLinks has no result, use default data')
})
},
getCommunityStatistics() {
this.$apollo
.query({
query: communityStatistics,
})
.then((result) => {
this.statistics.totalUsers = result.data.communityStatistics.totalUsers
this.statistics.activeUsers = result.data.communityStatistics.activeUsers
this.statistics.deletedUsers = result.data.communityStatistics.deletedUsers
this.statistics.totalGradidoCreated = result.data.communityStatistics.totalGradidoCreated
this.statistics.totalGradidoDecayed = result.data.communityStatistics.totalGradidoDecayed
this.statistics.totalGradidoAvailable =
result.data.communityStatistics.totalGradidoAvailable
this.statistics.totalGradidoUnbookedDecayed =
result.data.communityStatistics.totalGradidoUnbookedDecayed
})
.catch(() => {
this.toastError('communityStatistics has no result, use default data')
})
},
}, },
created() { created() {
this.getPendingCreations() this.getPendingCreations()
this.getCommunityStatistics()
this.getContributionLinks()
}, },
} }
</script> </script>

View File

@ -10,7 +10,7 @@ const authLink = new ApolloLink((operation, forward) => {
operation.setContext({ operation.setContext({
headers: { headers: {
Authorization: token && token.length > 0 ? `Bearer ${token}` : '', Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
clientRequestTime: new Date().toString(), clientTimezoneOffset: new Date().getTimezoneOffset(),
}, },
}) })
return forward(operation).map((response) => { return forward(operation).map((response) => {

View File

@ -94,7 +94,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: 'Bearer some-token', Authorization: 'Bearer some-token',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })
@ -110,7 +110,7 @@ describe('apolloProvider', () => {
expect(setContextMock).toBeCalledWith({ expect(setContextMock).toBeCalledWith({
headers: { headers: {
Authorization: '', Authorization: '',
clientRequestTime: expect.any(String), clientTimezoneOffset: expect.any(Number),
}, },
}) })
}) })

View File

@ -44,8 +44,8 @@ describe('router', () => {
}) })
describe('routes', () => { describe('routes', () => {
it('has seven routes defined', () => { it('has nine routes defined', () => {
expect(routes).toHaveLength(7) expect(routes).toHaveLength(9)
}) })
it('has "/overview" as default', async () => { it('has "/overview" as default', async () => {
@ -81,6 +81,20 @@ describe('router', () => {
}) })
}) })
describe('contribution-links', () => {
it('loads the "ContributionLinks" page', async () => {
const component = await routes.find((r) => r.path === '/contribution-links').component()
expect(component.default.name).toBe('ContributionLinks')
})
})
describe('statistics', () => {
it('loads the "CommunityStatistic" page', async () => {
const component = await routes.find((r) => r.path === '/statistic').component()
expect(component.default.name).toBe('CommunityStatistic')
})
})
describe('not found page', () => { describe('not found page', () => {
it('renders the "NotFound" component', async () => { it('renders the "NotFound" component', async () => {
const component = await routes.find((r) => r.path === '*').component() const component = await routes.find((r) => r.path === '*').component()

View File

@ -6,6 +6,10 @@ const routes = [
path: '/', path: '/',
component: () => import('@/pages/Overview.vue'), component: () => import('@/pages/Overview.vue'),
}, },
{
path: '/statistic',
component: () => import('@/pages/CommunityStatistic.vue'),
},
{ {
// TODO: Implement a "You are logged out"-Page // TODO: Implement a "You are logged out"-Page
path: '/logout', path: '/logout',
@ -23,6 +27,10 @@ const routes = [
path: '/creation-confirm', path: '/creation-confirm',
component: () => import('@/pages/CreationConfirm.vue'), component: () => import('@/pages/CreationConfirm.vue'),
}, },
{
path: '/contribution-links',
component: () => import('@/pages/ContributionLinks.vue'),
},
{ {
path: '*', path: '*',
component: () => import('@/components/NotFoundPage.vue'), component: () => import('@/components/NotFoundPage.vue'),

View File

@ -2,6 +2,25 @@
# yarn lockfile v1 # 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": "@babel/code-frame@7.12.11":
version "7.12.11" version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
@ -1030,6 +1049,11 @@
minimatch "^3.0.4" minimatch "^3.0.4"
strip-json-comments "^3.1.1" 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": "@hapi/address@2.x.x":
version "2.1.4" version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
@ -2419,6 +2443,20 @@
"@types/node" ">=6" "@types/node" ">=6"
tslib "^1.9.3" 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": "@wry/equality@^0.1.2":
version "0.1.11" version "0.1.11"
resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790"
@ -2426,6 +2464,20 @@
dependencies: dependencies:
tslib "^1.9.3" 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": "@xtuc/ieee754@^1.2.0":
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" 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" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== 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: graphql-tag@^2.4.2:
version "2.12.5" version "2.12.5"
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.5.tgz#5cff974a67b417747d05c8d9f5f3cb4495d0db8f" 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-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1" 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: homedir-polyfill@^1.0.1:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8" resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
@ -9174,7 +9240,7 @@ lolex@^5.0.0:
dependencies: dependencies:
"@sinonjs/commons" "^1.7.0" "@sinonjs/commons" "^1.7.0"
loose-envify@^1.0.0: loose-envify@^1.0.0, loose-envify@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 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: dependencies:
minimist "^1.2.5" 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: moo-color@^1.0.2:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74" resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
@ -9987,6 +10058,14 @@ optimism@^0.10.0:
dependencies: dependencies:
"@wry/context" "^0.4.0" "@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: optionator@^0.8.1:
version "0.8.3" version "0.8.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
@ -10901,6 +10980,15 @@ prompts@^2.0.1:
kleur "^3.0.3" kleur "^3.0.3"
sisteransi "^1.0.5" 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: proto-list@~1.2.1:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" 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" iconv-lite "0.4.24"
unpipe "1.0.0" 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" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== 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" is-core-module "^2.2.0"
path-parse "^1.0.6" 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: restore-cursor@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" 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" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== 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: symbol-tree@^3.2.2, symbol-tree@^3.2.4:
version "3.2.4" version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" 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" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== 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: ts-invariant@^0.4.0:
version "0.4.4" version "0.4.4"
resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" 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" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== 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: tsutils@^3.21.0:
version "3.21.0" version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" 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" tslib "^1.9.3"
zen-observable "^0.8.0" 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" version "0.8.15"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ== integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v10.2022-09-20 CONFIG_VERSION=v11.2022-10-27
# Server # Server
PORT=4000 PORT=4000
@ -60,3 +60,8 @@ EVENT_PROTOCOL_DISABLED=false
# SET LOG LEVEL AS NEEDED IN YOUR .ENV # SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal # POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
# LOG_LEVEL=info # 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

View File

@ -55,3 +55,6 @@ WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET
# EventProtocol # EventProtocol
EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED
# DHT
DHT_TOPIC=$DHT_TOPIC

View File

@ -1,7 +1,7 @@
################################################################################## ##################################################################################
# BASE ########################################################################### # BASE ###########################################################################
################################################################################## ##################################################################################
FROM node:12.19.0-alpine3.10 as base FROM node:18.7.0-alpine3.16 as base
# ENVs (available in production aswell, can be overwritten by commandline or env file) # ENVs (available in production aswell, can be overwritten by commandline or env file)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame ## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame

View File

@ -1,6 +1,6 @@
{ {
"name": "gradido-backend", "name": "gradido-backend",
"version": "1.13.1", "version": "1.14.1",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions", "description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts", "main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend", "repository": "https://github.com/gradido/gradido/backend",
@ -18,6 +18,9 @@
"klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/klicktipp.ts" "klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/klicktipp.ts"
}, },
"dependencies": { "dependencies": {
"@hyperswarm/dht": "^6.2.0",
"@types/email-templates": "^10.0.1",
"@types/i18n": "^0.13.4",
"@types/jest": "^27.0.2", "@types/jest": "^27.0.2",
"@types/lodash.clonedeep": "^4.5.6", "@types/lodash.clonedeep": "^4.5.6",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
@ -29,14 +32,17 @@
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"decimal.js-light": "^2.5.1", "decimal.js-light": "^2.5.1",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"email-templates": "^10.0.1",
"express": "^4.17.1", "express": "^4.17.1",
"graphql": "^15.5.1", "graphql": "^15.5.1",
"i18n": "^0.15.1",
"jest": "^27.2.4", "jest": "^27.2.4",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"log4js": "^6.4.6", "log4js": "^6.4.6",
"mysql2": "^2.3.0", "mysql2": "^2.3.0",
"nodemailer": "^6.6.5", "nodemailer": "^6.6.5",
"pug": "^3.0.2",
"random-bigint": "^0.0.1", "random-bigint": "^0.0.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"sodium-native": "^3.3.0", "sodium-native": "^3.3.0",

View File

@ -10,14 +10,14 @@ Decimal.set({
}) })
const constants = { const constants = {
DB_VERSION: '0051-add_delete_by_to_contributions', DB_VERSION: '0053-change_password_encryption',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0 DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json', LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info // default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL || 'info', LOG_LEVEL: process.env.LOG_LEVEL || 'info',
CONFIG_VERSION: { CONFIG_VERSION: {
DEFAULT: 'DEFAULT', DEFAULT: 'DEFAULT',
EXPECTED: 'v10.2022-09-20', EXPECTED: 'v11.2022-10-27',
CURRENT: '', CURRENT: '',
}, },
} }
@ -116,6 +116,10 @@ if (
) )
} }
const federation = {
DHT_TOPIC: process.env.DHT_TOPIC || null,
}
const CONFIG = { const CONFIG = {
...constants, ...constants,
...server, ...server,
@ -126,6 +130,7 @@ const CONFIG = {
...loginServer, ...loginServer,
...webhook, ...webhook,
...eventProtocol, ...eventProtocol,
...federation,
} }
export default CONFIG export default CONFIG

View File

@ -0,0 +1,50 @@
# Using `forwardemail``email-templates` With `pug` Package
You'll find the GitHub repository of the `email-templates` package and the `pug` package here:
- [email-templates](https://github.com/forwardemail/email-templates)
- [pug](https://www.npmjs.com/package/pug)
## `pug` Documentation
The full `pug` documentation you'll find here:
- [pugjs.org](https://pugjs.org/)
### Caching Possibility
In case we are sending many emails in the future there is the possibility to cache the `pug` templates:
- [cache-pug-templates](https://github.com/ladjs/cache-pug-templates)
## Testing
To test your send emails you have different possibilities:
### In General
To send emails to yourself while developing set in `.env` the value `EMAIL_TEST_MODUS=true` and `EMAIL_TEST_RECEIVER` to your preferred email address.
### Unit Or Integration Tests
To change the behavior to show previews etc. you have the following options to be set in `sendEmailTranslated.ts` on creating the email object:
```js
const email = new Email({
// send emails in development/test env:
send: true,
// to open send emails in the browser
preview: true,
// or
// to open send emails in a specific the browser
preview: {
open: {
app: 'firefox',
wait: false,
},
},
})
```

View File

@ -0,0 +1,22 @@
doctype html
html(lang=locale)
head
title= t('emails.accountMultiRegistration.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.accountMultiRegistration.subject')
#container.col
p(style='margin-bottom: 24px;')= t('emails.accountMultiRegistration.helloName', { firstName, lastName })
p= t('emails.accountMultiRegistration.emailReused')
br
span= t('emails.accountMultiRegistration.emailExists')
p= t('emails.accountMultiRegistration.onForgottenPasswordClickLink')
br
a(href=resendLink) #{resendLink}
br
span= t('emails.accountMultiRegistration.onForgottenPasswordCopyLink')
p= t('emails.accountMultiRegistration.ifYouAreNotTheOne')
br
a(href='https://gradido.net/de/contact/') https://gradido.net/de/contact/
p(style='margin-top: 24px;')= t('emails.accountMultiRegistration.sincerelyYours')
br
span= t('emails.accountMultiRegistration.yourGradidoTeam')

View File

@ -0,0 +1 @@
= t('emails.accountMultiRegistration.subject')

View File

@ -0,0 +1,110 @@
import { createTransport } from 'nodemailer'
import { logger, i18n } from '@test/testSetup'
import CONFIG from '@/config'
import { sendEmailTranslated } from './sendEmailTranslated'
CONFIG.EMAIL = false
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
CONFIG.EMAIL_SMTP_PORT = '1234'
CONFIG.EMAIL_USERNAME = 'user'
CONFIG.EMAIL_PASSWORD = 'pwd'
jest.mock('nodemailer', () => {
return {
__esModule: true,
createTransport: jest.fn(() => {
return {
sendMail: jest.fn(() => {
return {
messageId: 'message',
}
}),
}
}),
}
})
describe('sendEmailTranslated', () => {
let result: Record<string, unknown> | null
describe('config email is false', () => {
beforeEach(async () => {
result = await sendEmailTranslated({
receiver: {
to: 'receiver@mail.org',
cc: 'support@gradido.net',
},
template: 'accountMultiRegistration',
locals: {
locale: 'en',
},
})
})
it('logs warning', () => {
expect(logger.info).toBeCalledWith('Emails are disabled via config...')
})
it('returns false', () => {
expect(result).toBeFalsy()
})
})
describe('config email is true', () => {
beforeEach(async () => {
CONFIG.EMAIL = true
result = await sendEmailTranslated({
receiver: {
to: 'receiver@mail.org',
cc: 'support@gradido.net',
},
template: 'accountMultiRegistration',
locals: {
locale: 'en',
},
})
})
it('calls the transporter', () => {
expect(createTransport).toBeCalledWith({
host: 'EMAIL_SMTP_URL',
port: 1234,
secure: false,
requireTLS: true,
auth: {
user: 'user',
pass: 'pwd',
},
})
})
describe('call of "sendEmailTranslated"', () => {
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['receiver@mail.org', 'support@gradido.net'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
from: 'Gradido (nicht antworten) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Try To Register Again With Your Email',
html: expect.stringContaining('Gradido: Try To Register Again With Your Email'),
text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'),
}),
})
})
})
it.skip('calls "i18n.setLocale" with "en"', () => {
expect(i18n.setLocale).toBeCalledWith('en')
})
it.skip('calls "i18n.__" for translation', () => {
expect(i18n.__).toBeCalled()
})
})
})

View File

@ -0,0 +1,85 @@
import { backendLogger as logger } from '@/server/logger'
import path from 'path'
import { createTransport } from 'nodemailer'
import Email from 'email-templates'
import i18n from 'i18n'
import CONFIG from '@/config'
export const sendEmailTranslated = async (params: {
receiver: {
to: string
cc?: string
}
template: string
locals: Record<string, string>
}): Promise<Record<string, unknown> | null> => {
let resultSend: Record<string, unknown> | null = null
// TODO: test the calling order of 'i18n.setLocale' for example: language of logging 'en', language of email receiver 'es', reset language of current user 'de'
// because language of receiver can differ from language of current user who triggers the sending
const rememberLocaleToRestore = i18n.getLocale()
i18n.setLocale('en') // for logging
logger.info(
`send Email: language=${params.locals.locale} to=${params.receiver.to}` +
(params.receiver.cc ? `, cc=${params.receiver.cc}` : '') +
`, subject=${i18n.__('emails.' + params.template + '.subject')}`,
)
if (!CONFIG.EMAIL) {
logger.info(`Emails are disabled via config...`)
return null
}
// because 'CONFIG.EMAIL_TEST_MODUS' can be boolean 'true' or string '`false`'
if (CONFIG.EMAIL_TEST_MODUS === true) {
logger.info(
`Testmodus=ON: change receiver from ${params.receiver.to} to ${CONFIG.EMAIL_TEST_RECEIVER}`,
)
params.receiver.to = CONFIG.EMAIL_TEST_RECEIVER
}
const transport = createTransport({
host: CONFIG.EMAIL_SMTP_URL,
port: Number(CONFIG.EMAIL_SMTP_PORT),
secure: false, // true for 465, false for other ports
requireTLS: true,
auth: {
user: CONFIG.EMAIL_USERNAME,
pass: CONFIG.EMAIL_PASSWORD,
},
})
i18n.setLocale(params.locals.locale) // for email
// TESTING: see 'README.md'
const email = new Email({
message: {
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
},
transport,
preview: false,
// i18n, // is only needed if you don't install i18n
})
// ATTENTION: await is needed, because otherwise on send the email gets send in the language of the current user, because below the language gets reset
await email
.send({
template: path.join(__dirname, params.template),
message: params.receiver,
locals: params.locals, // the 'locale' in here seems not to be used by 'email-template', because it doesn't work if the language isn't set before by 'i18n.setLocale'
})
.then((result: Record<string, unknown>) => {
resultSend = result
logger.info('Send email successfully !!!')
logger.info('Result: ', result)
})
.catch((error: unknown) => {
logger.error('Error sending notification email: ', error)
throw new Error('Error sending notification email!')
})
i18n.setLocale(rememberLocaleToRestore)
return resultSend
}

View File

@ -0,0 +1,88 @@
import CONFIG from '@/config'
import { sendAccountMultiRegistrationEmail } from './sendEmailVariants'
import { sendEmailTranslated } from './sendEmailTranslated'
CONFIG.EMAIL = true
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
CONFIG.EMAIL_SMTP_PORT = '1234'
CONFIG.EMAIL_USERNAME = 'user'
CONFIG.EMAIL_PASSWORD = 'pwd'
jest.mock('./sendEmailTranslated', () => {
const originalModule = jest.requireActual('./sendEmailTranslated')
return {
__esModule: true,
sendEmailTranslated: jest.fn((a) => originalModule.sendEmailTranslated(a)),
}
})
describe('sendEmailVariants', () => {
let result: Record<string, unknown> | null
describe('sendAccountMultiRegistrationEmail', () => {
beforeAll(async () => {
result = await sendAccountMultiRegistrationEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'accountMultiRegistration',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (nicht antworten) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Try To Register Again With Your Email',
html:
expect.stringContaining(
'<title>Gradido: Try To Register Again With Your Email</title>',
) &&
expect.stringContaining('>Gradido: Try To Register Again With Your Email</h1>') &&
expect.stringContaining(
'Your email address has just been used again to register an account with Gradido.',
) &&
expect.stringContaining(
'However, an account already exists for your email address.',
) &&
expect.stringContaining(
'Please click on the following link if you have forgotten your password:',
) &&
expect.stringContaining(
`<a href="${CONFIG.EMAIL_LINK_FORGOTPASSWORD}">${CONFIG.EMAIL_LINK_FORGOTPASSWORD}</a>`,
) &&
expect.stringContaining('or copy the link above into your browser window.') &&
expect.stringContaining(
'If you are not the one who tried to register again, please contact our support:',
) &&
expect.stringContaining('Sincerely yours,<br><span>your Gradido team'),
text: expect.stringContaining('GRADIDO: TRY TO REGISTER AGAIN WITH YOUR EMAIL'),
}),
})
})
})
})
})

View File

@ -0,0 +1,20 @@
import CONFIG from '@/config'
import { sendEmailTranslated } from './sendEmailTranslated'
export const sendAccountMultiRegistrationEmail = (data: {
firstName: string
lastName: string
email: string
language: string
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'accountMultiRegistration',
locals: {
locale: data.language,
firstName: data.firstName,
lastName: data.lastName,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
},
})
}

View File

@ -66,6 +66,9 @@ export class EventTransactionCreation extends EventBasicTx {}
export class EventTransactionReceive extends EventBasicTxX {} export class EventTransactionReceive extends EventBasicTxX {}
export class EventTransactionReceiveRedeem extends EventBasicTxX {} export class EventTransactionReceiveRedeem extends EventBasicTxX {}
export class EventContributionCreate extends EventBasicCt {} export class EventContributionCreate extends EventBasicCt {}
export class EventAdminContributionCreate extends EventBasicCt {}
export class EventAdminContributionDelete extends EventBasicCt {}
export class EventAdminContributionUpdate extends EventBasicCt {}
export class EventUserCreateContributionMessage extends EventBasicCtMsg {} export class EventUserCreateContributionMessage extends EventBasicCtMsg {}
export class EventAdminCreateContributionMessage extends EventBasicCtMsg {} export class EventAdminCreateContributionMessage extends EventBasicCtMsg {}
export class EventContributionDelete extends EventBasicCt {} export class EventContributionDelete extends EventBasicCt {}
@ -74,6 +77,14 @@ export class EventContributionConfirm extends EventBasicCtX {}
export class EventContributionDeny extends EventBasicCtX {} export class EventContributionDeny extends EventBasicCtX {}
export class EventContributionLinkDefine extends EventBasicCt {} export class EventContributionLinkDefine extends EventBasicCt {}
export class EventContributionLinkActivateRedeem 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 { export class Event {
constructor() constructor()
@ -289,6 +300,27 @@ export class Event {
return this return this
} }
public setEventAdminContributionCreate(ev: EventAdminContributionCreate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
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 { public setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event {
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId) this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE
@ -345,6 +377,62 @@ export class Event {
return this 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 { setByBasicUser(userId: number): Event {
this.setEventBasic() this.setEventBasic()
this.userId = userId this.userId = userId

View File

@ -33,6 +33,17 @@ export enum EventProtocolType {
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM', CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE', CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE',
CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE', 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', USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE',
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_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',
} }

View File

@ -0,0 +1 @@
declare module '@hyperswarm/dht'

View File

@ -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<void> => {
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)
}
}

View File

@ -0,0 +1,12 @@
import { registerEnumType } from 'type-graphql'
export enum PasswordEncryptionType {
NO_PASSWORD = 0,
EMAIL = 1,
GRADIDO_ID = 2,
}
registerEnumType(PasswordEncryptionType, {
name: 'PasswordEncryptionType', // this one is mandatory
description: 'Type of the password encryption', // this one is optional
})

View File

@ -2,7 +2,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { objectValuesToArray } from '@/util/utilities' import { objectValuesToArray } from '@/util/utilities'
import { testEnvironment, resetToken, cleanDB } from '@test/helpers' import { testEnvironment, resetToken, cleanDB, contributionDateFormatter } from '@test/helpers'
import { userFactory } from '@/seeds/factory/user' import { userFactory } from '@/seeds/factory/user'
import { creationFactory } from '@/seeds/factory/creation' import { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index' import { creations } from '@/seeds/creation/index'
@ -42,6 +42,9 @@ import { Contribution } from '@entity/Contribution'
import { Transaction as DbTransaction } from '@entity/Transaction' import { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' 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 // mock account activation email to avoid console spam
jest.mock('@/mailer/sendAccountActivationEmail', () => { jest.mock('@/mailer/sendAccountActivationEmail', () => {
@ -80,6 +83,12 @@ let user: User
let creation: Contribution | void let creation: Contribution | void
let result: any let result: any
describe('contributionDateFormatter', () => {
it('formats the date correctly', () => {
expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024')
})
})
describe('AdminResolver', () => { describe('AdminResolver', () => {
describe('set user role', () => { describe('set user role', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
@ -136,6 +145,7 @@ describe('AdminResolver', () => {
describe('user to get a new role does not exist', () => { describe('user to get a new role does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }), mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }),
).resolves.toEqual( ).resolves.toEqual(
@ -144,6 +154,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', () => { describe('change role with success', () => {
@ -188,6 +202,7 @@ describe('AdminResolver', () => {
describe('change role with error', () => { describe('change role with error', () => {
describe('is own role', () => { describe('is own role', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }), mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }),
).resolves.toEqual( ).resolves.toEqual(
@ -196,11 +211,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('user has already role to be set', () => {
describe('to admin', () => { describe('to admin', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await mutate({ await mutate({
mutation: setUserRole, mutation: setUserRole,
variables: { userId: user.id, isAdmin: true }, variables: { userId: user.id, isAdmin: true },
@ -213,10 +232,15 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already admin!')
})
}) })
describe('to usual user', () => { describe('to usual user', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await mutate({ await mutate({
mutation: setUserRole, mutation: setUserRole,
variables: { userId: user.id, isAdmin: false }, variables: { userId: user.id, isAdmin: false },
@ -229,6 +253,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already a usual user!')
})
}) })
}) })
}) })
@ -289,6 +317,7 @@ describe('AdminResolver', () => {
describe('user to be deleted does not exist', () => { describe('user to be deleted does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }), mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }),
).resolves.toEqual( ).resolves.toEqual(
@ -297,10 +326,15 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
})
}) })
describe('delete self', () => { describe('delete self', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: deleteUser, variables: { userId: admin.id } }), mutate({ mutation: deleteUser, variables: { userId: admin.id } }),
).resolves.toEqual( ).resolves.toEqual(
@ -309,6 +343,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not delete his own account!')
})
}) })
describe('delete with success', () => { describe('delete with success', () => {
@ -330,6 +368,7 @@ describe('AdminResolver', () => {
describe('delete deleted user', () => { describe('delete deleted user', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: deleteUser, variables: { userId: user.id } }), mutate({ mutation: deleteUser, variables: { userId: user.id } }),
).resolves.toEqual( ).resolves.toEqual(
@ -338,6 +377,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`)
})
}) })
}) })
}) })
@ -397,6 +440,7 @@ describe('AdminResolver', () => {
describe('user to be undelete does not exist', () => { describe('user to be undelete does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }), mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }),
).resolves.toEqual( ).resolves.toEqual(
@ -405,6 +449,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', () => { describe('user to undelete is not deleted', () => {
@ -413,6 +461,7 @@ describe('AdminResolver', () => {
}) })
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), mutate({ mutation: unDeleteUser, variables: { userId: user.id } }),
).resolves.toEqual( ).resolves.toEqual(
@ -422,6 +471,10 @@ describe('AdminResolver', () => {
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is not deleted')
})
describe('undelete deleted user', () => { describe('undelete deleted user', () => {
beforeAll(async () => { beforeAll(async () => {
await mutate({ mutation: deleteUser, variables: { userId: user.id } }) await mutate({ mutation: deleteUser, variables: { userId: user.id } })
@ -704,7 +757,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -814,7 +867,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -889,18 +942,25 @@ describe('AdminResolver', () => {
}) })
describe('adminCreateContribution', () => { describe('adminCreateContribution', () => {
beforeAll(async () => {
const now = new Date() const now = new Date()
beforeAll(async () => {
creation = await creationFactory(testEnv, { creation = await creationFactory(testEnv, {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: 400, amount: 400,
memo: 'Herzlich Willkommen bei Gradido!', memo: 'Herzlich Willkommen bei Gradido!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
),
}) })
}) })
describe('user to create for does not exist', () => { describe('user to create for does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -909,15 +969,25 @@ 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', () => { describe('user to create for is deleted', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, stephenHawking) user = await userFactory(testEnv, stephenHawking)
variables.email = 'stephen@hawking.uk' variables.email = 'stephen@hawking.uk'
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
}) })
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -928,15 +998,25 @@ 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', () => { describe('user to create for has email not confirmed', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, garrickOllivander) user = await userFactory(testEnv, garrickOllivander)
variables.email = 'garrick@ollivander.com' variables.email = 'garrick@ollivander.com'
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
}) })
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -947,16 +1027,44 @@ 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', () => { describe('valid user to create for', () => {
beforeAll(async () => { beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg) user = await userFactory(testEnv, bibiBloxberg)
variables.email = 'bibi@bloxberg.de' variables.email = 'bibi@bloxberg.de'
variables.creationDate = 'invalid-date'
}) })
describe('date of creation is not a date string', () => { describe('date of creation is not a date string', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`invalid Date for creationDate=invalid-date`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`invalid Date for creationDate=invalid-date`)
})
})
describe('date of creation is four months ago', () => {
it('throws an error', async () => {
jest.clearAllMocks()
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 4, 1),
)
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -967,36 +1075,21 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
})
describe('date of creation is four months ago', () => { it('logs the error thrown', () => {
it('throws an error', async () => { expect(logger.error).toBeCalledWith(
const now = new Date() 'No information for available creations with the given creationDate=',
variables.creationDate = new Date( new Date(variables.creationDate).toString(),
now.getFullYear(),
now.getMonth() - 4,
1,
).toString()
await expect(
mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError('No information for available creations for the given date'),
],
}),
) )
}) })
}) })
describe('date of creation is in the future', () => { describe('date of creation is in the future', () => {
it('throws an error', async () => { it('throws an error', async () => {
const now = new Date() jest.clearAllMocks()
variables.creationDate = new Date( variables.creationDate = contributionDateFormatter(
now.getFullYear(), new Date(now.getFullYear(), now.getMonth() + 4, 1),
now.getMonth() + 4, )
1,
).toString()
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -1007,11 +1100,19 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
new Date(variables.creationDate).toString(),
)
})
}) })
describe('amount of creation is too high', () => { describe('amount of creation is too high', () => {
it('throws an error', async () => { it('throws an error', async () => {
variables.creationDate = new Date().toString() jest.clearAllMocks()
variables.creationDate = contributionDateFormatter(now)
await expect( await expect(
mutate({ mutation: adminCreateContribution, variables }), mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual( ).resolves.toEqual(
@ -1024,6 +1125,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', () => { describe('creation is valid', () => {
@ -1039,6 +1146,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 ', () => { describe('second creation surpasses the available amount ', () => {
@ -1056,6 +1172,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.',
)
})
}) })
}) })
}) })
@ -1078,7 +1200,7 @@ describe('AdminResolver', () => {
email, email,
amount: new Decimal(500), amount: new Decimal(500),
memo: 'Grundeinkommen', memo: 'Grundeinkommen',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
} }
}) })
@ -1115,6 +1237,7 @@ describe('AdminResolver', () => {
describe('user for creation to update does not exist', () => { describe('user for creation to update does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1123,7 +1246,7 @@ describe('AdminResolver', () => {
email: 'bob@baumeister.de', email: 'bob@baumeister.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1134,10 +1257,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', () => { describe('user for creation to update is deleted', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1146,7 +1276,7 @@ describe('AdminResolver', () => {
email: 'stephen@hawking.uk', email: 'stephen@hawking.uk',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1155,10 +1285,15 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)')
})
}) })
describe('creation does not exist', () => { describe('creation does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1167,7 +1302,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1176,10 +1311,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', () => { describe('user email does not match creation user', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1188,7 +1328,9 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Bibi!', memo: 'Danke Bibi!',
creationDate: new Date().toString(), creationDate: creation
? contributionDateFormatter(creation.contributionDate)
: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1201,11 +1343,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', () => { describe('creation update is not valid', () => {
// as this test has not clearly defined that date, it is a false positive // as this test has not clearly defined that date, it is a false positive
it.skip('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1214,24 +1363,32 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: new Decimal(1900), amount: new Decimal(1900),
memo: 'Danke Peter!', memo: 'Danke Peter!',
creationDate: new Date().toString(), creationDate: creation
? contributionDateFormatter(creation.contributionDate)
: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( 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 // skipped as changing the month is currently disable
it.skip('returns update creation object', async () => { it('returns update creation object', async () => {
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1240,7 +1397,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: new Decimal(300), amount: new Decimal(300),
memo: 'Danke Peter!', memo: 'Danke Peter!',
creationDate: new Date().toString(), creationDate: creation
? contributionDateFormatter(creation.contributionDate)
: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1250,17 +1409,26 @@ describe('AdminResolver', () => {
date: expect.any(String), date: expect.any(String),
memo: 'Danke Peter!', memo: 'Danke Peter!',
amount: '300', 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', () => { describe('creation update is successful without changing month', () => {
// actually this mutation IS changing the month // actually this mutation IS changing the month
it.skip('returns update creation object', async () => { it('returns update creation object', async () => {
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -1269,7 +1437,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: new Decimal(200), amount: new Decimal(200),
memo: 'Das war leider zu Viel!', memo: 'Das war leider zu Viel!',
creationDate: new Date().toString(), creationDate: creation
? contributionDateFormatter(creation.contributionDate)
: contributionDateFormatter(new Date()),
}, },
}), }),
).resolves.toEqual( ).resolves.toEqual(
@ -1279,12 +1449,21 @@ describe('AdminResolver', () => {
date: expect.any(String), date: expect.any(String),
memo: 'Das war leider zu Viel!', memo: 'Das war leider zu Viel!',
amount: '200', 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,
}),
)
})
}) })
}) })
@ -1304,10 +1483,10 @@ describe('AdminResolver', () => {
lastName: 'Lustig', lastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
date: expect.any(String), date: expect.any(String),
memo: 'Herzlich Willkommen bei Gradido!', memo: 'Das war leider zu Viel!',
amount: '400', amount: '200',
moderator: admin.id, moderator: admin.id,
creation: ['1000', '600', '500'], creation: ['1000', '800', '500'],
}, },
{ {
id: expect.any(Number), id: expect.any(Number),
@ -1318,7 +1497,7 @@ describe('AdminResolver', () => {
memo: 'Grundeinkommen', memo: 'Grundeinkommen',
amount: '500', amount: '500',
moderator: admin.id, moderator: admin.id,
creation: ['1000', '600', '500'], creation: ['1000', '800', '500'],
}, },
{ {
id: expect.any(Number), id: expect.any(Number),
@ -1352,6 +1531,7 @@ describe('AdminResolver', () => {
describe('adminDeleteContribution', () => { describe('adminDeleteContribution', () => {
describe('creation id does not exist', () => { describe('creation id does not exist', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: adminDeleteContribution, mutation: adminDeleteContribution,
@ -1365,6 +1545,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution not found for given id: -1')
})
}) })
describe('admin deletes own user contribution', () => { describe('admin deletes own user contribution', () => {
@ -1378,12 +1562,13 @@ describe('AdminResolver', () => {
variables: { variables: {
amount: 100.0, amount: 100.0,
memo: 'Test env contribution', memo: 'Test env contribution',
creationDate: new Date().toString(), creationDate: contributionDateFormatter(new Date()),
}, },
}) })
}) })
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: adminDeleteContribution, mutation: adminDeleteContribution,
@ -1414,12 +1599,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('confirmContribution', () => {
describe('creation does not exits', () => { describe('creation does not exits', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: confirmContribution, mutation: confirmContribution,
@ -1433,6 +1628,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution not found for given id: -1')
})
}) })
describe('confirm own creation', () => { describe('confirm own creation', () => {
@ -1442,7 +1641,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de', email: 'peter@lustig.de',
amount: 400, amount: 400,
memo: 'Herzlich Willkommen bei Gradido!', memo: 'Herzlich Willkommen bei Gradido!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
),
}) })
}) })
@ -1460,6 +1661,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution')
})
}) })
describe('confirm creation for other user', () => { describe('confirm creation for other user', () => {
@ -1469,7 +1674,9 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 450, amount: 450,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
}) })
@ -1488,6 +1695,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 () => { it('creates a transaction', async () => {
const transaction = await DbTransaction.find() const transaction = await DbTransaction.find()
expect(transaction[0].amount.toString()).toBe('450') expect(transaction[0].amount.toString()).toBe('450')
@ -1512,6 +1727,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', () => { describe('confirm two creations one after the other quickly', () => {
@ -1524,13 +1747,17 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 50, amount: 50,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
c2 = await creationFactory(testEnv, { c2 = await creationFactory(testEnv, {
email: 'bibi@bloxberg.de', email: 'bibi@bloxberg.de',
amount: 50, amount: 50,
memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', memo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, 1),
),
}) })
}) })
@ -2052,6 +2279,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 () => { it('returns an error if missing endDate', async () => {
await expect( await expect(
mutate({ mutate({
@ -2068,6 +2301,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 () => { it('returns an error if endDate is before startDate', async () => {
await expect( await expect(
mutate({ mutate({
@ -2087,6 +2326,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 () => { it('returns an error if name is an empty string', async () => {
await expect( await expect(
mutate({ mutate({
@ -2103,6 +2348,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 () => { it('returns an error if name is shorter than 5 characters', async () => {
await expect( await expect(
mutate({ mutate({
@ -2123,6 +2372,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 () => { it('returns an error if name is longer than 100 characters', async () => {
await expect( await expect(
mutate({ mutate({
@ -2143,6 +2398,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 () => { it('returns an error if memo is an empty string', async () => {
await expect( await expect(
mutate({ mutate({
@ -2159,6 +2420,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 () => { it('returns an error if memo is shorter than 5 characters', async () => {
await expect( await expect(
mutate({ mutate({
@ -2179,6 +2444,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 () => { it('returns an error if memo is longer than 255 characters', async () => {
await expect( await expect(
mutate({ mutate({
@ -2199,6 +2470,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 () => { it('returns an error if amount is not positive', async () => {
await expect( await expect(
mutate({ mutate({
@ -2216,6 +2493,12 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount=0 must be initialized with a positiv value!',
)
})
}) })
describe('listContributionLinks', () => { describe('listContributionLinks', () => {
@ -2271,6 +2554,10 @@ describe('AdminResolver', () => {
}) })
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
})
describe('valid id', () => { describe('valid id', () => {
let linkId: number let linkId: number
beforeAll(async () => { beforeAll(async () => {
@ -2336,6 +2623,10 @@ describe('AdminResolver', () => {
}), }),
) )
}) })
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
})
}) })
describe('valid id', () => { describe('valid id', () => {

View File

@ -1,4 +1,4 @@
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql' import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql'
import { import {
@ -49,6 +49,7 @@ import {
validateContribution, validateContribution,
isStartEndDateValid, isStartEndDateValid,
updateCreations, updateCreations,
isValidDateString,
} from './util/creations' } from './util/creations'
import { import {
CONTRIBUTIONLINK_NAME_MAX_CHARS, CONTRIBUTIONLINK_NAME_MAX_CHARS,
@ -63,7 +64,17 @@ import ContributionMessageArgs from '@arg/ContributionMessageArgs'
import { ContributionMessageType } from '@enum/MessageType' import { ContributionMessageType } from '@enum/MessageType'
import { ContributionMessage } from '@model/ContributionMessage' import { ContributionMessage } from '@model/ContributionMessage'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail' import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { sendContributionRejectedEmail } from '@/mailer/sendContributionRejectedEmail'
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail' 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' import { ContributionListResult } from '../model/Contribution'
// const EMAIL_OPT_IN_REGISTER = 1 // const EMAIL_OPT_IN_REGISTER = 1
@ -76,7 +87,9 @@ export class AdminResolver {
async searchUsers( async searchUsers(
@Args() @Args()
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
@Ctx() context: Context,
): Promise<SearchUsersResult> { ): Promise<SearchUsersResult> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userRepository = getCustomRepository(UserRepository) const userRepository = getCustomRepository(UserRepository)
const userFields = [ const userFields = [
'id', 'id',
@ -104,7 +117,10 @@ export class AdminResolver {
} }
} }
const creations = await getUserCreations(users.map((u) => u.id)) const creations = await getUserCreations(
users.map((u) => u.id),
clientTimezoneOffset,
)
const adminUsers = await Promise.all( const adminUsers = await Promise.all(
users.map(async (user) => { users.map(async (user) => {
@ -145,11 +161,13 @@ export class AdminResolver {
const user = await dbUser.findOne({ id: userId }) const user = await dbUser.findOne({ id: userId })
// user exists ? // user exists ?
if (!user) { if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`)
} }
// administrator user changes own role? // administrator user changes own role?
const moderatorUser = getUser(context) const moderatorUser = getUser(context)
if (moderatorUser.id === userId) { if (moderatorUser.id === userId) {
logger.error('Administrator can not change his own role!')
throw new Error('Administrator can not change his own role!') throw new Error('Administrator can not change his own role!')
} }
// change isAdmin // change isAdmin
@ -158,6 +176,7 @@ export class AdminResolver {
if (isAdmin === true) { if (isAdmin === true) {
user.isAdmin = new Date() user.isAdmin = new Date()
} else { } else {
logger.error('User is already a usual user!')
throw new Error('User is already a usual user!') throw new Error('User is already a usual user!')
} }
break break
@ -165,6 +184,7 @@ export class AdminResolver {
if (isAdmin === false) { if (isAdmin === false) {
user.isAdmin = null user.isAdmin = null
} else { } else {
logger.error('User is already admin!')
throw new Error('User is already admin!') throw new Error('User is already admin!')
} }
break break
@ -183,11 +203,13 @@ export class AdminResolver {
const user = await dbUser.findOne({ id: userId }) const user = await dbUser.findOne({ id: userId })
// user exists ? // user exists ?
if (!user) { if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`)
} }
// moderator user disabled own account? // moderator user disabled own account?
const moderatorUser = getUser(context) const moderatorUser = getUser(context)
if (moderatorUser.id === userId) { if (moderatorUser.id === userId) {
logger.error('Moderator can not delete his own account!')
throw new Error('Moderator can not delete his own account!') throw new Error('Moderator can not delete his own account!')
} }
// soft-delete user // soft-delete user
@ -201,9 +223,11 @@ export class AdminResolver {
async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> { async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> {
const user = await dbUser.findOne({ id: userId }, { withDeleted: true }) const user = await dbUser.findOne({ id: userId }, { withDeleted: true })
if (!user) { if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`) throw new Error(`Could not find user with userId: ${userId}`)
} }
if (!user.deletedAt) { if (!user.deletedAt) {
logger.error('User is not deleted')
throw new Error('User is not deleted') throw new Error('User is not deleted')
} }
await user.recover() await user.recover()
@ -219,6 +243,11 @@ export class AdminResolver {
logger.info( logger.info(
`adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`, `adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`,
) )
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (!isValidDateString(creationDate)) {
logger.error(`invalid Date for creationDate=${creationDate}`)
throw new Error(`invalid Date for creationDate=${creationDate}`)
}
const emailContact = await UserContact.findOne({ const emailContact = await UserContact.findOne({
where: { email }, where: { email },
withDeleted: true, withDeleted: true,
@ -240,13 +269,15 @@ export class AdminResolver {
logger.error('Contribution could not be saved, Email is not activated') logger.error('Contribution could not be saved, Email is not activated')
throw new 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) const moderator = getUser(context)
logger.trace('moderator: ', moderator.id) logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(emailContact.userId) const creations = await getUserCreation(emailContact.userId, clientTimezoneOffset)
logger.trace('creations:', creations) logger.trace('creations:', creations)
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
logger.trace('creationDateObj:', creationDateObj) logger.trace('creationDateObj:', creationDateObj)
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = DbContribution.create() const contribution = DbContribution.create()
contribution.userId = emailContact.userId contribution.userId = emailContact.userId
contribution.amount = amount contribution.amount = amount
@ -258,8 +289,18 @@ export class AdminResolver {
contribution.contributionStatus = ContributionStatus.PENDING contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution) logger.trace('contribution to save', contribution)
await DbContribution.save(contribution) await DbContribution.save(contribution)
return getUserCreation(emailContact.userId)
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, clientTimezoneOffset)
} }
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS]) @Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
@ -295,6 +336,7 @@ export class AdminResolver {
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs, @Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<AdminUpdateContribution> { ): Promise<AdminUpdateContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const emailContact = await UserContact.findOne({ const emailContact = await UserContact.findOne({
where: { email }, where: { email },
withDeleted: true, withDeleted: true,
@ -319,7 +361,6 @@ export class AdminResolver {
const contributionToUpdate = await DbContribution.findOne({ const contributionToUpdate = await DbContribution.findOne({
where: { id, confirmedAt: IsNull() }, where: { id, confirmedAt: IsNull() },
}) })
if (!contributionToUpdate) { if (!contributionToUpdate) {
logger.error('No contribution found to given id.') logger.error('No contribution found to given id.')
throw new Error('No contribution found to given id.') throw new Error('No contribution found to given id.')
@ -336,16 +377,17 @@ export class AdminResolver {
} }
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id) let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate) creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else { } else {
logger.error('Currently the month of the contribution cannot change.') logger.error('Currently the month of the contribution cannot change.')
throw new 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 // all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
contributionToUpdate.amount = amount contributionToUpdate.amount = amount
contributionToUpdate.memo = memo contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate) contributionToUpdate.contributionDate = new Date(creationDate)
@ -353,19 +395,30 @@ export class AdminResolver {
contributionToUpdate.contributionStatus = ContributionStatus.PENDING contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await DbContribution.save(contributionToUpdate) await DbContribution.save(contributionToUpdate)
const result = new AdminUpdateContribution() const result = new AdminUpdateContribution()
result.amount = amount result.amount = amount
result.memo = contributionToUpdate.memo result.memo = contributionToUpdate.memo
result.date = contributionToUpdate.contributionDate result.date = contributionToUpdate.contributionDate
result.creation = await getUserCreation(user.id) result.creation = await getUserCreation(user.id, clientTimezoneOffset)
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 return result
} }
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS]) @Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [UnconfirmedContribution]) @Query(() => [UnconfirmedContribution])
async listUnconfirmedContributions(): Promise<UnconfirmedContribution[]> { async listUnconfirmedContributions(@Ctx() context: Context): Promise<UnconfirmedContribution[]> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contributions = await getConnection() const contributions = await getConnection()
.createQueryBuilder() .createQueryBuilder()
.select('c') .select('c')
@ -379,7 +432,7 @@ export class AdminResolver {
} }
const userIds = contributions.map((p) => p.userId) const userIds = contributions.map((p) => p.userId)
const userCreations = await getUserCreations(userIds) const userCreations = await getUserCreations(userIds, clientTimezoneOffset)
const users = await dbUser.find({ const users = await dbUser.find({
where: { id: In(userIds) }, where: { id: In(userIds) },
withDeleted: true, withDeleted: true,
@ -416,10 +469,34 @@ export class AdminResolver {
) { ) {
throw new Error('Own contribution can not be deleted as admin') 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.contributionStatus = ContributionStatus.DELETED
contribution.deletedBy = moderator.id contribution.deletedBy = moderator.id
await contribution.save() await contribution.save()
const res = await contribution.softRemove() 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 return !!res
} }
@ -429,6 +506,7 @@ export class AdminResolver {
@Arg('id', () => Int) id: number, @Arg('id', () => Int) id: number,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<boolean> { ): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const contribution = await DbContribution.findOne(id) const contribution = await DbContribution.findOne(id)
if (!contribution) { if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`) logger.error(`Contribution not found for given id: ${id}`)
@ -447,8 +525,13 @@ export class AdminResolver {
logger.error('This user was deleted. Cannot confirm a contribution.') logger.error('This user was deleted. Cannot confirm a contribution.')
throw new Error('This user was deleted. Cannot confirm a contribution.') throw new Error('This user was deleted. Cannot confirm a contribution.')
} }
const creations = await getUserCreation(contribution.userId, false) const creations = await getUserCreation(contribution.userId, clientTimezoneOffset, false)
validateContribution(creations, contribution.amount, contribution.contributionDate) validateContribution(
creations,
contribution.amount,
contribution.contributionDate,
clientTimezoneOffset,
)
const receivedCallDate = new Date() const receivedCallDate = new Date()
@ -515,6 +598,13 @@ export class AdminResolver {
} finally { } finally {
await queryRunner.release() 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 return true
} }
@ -576,6 +666,13 @@ export class AdminResolver {
// In case EMails are disabled log the activation link for the user // In case EMails are disabled log the activation link for the user
if (!emailSent) { if (!emailSent) {
logger.info(`Account confirmation link: ${activationLink}`) 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 return true
@ -768,9 +865,11 @@ export class AdminResolver {
relations: ['user'], relations: ['user'],
}) })
if (!contribution) { if (!contribution) {
logger.error('Contribution not found')
throw new Error('Contribution not found') throw new Error('Contribution not found')
} }
if (contribution.userId === user.id) { if (contribution.userId === user.id) {
logger.error('Admin can not answer on own contribution')
throw new Error('Admin can not answer on own contribution') throw new Error('Admin can not answer on own contribution')
} }
if (!contribution.user.emailContact) { if (!contribution.user.emailContact) {

View File

@ -74,6 +74,7 @@ describe('ContributionResolver', () => {
describe('input not valid', () => { describe('input not valid', () => {
it('throws error when memo length smaller than 5 chars', async () => { it('throws error when memo length smaller than 5 chars', async () => {
jest.clearAllMocks()
const date = new Date() const date = new Date()
await expect( await expect(
mutate({ mutate({
@ -92,10 +93,11 @@ describe('ContributionResolver', () => {
}) })
it('logs the error found', () => { it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`memo text is too short: memo.length=4 < (5)`) expect(logger.error).toBeCalledWith(`memo text is too short: memo.length=4 < 5`)
}) })
it('throws error when memo length greater than 255 chars', async () => { it('throws error when memo length greater than 255 chars', async () => {
jest.clearAllMocks()
const date = new Date() const date = new Date()
await expect( await expect(
mutate({ mutate({
@ -114,10 +116,11 @@ describe('ContributionResolver', () => {
}) })
it('logs the error found', () => { it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`memo text is too long: memo.length=259 > (255)`) expect(logger.error).toBeCalledWith(`memo text is too long: memo.length=259 > 255`)
}) })
it('throws error when creationDate not-valid', async () => { it('throws error when creationDate not-valid', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: createContribution, mutation: createContribution,
@ -144,6 +147,7 @@ describe('ContributionResolver', () => {
}) })
it('throws error when creationDate 3 month behind', async () => { it('throws error when creationDate 3 month behind', async () => {
jest.clearAllMocks()
const date = new Date() const date = new Date()
await expect( await expect(
mutate({ mutate({
@ -375,6 +379,7 @@ describe('ContributionResolver', () => {
describe('wrong contribution id', () => { describe('wrong contribution id', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: updateContribution, mutation: updateContribution,
@ -399,6 +404,7 @@ describe('ContributionResolver', () => {
describe('Memo length smaller than 5 chars', () => { describe('Memo length smaller than 5 chars', () => {
it('throws error', async () => { it('throws error', async () => {
jest.clearAllMocks()
const date = new Date() const date = new Date()
await expect( await expect(
mutate({ mutate({
@ -418,12 +424,13 @@ describe('ContributionResolver', () => {
}) })
it('logs the error found', () => { it('logs the error found', () => {
expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < (5)') expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < 5')
}) })
}) })
describe('Memo length greater than 255 chars', () => { describe('Memo length greater than 255 chars', () => {
it('throws error', async () => { it('throws error', async () => {
jest.clearAllMocks()
const date = new Date() const date = new Date()
await expect( await expect(
mutate({ mutate({
@ -443,7 +450,7 @@ describe('ContributionResolver', () => {
}) })
it('logs the error found', () => { it('logs the error found', () => {
expect(logger.error).toBeCalledWith('memo text is too long: memo.length=259 > (255)') expect(logger.error).toBeCalledWith('memo text is too long: memo.length=259 > 255')
}) })
}) })
@ -456,6 +463,7 @@ describe('ContributionResolver', () => {
}) })
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: updateContribution, mutation: updateContribution,
@ -486,6 +494,7 @@ describe('ContributionResolver', () => {
describe('admin tries to update a user contribution', () => { describe('admin tries to update a user contribution', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: adminUpdateContribution, mutation: adminUpdateContribution,
@ -516,6 +525,7 @@ describe('ContributionResolver', () => {
}) })
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: updateContribution, mutation: updateContribution,
@ -546,6 +556,7 @@ describe('ContributionResolver', () => {
describe('update creation to a date that is older than 3 months', () => { describe('update creation to a date that is older than 3 months', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
const date = new Date() const date = new Date()
await expect( await expect(
mutate({ mutate({
@ -564,7 +575,7 @@ describe('ContributionResolver', () => {
) )
}) })
it('logs the error found', () => { it.skip('logs the error found', () => {
expect(logger.error).toBeCalledWith( expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=', 'No information for available creations with the given creationDate=',
'Invalid Date', 'Invalid Date',
@ -830,6 +841,7 @@ describe('ContributionResolver', () => {
describe('User deletes already confirmed contribution', () => { describe('User deletes already confirmed contribution', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, variables: { email: 'peter@lustig.de', password: 'Aa12345_' },

View File

@ -1,5 +1,5 @@
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Contribution as dbContribution } from '@entity/Contribution' import { Contribution as dbContribution } from '@entity/Contribution'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
@ -13,6 +13,8 @@ import { Contribution, ContributionListResult } from '@model/Contribution'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution' import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { validateContribution, getUserCreation, updateCreations } from './util/creations' import { validateContribution, getUserCreation, updateCreations } from './util/creations'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const' import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { ContributionMessage } from '@entity/ContributionMessage'
import { ContributionMessageType } from '@enum/MessageType'
import { import {
Event, Event,
EventContributionCreate, EventContributionCreate,
@ -29,23 +31,24 @@ export class ContributionResolver {
@Args() { amount, memo, creationDate }: ContributionArgs, @Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<UnconfirmedContribution> { ): Promise<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (memo.length > MEMO_MAX_CHARS) { 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)`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
} }
if (memo.length < MEMO_MIN_CHARS) { 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)`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
} }
const event = new Event() const event = new Event()
const user = getUser(context) const user = getUser(context)
const creations = await getUserCreation(user.id) const creations = await getUserCreation(user.id, clientTimezoneOffset)
logger.trace('creations', creations) logger.trace('creations', creations)
const creationDateObj = new Date(creationDate) const creationDateObj = new Date(creationDate)
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
const contribution = dbContribution.create() const contribution = dbContribution.create()
contribution.userId = user.id contribution.userId = user.id
@ -169,13 +172,14 @@ export class ContributionResolver {
@Args() { amount, memo, creationDate }: ContributionArgs, @Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<UnconfirmedContribution> { ): Promise<UnconfirmedContribution> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
if (memo.length > MEMO_MAX_CHARS) { 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)`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
} }
if (memo.length < MEMO_MIN_CHARS) { 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)`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
} }
@ -192,22 +196,50 @@ export class ContributionResolver {
logger.error('user of the pending contribution and send user does not correspond') 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') 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) const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id) let creations = await getUserCreation(user.id, clientTimezoneOffset)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) { if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate) creations = updateCreations(creations, contributionToUpdate, clientTimezoneOffset)
} else { } else {
logger.error('Currently the month of the contribution cannot change.') logger.error('Currently the month of the contribution cannot change.')
throw new 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 // all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj) validateContribution(creations, amount, creationDateObj, clientTimezoneOffset)
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.amount = amount
contributionToUpdate.memo = memo contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate) contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.contributionStatus = ContributionStatus.PENDING contributionToUpdate.contributionStatus = ContributionStatus.PENDING
contributionToUpdate.updatedAt = new Date()
dbContribution.save(contributionToUpdate) dbContribution.save(contributionToUpdate)
const event = new Event() const event = new Event()

View File

@ -63,6 +63,8 @@ export class StatisticsResolver {
.where('transaction.decay IS NOT NULL') .where('transaction.decay IS NOT NULL')
.getRawOne() .getRawOne()
await queryRunner.release()
return { return {
totalUsers, totalUsers,
activeUsers, activeUsers,

View File

@ -6,8 +6,15 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig' import { peterLustig } from '@/seeds/users/peter-lustig'
import { cleanDB, testEnvironment } from '@test/helpers' import { cleanDB, testEnvironment } from '@test/helpers'
import { userFactory } from '@/seeds/factory/user' import { userFactory } from '@/seeds/factory/user'
import { login, createContributionLink, redeemTransactionLink } from '@/seeds/graphql/mutations' import {
login,
createContributionLink,
redeemTransactionLink,
createContribution,
updateContribution,
} from '@/seeds/graphql/mutations'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink' import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import Decimal from 'decimal.js-light' import Decimal from 'decimal.js-light'
import { GraphQLError } from 'graphql' import { GraphQLError } from 'graphql'
@ -32,6 +39,7 @@ describe('TransactionLinkResolver', () => {
describe('redeem daily Contribution Link', () => { describe('redeem daily Contribution Link', () => {
const now = new Date() const now = new Date()
let contributionLink: DbContributionLink | undefined let contributionLink: DbContributionLink | undefined
let contribution: UnconfirmedContribution | undefined
beforeAll(async () => { beforeAll(async () => {
await mutate({ await mutate({
@ -79,6 +87,58 @@ describe('TransactionLinkResolver', () => {
) )
}) })
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 () => { it('allows the user to redeem the contribution link', async () => {
await expect( await expect(
mutate({ mutate({
@ -120,7 +180,7 @@ describe('TransactionLinkResolver', () => {
jest.runAllTimers() jest.runAllTimers()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
}) })
}) })
@ -162,6 +222,7 @@ describe('TransactionLinkResolver', () => {
}) })
}) })
}) })
})
}) })
describe('transactionLinkCode', () => { describe('transactionLinkCode', () => {

View File

@ -1,5 +1,5 @@
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context' import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { getConnection } from '@dbTools/typeorm' import { getConnection } from '@dbTools/typeorm'
import { import {
Resolver, Resolver,
@ -169,6 +169,7 @@ export class TransactionLinkResolver {
@Arg('code', () => String) code: string, @Arg('code', () => String) code: string,
@Ctx() context: Context, @Ctx() context: Context,
): Promise<boolean> { ): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const user = getUser(context) const user = getUser(context)
const now = new Date() const now = new Date()
@ -258,9 +259,9 @@ export class TransactionLinkResolver {
} }
} }
const creations = await getUserCreation(user.id, false) const creations = await getUserCreation(user.id, clientTimezoneOffset)
logger.info('open creations', creations) logger.info('open creations', creations)
validateContribution(creations, contributionLink.amount, now) validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset)
const contribution = new DbContribution() const contribution = new DbContribution()
contribution.userId = user.id contribution.userId = user.id
contribution.createdAt = now contribution.createdAt = now

View File

@ -67,6 +67,7 @@ describe('send coins', () => {
describe('unknown recipient', () => { describe('unknown recipient', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: bobData, variables: bobData,
@ -93,6 +94,7 @@ describe('send coins', () => {
describe('deleted recipient', () => { describe('deleted recipient', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: peterData, variables: peterData,
@ -125,6 +127,7 @@ describe('send coins', () => {
describe('recipient account not activated', () => { describe('recipient account not activated', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await mutate({ await mutate({
mutation: login, mutation: login,
variables: peterData, variables: peterData,
@ -166,6 +169,7 @@ describe('send coins', () => {
describe('sender and recipient are the same', () => { describe('sender and recipient are the same', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
expect( expect(
await mutate({ await mutate({
mutation: sendCoins, mutation: sendCoins,
@ -189,6 +193,7 @@ describe('send coins', () => {
describe('memo text is too long', () => { describe('memo text is too long', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
expect( expect(
await mutate({ await mutate({
mutation: sendCoins, mutation: sendCoins,
@ -212,6 +217,7 @@ describe('send coins', () => {
describe('memo text is too short', () => { describe('memo text is too short', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
expect( expect(
await mutate({ await mutate({
mutation: sendCoins, mutation: sendCoins,
@ -235,6 +241,7 @@ describe('send coins', () => {
describe('user has not enough GDD', () => { describe('user has not enough GDD', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
expect( expect(
await mutate({ await mutate({
mutation: sendCoins, mutation: sendCoins,
@ -260,6 +267,7 @@ describe('send coins', () => {
describe('sending negative amount', () => { describe('sending negative amount', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
expect( expect(
await mutate({ await mutate({
mutation: sendCoins, mutation: sendCoins,

View File

@ -19,7 +19,7 @@ import { GraphQLError } from 'graphql'
import { User } from '@entity/User' import { User } from '@entity/User'
import CONFIG from '@/config' import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { printTimeDuration, activationLink } from './UserResolver' import { printTimeDuration, activationLink } from './UserResolver'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink' import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
@ -29,13 +29,16 @@ import { TransactionLink } from '@entity/TransactionLink'
import { EventProtocolType } from '@/event/EventProtocolType' import { EventProtocolType } from '@/event/EventProtocolType'
import { EventProtocol } from '@entity/EventProtocol' import { EventProtocol } from '@entity/EventProtocol'
import { logger } from '@test/testSetup' import { logger, i18n as localization } from '@test/testSetup'
import { validate as validateUUID, version as versionUUID } from 'uuid' import { validate as validateUUID, version as versionUUID } from 'uuid'
import { peterLustig } from '@/seeds/users/peter-lustig' import { peterLustig } from '@/seeds/users/peter-lustig'
import { UserContact } from '@entity/UserContact' import { UserContact } from '@entity/UserContact'
import { OptInType } from '../enum/OptInType' import { OptInType } from '../enum/OptInType'
import { UserContactType } from '../enum/UserContactType' import { UserContactType } from '../enum/UserContactType'
import { bobBaumeister } from '@/seeds/users/bob-baumeister' import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { encryptPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
// import { klicktippSignIn } from '@/apis/KlicktippController' // import { klicktippSignIn } from '@/apis/KlicktippController'
@ -46,7 +49,7 @@ jest.mock('@/mailer/sendAccountActivationEmail', () => {
} }
}) })
jest.mock('@/mailer/sendAccountMultiRegistrationEmail', () => { jest.mock('@/emails/sendEmailVariants', () => {
return { return {
__esModule: true, __esModule: true,
sendAccountMultiRegistrationEmail: jest.fn(), sendAccountMultiRegistrationEmail: jest.fn(),
@ -73,7 +76,7 @@ let mutate: any, query: any, con: any
let testEnv: any let testEnv: any
beforeAll(async () => { beforeAll(async () => {
testEnv = await testEnvironment(logger) testEnv = await testEnvironment(logger, localization)
mutate = testEnv.mutate mutate = testEnv.mutate
query = testEnv.query query = testEnv.query
con = testEnv.con con = testEnv.con
@ -146,6 +149,7 @@ describe('UserResolver', () => {
publisherId: 1234, publisherId: 1234,
referrerId: null, referrerId: null,
contributionLinkId: null, contributionLinkId: null,
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
}, },
]) ])
const valUUID = validateUUID(user[0].gradidoID) const valUUID = validateUUID(user[0].gradidoID)
@ -213,6 +217,7 @@ describe('UserResolver', () => {
firstName: 'Peter', firstName: 'Peter',
lastName: 'Lustig', lastName: 'Lustig',
email: 'peter@lustig.de', email: 'peter@lustig.de',
language: 'de',
}) })
}) })
@ -490,7 +495,8 @@ describe('UserResolver', () => {
}) })
it('updates the password', () => { it('updates the password', () => {
expect(newUser.password).toEqual('3917921995996627700') const encryptedPass = encryptPassword(newUser, 'Aa12345_')
expect(newUser.password.toString()).toEqual(encryptedPass.toString())
}) })
/* /*
@ -514,18 +520,20 @@ describe('UserResolver', () => {
await mutate({ mutation: createUser, variables: createUserVariables }) await mutate({ mutation: createUser, variables: createUserVariables })
const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email }) const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email })
emailVerificationCode = emailContact.emailVerificationCode.toString() emailVerificationCode = emailContact.emailVerificationCode.toString()
result = await mutate({
mutation: setPassword,
variables: { code: emailVerificationCode, password: 'not-valid' },
})
}) })
afterAll(async () => { afterAll(async () => {
await cleanDB() await cleanDB()
}) })
it('throws an error', () => { it('throws an error', async () => {
expect(result).toEqual( jest.clearAllMocks()
expect(
await mutate({
mutation: setPassword,
variables: { code: emailVerificationCode, password: 'not-valid' },
}),
).toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [ errors: [
new GraphQLError( new GraphQLError(
@ -544,18 +552,20 @@ describe('UserResolver', () => {
describe('no valid optin code', () => { describe('no valid optin code', () => {
beforeAll(async () => { beforeAll(async () => {
await mutate({ mutation: createUser, variables: createUserVariables }) await mutate({ mutation: createUser, variables: createUserVariables })
result = await mutate({
mutation: setPassword,
variables: { code: 'not valid', password: 'Aa12345_' },
})
}) })
afterAll(async () => { afterAll(async () => {
await cleanDB() await cleanDB()
}) })
it('throws an error', () => { it('throws an error', async () => {
expect(result).toEqual( jest.clearAllMocks()
expect(
await mutate({
mutation: setPassword,
variables: { code: 'not valid', password: 'Aa12345_' },
}),
).toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('Could not login with emailVerificationCode')], errors: [new GraphQLError('Could not login with emailVerificationCode')],
}), }),
@ -582,13 +592,9 @@ describe('UserResolver', () => {
}) })
describe('no users in database', () => { describe('no users in database', () => {
beforeAll(async () => { it('throws an error', async () => {
jest.clearAllMocks() jest.clearAllMocks()
result = await mutate({ mutation: login, variables }) expect(await mutate({ mutation: login, variables })).toEqual(
})
it('throws an error', () => {
expect(result).toEqual(
expect.objectContaining({ expect.objectContaining({
errors: [new GraphQLError('No user with this credentials')], errors: [new GraphQLError('No user with this credentials')],
}), }),
@ -666,6 +672,7 @@ describe('UserResolver', () => {
describe('logout', () => { describe('logout', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
resetToken() resetToken()
await expect(mutate({ mutation: logout })).resolves.toEqual( await expect(mutate({ mutation: logout })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
@ -704,6 +711,7 @@ describe('UserResolver', () => {
describe('verifyLogin', () => { describe('verifyLogin', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
resetToken() resetToken()
await expect(query({ query: verifyLogin })).resolves.toEqual( await expect(query({ query: verifyLogin })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
@ -723,6 +731,7 @@ describe('UserResolver', () => {
}) })
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
resetToken() resetToken()
await expect(query({ query: verifyLogin })).resolves.toEqual( await expect(query({ query: verifyLogin })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
@ -883,6 +892,7 @@ describe('UserResolver', () => {
describe('wrong optin code', () => { describe('wrong optin code', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
query({ query: queryOptIn, variables: { optIn: 'not-valid' } }), query({ query: queryOptIn, variables: { optIn: 'not-valid' } }),
).resolves.toEqual( ).resolves.toEqual(
@ -919,6 +929,7 @@ describe('UserResolver', () => {
describe('updateUserInfos', () => { describe('updateUserInfos', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
resetToken() resetToken()
await expect(mutate({ mutation: updateUserInfos })).resolves.toEqual( await expect(mutate({ mutation: updateUserInfos })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
@ -976,6 +987,7 @@ describe('UserResolver', () => {
describe('language is not valid', () => { describe('language is not valid', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: updateUserInfos, mutation: updateUserInfos,
@ -998,6 +1010,7 @@ describe('UserResolver', () => {
describe('password', () => { describe('password', () => {
describe('wrong old password', () => { describe('wrong old password', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: updateUserInfos, mutation: updateUserInfos,
@ -1020,6 +1033,7 @@ describe('UserResolver', () => {
describe('invalid new password', () => { describe('invalid new password', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
await expect( await expect(
mutate({ mutate({
mutation: updateUserInfos, mutation: updateUserInfos,
@ -1108,6 +1122,7 @@ describe('UserResolver', () => {
describe('searchAdminUsers', () => { describe('searchAdminUsers', () => {
describe('unauthenticated', () => { describe('unauthenticated', () => {
it('throws an error', async () => { it('throws an error', async () => {
jest.clearAllMocks()
resetToken() resetToken()
await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual( await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual(
expect.objectContaining({ expect.objectContaining({
@ -1149,6 +1164,93 @@ describe('UserResolver', () => {
}) })
}) })
}) })
describe('password encryption type', () => {
describe('user just registered', () => {
let bibi: User
it('has password type gradido id', async () => {
const users = await User.find()
bibi = users[1]
expect(bibi).toEqual(
expect.objectContaining({
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
})
})
describe('user has encryption type email', () => {
const variables = {
email: 'bibi@bloxberg.de',
password: 'Aa12345_',
publisherId: 1234,
}
let bibi: User
beforeAll(async () => {
const usercontact = await UserContact.findOneOrFail(
{ email: 'bibi@bloxberg.de' },
{ relations: ['user'] },
)
bibi = usercontact.user
bibi.passwordEncryptionType = PasswordEncryptionType.EMAIL
bibi.password = SecretKeyCryptographyCreateKey(
'bibi@bloxberg.de',
'Aa12345_',
)[0].readBigUInt64LE()
await bibi.save()
})
it('changes to gradidoID on login', async () => {
await mutate({ mutation: login, variables: variables })
const usercontact = await UserContact.findOneOrFail(
{ email: 'bibi@bloxberg.de' },
{ relations: ['user'] },
)
bibi = usercontact.user
expect(bibi).toEqual(
expect.objectContaining({
firstName: 'Bibi',
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
})
it('can login after password change', async () => {
resetToken()
expect(await mutate({ mutation: login, variables: variables })).toEqual(
expect.objectContaining({
data: {
login: {
email: 'bibi@bloxberg.de',
firstName: 'Bibi',
hasElopage: false,
id: expect.any(Number),
isAdmin: null,
klickTipp: {
newsletterState: false,
},
language: 'de',
lastName: 'Bloxberg',
publisherId: 1234,
},
},
}),
)
})
})
})
}) })
describe('printTimeDuration', () => { describe('printTimeDuration', () => {

View File

@ -1,6 +1,7 @@
import fs from 'fs' import fs from 'fs'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context' import i18n from 'i18n'
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm' import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
import CONFIG from '@/config' import CONFIG from '@/config'
@ -18,7 +19,7 @@ import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddle
import { OptInType } from '@enum/OptInType' import { OptInType } from '@enum/OptInType'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail' import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail' import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { klicktippSignIn } from '@/apis/KlicktippController' import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS' import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys' import { hasElopageBuys } from '@/util/hasElopageBuys'
@ -39,17 +40,15 @@ import { SearchAdminUsersResult } from '@model/AdminUser'
import Paginated from '@arg/Paginated' import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order' import { Order } from '@enum/Order'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native') const sodium = require('sodium-native')
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const random = require('random-bigint') const random = require('random-bigint')
// We will reuse this for changePassword
const isPassword = (password: string): boolean => {
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
}
const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl'] const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl']
const DEFAULT_LANGUAGE = 'de' const DEFAULT_LANGUAGE = 'de'
const isLanguage = (language: string): boolean => { const isLanguage = (language: string): boolean => {
@ -106,48 +105,6 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
return [pubKey, privKey] return [pubKey, privKey]
} }
const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
logger.trace('SecretKeyCryptographyCreateKey...')
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
logger.error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
throw new Error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
}
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
sodium.crypto_hash_sha512_init(state)
sodium.crypto_hash_sha512_update(state, Buffer.from(salt))
sodium.crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
sodium.crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
sodium.crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, sodium.crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES)
sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
logger.debug(
`SecretKeyCryptographyCreateKey...successful: encryptionKeyHash= ${encryptionKeyHash}, encryptionKey= ${encryptionKey}`,
)
return [encryptionKeyHash, encryptionKey]
}
/* /*
const getEmailHash = (email: string): Buffer => { const getEmailHash = (email: string): Buffer => {
logger.trace('getEmailHash...') logger.trace('getEmailHash...')
@ -305,8 +262,9 @@ export class UserResolver {
async verifyLogin(@Ctx() context: Context): Promise<User> { async verifyLogin(@Ctx() context: Context): Promise<User> {
logger.info('verifyLogin...') logger.info('verifyLogin...')
// TODO refactor and do not have duplicate code with login(see below) // TODO refactor and do not have duplicate code with login(see below)
const clientTimezoneOffset = getClientTimezoneOffset(context)
const userEntity = getUser(context) const userEntity = getUser(context)
const user = new User(userEntity, await getUserCreation(userEntity.id)) const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset))
// user.pubkey = userEntity.pubKey.toString('hex') // user.pubkey = userEntity.pubKey.toString('hex')
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage(context) user.hasElopage = await this.hasElopage(context)
@ -323,6 +281,7 @@ export class UserResolver {
@Ctx() context: Context, @Ctx() context: Context,
): Promise<User> { ): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`) logger.info(`login with ${email}, ***, ${publisherId} ...`)
const clientTimezoneOffset = getClientTimezoneOffset(context)
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
const dbUser = await findUserByEmail(email) const dbUser = await findUserByEmail(email)
if (dbUser.deletedAt) { if (dbUser.deletedAt) {
@ -343,19 +302,26 @@ export class UserResolver {
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code // TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new Error('User has no private or publicKey') throw new Error('User has no private or publicKey')
} }
const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
const loginUserPassword = BigInt(dbUser.password.toString()) if (!verifyPassword(dbUser, password)) {
if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) {
logger.error('The User has no valid credentials.') logger.error('The User has no valid credentials.')
throw new Error('No user with this credentials') throw new Error('No user with this credentials')
} }
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
dbUser.password = encryptPassword(dbUser, password)
await dbUser.save()
}
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message // add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
logger.addContext('user', dbUser.id) logger.addContext('user', dbUser.id)
logger.debug('validation of login credentials successful...') logger.debug('validation of login credentials successful...')
const user = new User(dbUser, await getUserCreation(dbUser.id)) const user = new User(dbUser, await getUserCreation(dbUser.id, clientTimezoneOffset))
logger.debug(`user= ${JSON.stringify(user, null, 2)}`) logger.debug(`user= ${JSON.stringify(user, null, 2)}`)
i18n.setLocale(user.language)
// Elopage Status & Stored PublisherId // Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage({ ...context, user: dbUser }) user.hasElopage = await this.hasElopage({ ...context, user: dbUser })
logger.info('user.hasElopage=' + user.hasElopage) logger.info('user.hasElopage=' + user.hasElopage)
@ -408,6 +374,7 @@ export class UserResolver {
if (!language || !isLanguage(language)) { if (!language || !isLanguage(language)) {
language = DEFAULT_LANGUAGE language = DEFAULT_LANGUAGE
} }
i18n.setLocale(language)
// check if user with email still exists? // check if user with email still exists?
email = email.trim().toLowerCase() email = email.trim().toLowerCase()
@ -416,8 +383,11 @@ export class UserResolver {
logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`) logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`)
if (foundUser) { if (foundUser) {
// ATTENTION: this logger-message will be exactly expected during tests // ATTENTION: this logger-message will be exactly expected during tests, next line
logger.info(`User already exists with this email=${email}`) logger.info(`User already exists with this email=${email}`)
logger.info(
`Specified username when trying to register multiple times with this email: firstName=${firstName}, lastName=${lastName}`,
)
// TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent. // TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent.
const user = new User(communityDbUser) const user = new User(communityDbUser)
@ -430,18 +400,20 @@ export class UserResolver {
user.publisherId = publisherId user.publisherId = publisherId
logger.debug('partly faked user=' + user) logger.debug('partly faked user=' + user)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountMultiRegistrationEmail({ const emailSent = await sendAccountMultiRegistrationEmail({
firstName, firstName: foundUser.firstName, // this is the real name of the email owner, but just "firstName" would be the name of the new registrant which shall not be passed to the outside
lastName, lastName: foundUser.lastName, // this is the real name of the email owner, but just "lastName" would be the name of the new registrant which shall not be passed to the outside
email, email,
language: foundUser.language, // use language of the emails owner for sending
}) })
const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail() const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail()
eventSendAccountMultiRegistrationEmail.userId = foundUser.id eventSendAccountMultiRegistrationEmail.userId = foundUser.id
eventProtocol.writeEvent( eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail), event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail),
) )
logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`) logger.info(
`sendAccountMultiRegistrationEmail by ${firstName} ${lastName} to ${foundUser.firstName} ${foundUser.lastName} <${email}>`,
)
/* uncomment this, when you need the activation link on the console */ /* uncomment this, when you need the activation link on the console */
// In case EMails are disabled log the activation link for the user // In case EMails are disabled log the activation link for the user
if (!emailSent) { if (!emailSent) {
@ -470,6 +442,7 @@ export class UserResolver {
dbUser.lastName = lastName dbUser.lastName = lastName
dbUser.language = language dbUser.language = language
dbUser.publisherId = publisherId dbUser.publisherId = publisherId
dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD
dbUser.passphrase = passphrase.join(' ') dbUser.passphrase = passphrase.join(' ')
logger.debug('new dbUser=' + dbUser) logger.debug('new dbUser=' + dbUser)
if (redeemCode) { if (redeemCode) {
@ -623,7 +596,7 @@ export class UserResolver {
): Promise<boolean> { ): Promise<boolean> {
logger.info(`setPassword(${code}, ***)...`) logger.info(`setPassword(${code}, ***)...`)
// Validate Password // Validate Password
if (!isPassword(password)) { if (!isValidPassword(password)) {
logger.error('Password entered is lexically invalid') logger.error('Password entered is lexically invalid')
throw new Error( throw new Error(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
@ -681,10 +654,11 @@ export class UserResolver {
userContact.emailChecked = true userContact.emailChecked = true
// Update Password // Update Password
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
user.password = passwordHash[0].readBigUInt64LE() // using the shorthash user.password = encryptPassword(user, password)
user.pubKey = keyPair[0] user.pubKey = keyPair[0]
user.privKey = encryptedPrivkey user.privKey = encryptedPrivkey
logger.debug('User credentials updated ...') logger.debug('User credentials updated ...')
@ -785,11 +759,12 @@ export class UserResolver {
throw new Error(`"${language}" isn't a valid language`) throw new Error(`"${language}" isn't a valid language`)
} }
userEntity.language = language userEntity.language = language
i18n.setLocale(language)
} }
if (password && passwordNew) { if (password && passwordNew) {
// Validate Password // Validate Password
if (!isPassword(passwordNew)) { if (!isValidPassword(passwordNew)) {
logger.error('newPassword does not fullfil the rules') logger.error('newPassword does not fullfil the rules')
throw new Error( throw new Error(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
@ -801,7 +776,7 @@ export class UserResolver {
userEntity.emailContact.email, userEntity.emailContact.email,
password, password,
) )
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) { if (!verifyPassword(userEntity, password)) {
logger.error(`Old password is invalid`) logger.error(`Old password is invalid`)
throw new Error(`Old password is invalid`) throw new Error(`Old password is invalid`)
} }
@ -817,7 +792,8 @@ export class UserResolver {
logger.debug('PrivateKey encrypted...') logger.debug('PrivateKey encrypted...')
// Save new password hash and newly encrypted private key // Save new password hash and newly encrypted private key
userEntity.password = newPasswordHash[0].readBigUInt64LE() userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
userEntity.password = encryptPassword(userEntity, passwordNew)
userEntity.privKey = encryptedPrivkey userEntity.privKey = encryptedPrivkey
} }

View File

@ -0,0 +1,266 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { testEnvironment, cleanDB, contributionDateFormatter } from '@test/helpers'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { User } from '@entity/User'
import { Contribution } from '@entity/Contribution'
import { userFactory } from '@/seeds/factory/user'
import { login, createContribution, adminCreateContribution } from '@/seeds/graphql/mutations'
import { getUserCreation } from './creations'
let mutate: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
const setZeroHours = (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
describe('util/creation', () => {
let user: User
let admin: User
const now = new Date()
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
admin = await userFactory(testEnv, peterLustig)
})
describe('getUserCreations', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 250.0,
memo: 'Admin contribution for this month',
creationDate: contributionDateFormatter(now),
},
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 160.0,
memo: 'Admin contribution for the last month',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
},
})
await mutate({
mutation: adminCreateContribution,
variables: {
email: 'bibi@bloxberg.de',
amount: 450.0,
memo: 'Admin contribution for two months ago',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()),
),
},
})
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContribution,
variables: {
amount: 400.0,
memo: 'Contribution for this month',
creationDate: contributionDateFormatter(now),
},
})
await mutate({
mutation: createContribution,
variables: {
amount: 500.0,
memo: 'Contribution for the last month',
creationDate: contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
},
})
})
it('has the correct data setup', async () => {
await expect(Contribution.find()).resolves.toEqual([
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(now),
amount: expect.decimalEqual(250),
memo: 'Admin contribution for this month',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
amount: expect.decimalEqual(160),
memo: 'Admin contribution for the last month',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 2, now.getDate()),
),
amount: expect.decimalEqual(450),
memo: 'Admin contribution for two months ago',
moderatorId: admin.id,
contributionType: 'ADMIN',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(now),
amount: expect.decimalEqual(400),
memo: 'Contribution for this month',
moderatorId: null,
contributionType: 'USER',
contributionStatus: 'PENDING',
}),
expect.objectContaining({
userId: user.id,
contributionDate: setZeroHours(
new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()),
),
amount: expect.decimalEqual(500),
memo: 'Contribution for the last month',
moderatorId: null,
contributionType: 'USER',
contributionStatus: 'PENDING',
}),
])
})
describe('call getUserCreation now', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
describe('run forward in time one hour before next month', () => {
const targetDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 0, 0)
beforeAll(() => {
jest.useFakeTimers()
setTimeout(jest.fn(), targetDate.getTime() - now.getTime())
jest.runAllTimers()
})
afterAll(() => {
jest.useRealTimers()
})
it('has the clock set correctly', () => {
expect(new Date().toISOString()).toContain(
`${targetDate.getFullYear()}-${targetDate.getMonth() + 1}-${targetDate.getDate()}T23:`,
)
})
describe('call getUserCreation with UTC', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
describe('call getUserCreation with JST (GMT+0900)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with PST (GMT-0800)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 480, true)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
describe('run two hours forward to be in the next month in UTC', () => {
const nextMonthTargetDate = new Date()
nextMonthTargetDate.setTime(targetDate.getTime() + 2 * 60 * 60 * 1000)
beforeAll(() => {
setTimeout(jest.fn(), 2 * 60 * 60 * 1000)
jest.runAllTimers()
})
it('has the clock set correctly', () => {
expect(new Date().toISOString()).toContain(
`${nextMonthTargetDate.getFullYear()}-${nextMonthTargetDate.getMonth() + 1}-01T01:`,
)
})
describe('call getUserCreation with UTC', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 0, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with JST (GMT+0900)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, -540, true)).resolves.toEqual([
expect.decimalEqual(340),
expect.decimalEqual(350),
expect.decimalEqual(1000),
])
})
})
describe('call getUserCreation with PST (GMT-0800)', () => {
it('returns the expected open contributions', async () => {
await expect(getUserCreation(user.id, 450, true)).resolves.toEqual([
expect.decimalEqual(550),
expect.decimalEqual(340),
expect.decimalEqual(350),
])
})
})
})
})
})
})
})

View File

@ -1,4 +1,3 @@
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { getConnection } from '@dbTools/typeorm' import { getConnection } from '@dbTools/typeorm'
import { Contribution } from '@entity/Contribution' import { Contribution } from '@entity/Contribution'
@ -14,9 +13,10 @@ export const validateContribution = (
creations: Decimal[], creations: Decimal[],
amount: Decimal, amount: Decimal,
creationDate: Date, creationDate: Date,
timezoneOffset: number,
): void => { ): void => {
logger.trace('isContributionValid: ', creations, amount, creationDate) logger.trace('isContributionValid: ', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth()) const index = getCreationIndex(creationDate.getMonth(), timezoneOffset)
if (index < 0) { if (index < 0) {
logger.error( logger.error(
@ -38,10 +38,11 @@ export const validateContribution = (
export const getUserCreations = async ( export const getUserCreations = async (
ids: number[], ids: number[],
timezoneOffset: number,
includePending = true, includePending = true,
): Promise<CreationMap[]> => { ): Promise<CreationMap[]> => {
logger.trace('getUserCreations:', ids, includePending) logger.trace('getUserCreations:', ids, includePending)
const months = getCreationMonths() const months = getCreationMonths(timezoneOffset)
logger.trace('getUserCreations months', months) logger.trace('getUserCreations months', months)
const queryRunner = getConnection().createQueryRunner() const queryRunner = getConnection().createQueryRunner()
@ -50,27 +51,27 @@ export const getUserCreations = async (
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day' const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
logger.trace('getUserCreations dateFilter=', dateFilter) logger.trace('getUserCreations dateFilter=', dateFilter)
const unionString = includePending const sumAmountContributionPerUserAndLast3MonthQuery = queryRunner.manager
? ` .createQueryBuilder(Contribution, 'c')
UNION .select('month(contribution_date)', 'month')
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions .addSelect('user_id', 'userId')
WHERE user_id IN (${ids.toString()}) .addSelect('sum(amount)', 'sum')
AND contribution_date >= ${dateFilter} .where(`user_id in (${ids.toString()})`)
AND confirmed_at IS NULL AND deleted_at IS NULL` .andWhere(`contribution_date >= ${dateFilter}`)
: '' .andWhere('deleted_at IS NULL')
logger.trace('getUserCreations unionString=', unionString) .andWhere('denied_at IS NULL')
.groupBy('month')
.addGroupBy('userId')
.orderBy('month', 'DESC')
const unionQuery = await queryRunner.manager.query(` if (!includePending) {
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM sumAmountContributionPerUserAndLast3MonthQuery.andWhere('confirmed_at IS NOT NULL')
(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} const sumAmountContributionPerUserAndLast3Month =
AND creation_date >= ${dateFilter} await sumAmountContributionPerUserAndLast3MonthQuery.getRawMany()
${unionString}) AS result
GROUP BY month, userId logger.trace(sumAmountContributionPerUserAndLast3Month)
ORDER BY date DESC
`)
logger.trace('getUserCreations unionQuery=', unionQuery)
await queryRunner.release() await queryRunner.release()
@ -78,9 +79,9 @@ export const getUserCreations = async (
return { return {
id, id,
creations: months.map((month) => { creations: months.map((month) => {
const creation = unionQuery.find( const creation = sumAmountContributionPerUserAndLast3Month.find(
(raw: { month: string; id: string; creation: number[] }) => (raw: { month: string; userId: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.id) === id, parseInt(raw.month) === month && parseInt(raw.userId) === id,
) )
return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0) return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0)
}), }),
@ -88,24 +89,29 @@ export const getUserCreations = async (
}) })
} }
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => { export const getUserCreation = async (
logger.trace('getUserCreation', id, includePending) id: number,
const creations = await getUserCreations([id], includePending) timezoneOffset: number,
includePending = true,
): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending, timezoneOffset)
const creations = await getUserCreations([id], timezoneOffset, includePending)
logger.trace('getUserCreation creations=', creations) logger.trace('getUserCreation creations=', creations)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
} }
export const getCreationMonths = (): number[] => { const getCreationMonths = (timezoneOffset: number): number[] => {
const now = new Date(Date.now()) const clientNow = new Date()
clientNow.setTime(clientNow.getTime() - timezoneOffset * 60 * 1000)
return [ return [
now.getMonth() + 1, new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1, new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1, clientNow.getMonth() + 1,
].reverse() ]
} }
export const getCreationIndex = (month: number): number => { const getCreationIndex = (month: number, timezoneOffset: number): number => {
return getCreationMonths().findIndex((el) => el === month + 1) return getCreationMonths(timezoneOffset).findIndex((el) => el === month + 1)
} }
export const isStartEndDateValid = ( export const isStartEndDateValid = (
@ -129,8 +135,12 @@ export const isStartEndDateValid = (
} }
} }
export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => { export const updateCreations = (
const index = getCreationIndex(contribution.contributionDate.getMonth()) creations: Decimal[],
contribution: Contribution,
timezoneOffset: number,
): Decimal[] => {
const index = getCreationIndex(contribution.contributionDate.getMonth(), timezoneOffset)
if (index < 0) { if (index < 0) {
throw new Error('You cannot create GDD for a month older than the last three months.') throw new Error('You cannot create GDD for a month older than the last three months.')
@ -138,3 +148,7 @@ export const updateCreations = (creations: Decimal[], contribution: Contribution
creations[index] = creations[index].plus(contribution.amount.toString()) creations[index] = creations[index].plus(contribution.amount.toString())
return creations return creations
} }
export const isValidDateString = (dateString: string): boolean => {
return new Date(dateString).toString() !== 'Invalid Date'
}

View File

@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import createServer from './server/createServer' import createServer from './server/createServer'
import { startDHT } from '@/federation/index'
// config // config
import CONFIG from './config' import CONFIG from './config'
@ -16,6 +17,11 @@ async function main() {
console.log(`GraphIQL available at http://localhost:${CONFIG.PORT}`) 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) => { main().catch((e) => {

View File

@ -0,0 +1,15 @@
{
"emails": {
"accountMultiRegistration": {
"emailExists": "Es existiert jedoch zu deiner E-Mail-Adresse schon ein Konto.",
"emailReused": "Deine E-Mail-Adresse wurde soeben erneut benutzt, um bei Gradido ein Konto zu registrieren.",
"helloName": "Hallo {firstName} {lastName}",
"ifYouAreNotTheOne": "Wenn du nicht derjenige bist, der sich versucht hat erneut zu registrieren, wende dich bitte an unseren support:",
"onForgottenPasswordClickLink": "Klicke bitte auf den folgenden Link, falls du dein Passwort vergessen haben solltest:",
"onForgottenPasswordCopyLink": "oder kopiere den obigen Link in dein Browserfenster.",
"sincerelyYours": "Mit freundlichen Grüßen,",
"subject": "Gradido: Erneuter Registrierungsversuch mit deiner E-Mail",
"yourGradidoTeam": "dein Gradido-Team"
}
}
}

View File

@ -0,0 +1,15 @@
{
"emails": {
"accountMultiRegistration": {
"emailExists": "However, an account already exists for your email address.",
"emailReused": "Your email address has just been used again to register an account with Gradido.",
"helloName": "Hello {firstName} {lastName}",
"ifYouAreNotTheOne": "If you are not the one who tried to register again, please contact our support:",
"onForgottenPasswordClickLink": "Please click on the following link if you have forgotten your password:",
"onForgottenPasswordCopyLink": "or copy the link above into your browser window.",
"sincerelyYours": "Sincerely yours,",
"subject": "Gradido: Try To Register Again With Your Email",
"yourGradidoTeam": "your Gradido team"
}
}
}

View File

@ -1,31 +0,0 @@
import CONFIG from '@/config'
import { sendAccountMultiRegistrationEmail } from './sendAccountMultiRegistrationEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendAccountMultiRegistrationEmail', () => {
beforeEach(async () => {
await sendAccountMultiRegistrationEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: Erneuter Registrierungsversuch mit deiner E-Mail',
text:
expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining(CONFIG.EMAIL_LINK_FORGOTPASSWORD) &&
expect.stringContaining('https://gradido.net/de/contact/'),
})
})
})

View File

@ -1,18 +0,0 @@
import { sendEMail } from './sendEMail'
import { accountMultiRegistration } from './text/accountMultiRegistration'
import CONFIG from '@/config'
export const sendAccountMultiRegistrationEmail = (data: {
firstName: string
lastName: string
email: string
}): Promise<boolean> => {
return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`,
subject: accountMultiRegistration.de.subject,
text: accountMultiRegistration.de.text({
...data,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
}),
})
}

View File

@ -26,12 +26,12 @@ describe('sendAddedContributionMessageEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`, to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag', subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining('Peter Lustig') && expect.stringContaining('Peter Lustig') &&
expect.stringContaining( 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('Was für ein Besen ist es geworden?') &&
expect.stringContaining('http://localhost/overview'), expect.stringContaining('http://localhost/overview'),

View File

@ -26,11 +26,11 @@ describe('sendContributionConfirmedEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>', to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Schöpfung wurde bestätigt', subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt',
text: text:
expect.stringContaining('Hallo Bibi Bloxberg') && expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining( 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('Betrag: 200,00 GDD') &&
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'), expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),

View File

@ -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 <bibi@bloxberg.de>',
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'),
})
})
})

View File

@ -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<boolean> => {
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),
})
}

View File

@ -38,7 +38,7 @@ describe('sendEMail', () => {
}) })
}) })
it('logs warining', () => { it('logs warning', () => {
expect(logger.info).toBeCalledWith('Emails are disabled via config...') expect(logger.info).toBeCalledWith('Emails are disabled via config...')
}) })

View File

@ -26,7 +26,7 @@ describe('sendTransactionReceivedEmail', () => {
it('calls sendEMail', () => { it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({ expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`, to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido Überweisung', subject: 'Du hast Gradidos erhalten',
text: text:
expect.stringContaining('Hallo Peter Lustig') && expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining('42,00 GDD') && expect.stringContaining('42,00 GDD') &&

View File

@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light'
export const contributionConfirmed = { export const contributionConfirmed = {
de: { de: {
subject: 'Schöpfung wurde bestätigt', subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -14,18 +14,17 @@ export const contributionConfirmed = {
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}, `Hallo ${data.recipientFirstName} ${data.recipientLastName},
Dein eingereichter Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${ dein Gemeinwohl-Beitrag "${data.contributionMemo}" wurde soeben von ${data.senderFirstName} ${
data.senderFirstName data.senderLastName
} ${data.senderLastName} bestätigt. } bestätigt und in deinem Gradido-Konto gutgeschrieben.
Betrag: ${data.contributionAmount.toFixed(2).replace('.', ',')} GDD Betrag: ${data.contributionAmount.toFixed(2).replace('.', ',')} GDD
Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team dein Gradido-Team`,
Link zu deinem Konto: ${data.overviewURL}`,
}, },
} }

View File

@ -1,6 +1,6 @@
export const contributionMessageReceived = { export const contributionMessageReceived = {
de: { de: {
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag', subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -14,15 +14,15 @@ export const contributionMessageReceived = {
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}, `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} Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team`, dein Gradido-Team`,
}, },
} }

View File

@ -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`,
},
}

View File

@ -14,20 +14,20 @@ export const transactionLinkRedeemed = {
memo: string memo: string
overviewURL: string overviewURL: string
}): string => }): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName} `Hallo ${data.recipientFirstName} ${data.recipientLastName},
${data.senderFirstName} ${data.senderLastName} (${ ${data.senderFirstName} ${data.senderLastName} (${
data.senderEmail data.senderEmail
}) hat soeben deinen Link eingelöst. }) hat soeben deinen Link eingelöst.
Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD, Betrag: ${data.amount.toFixed(2).replace('.', ',')} GDD,
Memo: ${data.memo} Memo: ${data.memo}
Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL} Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team`, dein Gradido-Team`,
}, },
} }

View File

@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light'
export const transactionReceived = { export const transactionReceived = {
de: { de: {
subject: 'Gradido Überweisung', subject: 'Du hast Gradidos erhalten',
text: (data: { text: (data: {
senderFirstName: string senderFirstName: string
senderLastName: string senderLastName: string
@ -13,9 +13,9 @@ export const transactionReceived = {
amount: Decimal amount: Decimal
overviewURL: string overviewURL: string
}): 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.senderLastName
} (${data.senderEmail}) erhalten. } (${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! Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen, Liebe Grüße
dein Gradido-Team`, dein Gradido-Team`,
}, },
} }

View File

@ -0,0 +1,71 @@
import CONFIG from '@/config'
import { backendLogger as logger } from '@/server/logger'
import { User } from '@entity/User'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
// We will reuse this for changePassword
export const isValidPassword = (password: string): boolean => {
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
}
export const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
logger.trace('SecretKeyCryptographyCreateKey...')
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
logger.error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
throw new Error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
}
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
sodium.crypto_hash_sha512_init(state)
sodium.crypto_hash_sha512_update(state, Buffer.from(salt))
sodium.crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
sodium.crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
sodium.crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, sodium.crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES)
sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return [encryptionKeyHash, encryptionKey]
}
export const getUserCryptographicSalt = (dbUser: User): string => {
switch (dbUser.passwordEncryptionType) {
case PasswordEncryptionType.NO_PASSWORD: {
logger.error('Password not set for user ' + dbUser.id)
throw new Error('Password not set for user ' + dbUser.id) // user has no password
}
case PasswordEncryptionType.EMAIL: {
return dbUser.emailContact.email
break
}
case PasswordEncryptionType.GRADIDO_ID: {
return dbUser.gradidoID
break
}
default:
logger.error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
throw new Error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
}
}

View File

@ -0,0 +1,14 @@
import { User } from '@entity/User'
// import { logger } from '@test/testSetup' getting error "jest is not defined"
import { getUserCryptographicSalt, SecretKeyCryptographyCreateKey } from './EncryptorUtils'
export const encryptPassword = (dbUser: User, password: string): bigint => {
const salt = getUserCryptographicSalt(dbUser)
const keyBuffer = SecretKeyCryptographyCreateKey(salt, password) // return short and long hash
const passwordHash = keyBuffer[0].readBigUInt64LE()
return passwordHash
}
export const verifyPassword = (dbUser: User, password: string): boolean => {
return dbUser.password.toString() === encryptPassword(dbUser, password).toString()
}

View File

@ -29,6 +29,7 @@ const context = {
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
forEach: (): void => {}, forEach: (): void => {},
}, },
clientTimezoneOffset: 0,
} }
export const cleanDB = async () => { export const cleanDB = async () => {

View File

@ -9,7 +9,7 @@ export interface Context {
setHeaders: { key: string; value: string }[] setHeaders: { key: string; value: string }[]
role?: Role role?: Role
user?: dbUser user?: dbUser
clientRequestTime?: string clientTimezoneOffset?: number
// hack to use less DB calls for Balance Resolver // hack to use less DB calls for Balance Resolver
lastTransaction?: dbTransaction lastTransaction?: dbTransaction
transactionCount?: number transactionCount?: number
@ -19,7 +19,7 @@ export interface Context {
const context = (args: ExpressContext): Context => { const context = (args: ExpressContext): Context => {
const authorization = args.req.headers.authorization const authorization = args.req.headers.authorization
const clientRequestTime = args.req.headers.clientrequesttime const clientTimezoneOffset = args.req.headers.clienttimezoneoffset
const context: Context = { const context: Context = {
token: null, token: null,
setHeaders: [], setHeaders: [],
@ -27,8 +27,8 @@ const context = (args: ExpressContext): Context => {
if (authorization) { if (authorization) {
context.token = authorization.replace(/^Bearer /, '') context.token = authorization.replace(/^Bearer /, '')
} }
if (clientRequestTime && typeof clientRequestTime === 'string') { if (clientTimezoneOffset && typeof clientTimezoneOffset === 'string') {
context.clientRequestTime = clientRequestTime context.clientTimezoneOffset = Number(clientTimezoneOffset)
} }
return context return context
} }
@ -38,4 +38,14 @@ export const getUser = (context: Context): dbUser => {
throw new Error('No user given in context!') throw new Error('No user given in context!')
} }
export const getClientTimezoneOffset = (context: Context): number => {
if (
(context.clientTimezoneOffset || context.clientTimezoneOffset === 0) &&
Math.abs(context.clientTimezoneOffset) <= 27 * 60
) {
return context.clientTimezoneOffset
}
throw new Error('No valid client time zone offset in context!')
}
export default context export default context

View File

@ -25,6 +25,9 @@ import { Connection } from '@dbTools/typeorm'
import { apolloLogger } from './logger' import { apolloLogger } from './logger'
import { Logger } from 'log4js' import { Logger } from 'log4js'
// i18n
import { i18n } from './localization'
// TODO implement // TODO implement
// import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity"; // import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity";
@ -34,6 +37,7 @@ const createServer = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any = serverContext, context: any = serverContext,
logger: Logger = apolloLogger, logger: Logger = apolloLogger,
localization: i18n.I18n = i18n,
): Promise<ServerDef> => { ): Promise<ServerDef> => {
logger.addContext('user', 'unknown') logger.addContext('user', 'unknown')
logger.debug('createServer...') logger.debug('createServer...')
@ -63,6 +67,9 @@ const createServer = async (
// bodyparser urlencoded for elopage // bodyparser urlencoded for elopage
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true }))
// i18n
app.use(localization.init)
// Elopage Webhook // Elopage Webhook
app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook) app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook)
@ -80,6 +87,7 @@ const createServer = async (
`running with PRODUCTION=${CONFIG.PRODUCTION}, sending EMAIL enabled=${CONFIG.EMAIL} and EMAIL_TEST_MODUS=${CONFIG.EMAIL_TEST_MODUS} ...`, `running with PRODUCTION=${CONFIG.PRODUCTION}, sending EMAIL enabled=${CONFIG.EMAIL} and EMAIL_TEST_MODUS=${CONFIG.EMAIL_TEST_MODUS} ...`,
) )
logger.debug('createServer...successful') logger.debug('createServer...successful')
return { apollo, app, con } return { apollo, app, con }
} }

View File

@ -0,0 +1,28 @@
import path from 'path'
import { backendLogger } from './logger'
import i18n from 'i18n'
i18n.configure({
locales: ['en', 'de'],
defaultLocale: 'en',
retryInDefaultLocale: false,
directory: path.join(__dirname, '..', 'locales'),
// autoReload: true, // if this is activated the seeding hangs at the very end
updateFiles: false,
objectNotation: true,
logDebugFn: (msg) => backendLogger.debug(msg),
logWarnFn: (msg) => backendLogger.info(msg),
logErrorFn: (msg) => backendLogger.error(msg),
// this api is needed for email-template pug files
api: {
__: 't', // now req.__ becomes req.t
__n: 'tn', // and req.__n can be called as req.tn
},
register: global,
mustacheConfig: {
tags: ['{', '}'],
disable: false,
},
})
export { i18n }

View File

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { PasswordEncryptionType } from '@/graphql/enum/PasswordEncryptionType'
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm' import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
import { User as dbUser } from '@entity/User' import { User as dbUser } from '@entity/User'
import { UserContact } from '@entity/UserContact' import { UserContact } from '@entity/UserContact'
@ -26,6 +27,8 @@ const communityDbUser: dbUser = {
isAdmin: null, isAdmin: null,
publisherId: 0, publisherId: 0,
passphrase: '', passphrase: '',
// default password encryption type
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
hasId: function (): boolean { hasId: function (): boolean {
throw new Error('Function not implemented.') throw new Error('Function not implemented.')
}, },

View File

@ -16,6 +16,7 @@ const context = {
push: headerPushMock, push: headerPushMock,
forEach: jest.fn(), forEach: jest.fn(),
}, },
clientTimezoneOffset: 0,
} }
export const cleanDB = async () => { export const cleanDB = async () => {
@ -25,8 +26,8 @@ export const cleanDB = async () => {
} }
} }
export const testEnvironment = async (logger?: any) => { export const testEnvironment = async (logger?: any, localization?: any) => {
const server = await createServer(context, logger) const server = await createServer(context, logger, localization)
const con = server.con const con = server.con
const testClient = createTestClient(server.apollo) const testClient = createTestClient(server.apollo)
const mutate = testClient.mutate const mutate = testClient.mutate
@ -46,3 +47,12 @@ export const resetEntity = async (entity: any) => {
export const resetToken = () => { export const resetToken = () => {
context.token = '' context.token = ''
} }
// format date string as it comes from the frontend for the contribution date
export const contributionDateFormatter = (date: Date): string => {
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
}
export const setClientTimezoneOffset = (offset: number): void => {
context.clientTimezoneOffset = offset
}

View File

@ -1,4 +1,5 @@
import { backendLogger as logger } from '@/server/logger' import { backendLogger as logger } from '@/server/logger'
import { i18n } from '@/server/localization'
jest.setTimeout(1000000) jest.setTimeout(1000000)
@ -19,4 +20,18 @@ jest.mock('@/server/logger', () => {
} }
}) })
export { logger } jest.mock('@/server/localization', () => {
const originalModule = jest.requireActual('@/server/localization')
return {
__esModule: true,
...originalModule,
i18n: {
init: jest.fn(),
// configure: jest.fn(),
// __: jest.fn(),
// setLocale: jest.fn(),
},
}
})
export { logger, i18n }

View File

@ -58,7 +58,7 @@
"@entity/*": ["../database/entity/*", "../../database/build/entity/*"] "@entity/*": ["../database/entity/*", "../../database/build/entity/*"]
}, },
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "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. */ // "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. */ // "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'. */ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ ENV NODE_ENV="production"
# Labels # Labels
LABEL org.label-schema.build-date="${BUILD_DATE}" LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="gradido:database" 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.usage="https://github.com/gradido/gradido/blob/master/README.md"
LABEL org.label-schema.url="https://gradido.net" LABEL org.label-schema.url="https://gradido.net"
LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/database" LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/database"

View File

@ -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[]
}

View File

@ -0,0 +1,127 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
OneToOne,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { ContributionMessage } from '../ContributionMessage'
import { UserContact } from '../UserContact'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'gradido_id',
length: 36,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'alias',
length: 20,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
alias: string
@Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true })
pubKey: Buffer
@Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true })
privKey: Buffer
@Column({
type: 'text',
name: 'passphrase',
collation: 'utf8mb4_unicode_ci',
nullable: true,
default: null,
})
passphrase: string
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
@JoinColumn({ name: 'email_id' })
emailContact: UserContact
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
emailId: number | null
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({
name: 'password_encryption_type',
type: 'int',
unsigned: true,
nullable: false,
default: 0,
})
passwordEncryptionType: number
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null })
isAdmin: Date | null
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({
name: 'contribution_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
contributionLinkId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
@OneToMany(() => ContributionMessage, (message) => message.user)
@JoinColumn({ name: 'user_id' })
messages?: ContributionMessage[]
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
@JoinColumn({ name: 'user_id' })
userContacts?: UserContact[]
}

View File

@ -0,0 +1,60 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToOne,
} from 'typeorm'
import { User } from './User'
@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class UserContact extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'type',
length: 100,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
type: string
@OneToOne(() => User, (user) => user.emailContact)
user: User
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false })
userId: number
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
email: string
@Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true })
emailVerificationCode: BigInt
@Column({ name: 'email_opt_in_type_id' })
emailOptInTypeId: number
@Column({ name: 'email_resend_count' })
emailResendCount: number
// @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true })
// emailHash: Buffer
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
emailChecked: boolean
@Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' })
phone: string
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' })
updatedAt: Date | null
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
}

View File

@ -1 +1 @@
export { Contribution } from './0051-add_delete_by_to_contributions/Contribution' export { Contribution } from './0052-add_updated_at_to_contributions/Contribution'

View File

@ -1 +1 @@
export { User } from './0049-add_user_contacts_table/User' export { User } from './0053-change_password_encryption/User'

Some files were not shown because too many files have changed in this diff Show More