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
on: [push]
on: push
jobs:
##############################################################################
@ -15,7 +15,7 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# FRONTEND ###############################################################
##########################################################################
@ -24,7 +24,7 @@ jobs:
docker build --target test -t "gradido/frontend:test" frontend/
docker save "gradido/frontend:test" > /tmp/frontend.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: docker-frontend-test
path: /tmp/frontend.tar
@ -41,7 +41,7 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# ADMIN INTERFACE ########################################################
##########################################################################
@ -50,7 +50,7 @@ jobs:
docker build --target test -t "gradido/admin:test" admin/ --build-arg NODE_ENV="test"
docker save "gradido/admin:test" > /tmp/admin.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: docker-admin-test
path: /tmp/admin.tar
@ -67,7 +67,7 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# BACKEND ################################################################
##########################################################################
@ -76,7 +76,7 @@ jobs:
docker build -f ./backend/Dockerfile --target test -t "gradido/backend:test" .
docker save "gradido/backend:test" > /tmp/backend.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: docker-backend-test
path: /tmp/backend.tar
@ -93,7 +93,7 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DATABASE UP ############################################################
##########################################################################
@ -102,7 +102,7 @@ jobs:
docker build --target test_up -t "gradido/database:test_up" database/
docker save "gradido/database:test_up" > /tmp/database_up.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: docker-database-test_up
path: /tmp/database_up.tar
@ -119,7 +119,7 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# BUILD MARIADB DOCKER IMAGE #############################################
##########################################################################
@ -128,7 +128,7 @@ jobs:
docker build --target mariadb_server -t "gradido/mariadb:test" -f ./mariadb/Dockerfile ./
docker save "gradido/mariadb:test" > /tmp/mariadb.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: docker-mariadb-test
path: /tmp/mariadb.tar
@ -139,13 +139,13 @@ jobs:
build_test_nginx:
name: Docker Build Test - Nginx
runs-on: ubuntu-latest
#needs: [nothing]
needs: [build_test_backend, build_test_admin, build_test_frontend]
steps:
##########################################################################
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# BUILD NGINX DOCKER IMAGE ###############################################
##########################################################################
@ -154,7 +154,7 @@ jobs:
docker build -t "gradido/nginx:test" nginx/
docker save "gradido/nginx:test" > /tmp/nginx.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: docker-nginx-test
path: /tmp/nginx.tar
@ -171,12 +171,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
@ -200,12 +200,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
@ -229,12 +229,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
@ -258,12 +258,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
@ -287,12 +287,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
@ -308,7 +308,7 @@ jobs:
# JOB: LOCALES ADMIN #########################################################
##############################################################################
locales_admin:
name: Locales - Admin
name: Locales - Admin Interface
runs-on: ubuntu-latest
needs: [build_test_admin]
steps:
@ -316,12 +316,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
@ -345,12 +345,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-backend-test
path: /tmp
@ -374,12 +374,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-database-test_up
path: /tmp
@ -403,12 +403,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
@ -453,12 +453,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
@ -495,12 +495,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Mariadb)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-mariadb-test
path: /tmp
@ -543,7 +543,7 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOCKER COMPOSE DATABASE UP + RESET #####################################
##########################################################################
@ -553,3 +553,108 @@ jobs:
run: docker-compose -f docker-compose.yml run -T database yarn up
- name: database | reset
run: docker-compose -f docker-compose.yml run -T database yarn reset
##############################################################################
# JOB: END-TO-END TESTS #####################################################
##############################################################################
end-to-end-tests:
name: End-to-End Tests
runs-on: ubuntu-latest
needs: [build_test_mariadb, build_test_database_up, build_test_backend, build_test_admin, build_test_frontend, build_test_nginx]
steps:
##########################################################################
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Mariadb)
uses: actions/download-artifact@v3
with:
name: docker-mariadb-test
path: /tmp
- name: Load Docker Image (Mariadb)
run: docker load < /tmp/mariadb.tar
- name: Download Docker Image (Database Up)
uses: actions/download-artifact@v3
with:
name: docker-database-test_up
path: /tmp
- name: Load Docker Image (Database Up)
run: docker load < /tmp/database_up.tar
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v3
with:
name: docker-backend-test
path: /tmp
- name: Load Docker Image (Backend)
run: docker load < /tmp/backend.tar
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
- name: Load Docker Image (Frontend)
run: docker load < /tmp/frontend.tar
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
- name: Load Docker Image (Admin Interface)
run: docker load < /tmp/admin.tar
- name: Download Docker Image (Nginx)
uses: actions/download-artifact@v3
with:
name: docker-nginx-test
path: /tmp
- name: Load Docker Image (Nginx)
run: docker load < /tmp/nginx.tar
##########################################################################
# BOOT UP THE TEST SYSTEM ################################################
##########################################################################
- name: Boot up test system | docker-compose mariadb
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb
- name: Boot up test system | docker-compose database
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
- name: Boot up test system | docker-compose backend
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend
- name: Sleep for 10 seconds
run: sleep 10s
- name: Boot up test system | seed backend
run: |
sudo chown runner:docker -R *
cd database
yarn && yarn dev_reset
cd ../backend
yarn && yarn seed
cd ..
- name: Boot up test system | docker-compose frontends
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps frontend admin nginx
- name: Sleep for 15 seconds
run: sleep 15s
##########################################################################
# END-TO-END TESTS #######################################################
##########################################################################
- name: End-to-end tests | run tests
id: e2e-tests
run: |
cd e2e-tests/cypress/tests/
yarn
yarn run cypress run --spec cypress/e2e/User.Authentication.feature
- name: End-to-end tests | if tests failed, upload screenshots
if: steps.e2e-tests.outcome == 'failure'
uses: actions/upload-artifact@v3
with:
name: cypress-screenshots
path: /home/runner/work/gradido/gradido/e2e-tests/cypress/tests/cypress/screenshots/

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).
#### [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)
> 20 October 2022
- release: Version 1.13.1 [`#2279`](https://github.com/gradido/gradido/pull/2279)
- Fix: correctly evaluate to EMAIL_TEST_MODE to false [`#2273`](https://github.com/gradido/gradido/pull/2273)
- Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231)

View File

@ -20,10 +20,10 @@ ENV PORT="8080"
# Labels
LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="gradido:admin"
LABEL org.label-schema.description="Gradido Vue Admin Interface"
LABEL org.label-schema.usage="https://github.com/gradido/gradido/admin/README.md"
LABEL org.label-schema.description="Gradido Admin Interface"
LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md"
LABEL org.label-schema.url="https://gradido.net"
LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/backend"
LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/admin"
LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}"
LABEL org.label-schema.vendor="gradido Community"
LABEL org.label-schema.version="${BUILD_VERSION}"

View File

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

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>
<div class="content-footer">
<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>
<div class="copyright text-center text-lg-center text-muted">
{{ $t('footer.copyright.year', { year }) }}
@ -25,7 +25,7 @@
</a>
</div>
</b-col>
</b-row>
</div>
</div>
</template>
<script>

View File

@ -46,5 +46,10 @@ describe('ContributionLink', () => {
wrapper.vm.editContributionLinkData()
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"
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') }}
</b-button>
@ -17,7 +21,9 @@
<p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p>
<contribution-link-form
:contributionLinkData="contributionLinkData"
:editContributionLink="editContributionLink"
@get-contribution-links="$emit('get-contribution-links')"
@closeContributionForm="closeContributionForm"
/>
</b-card>
</b-collapse>
@ -28,6 +34,7 @@
:items="items"
@editContributionLinkData="editContributionLinkData"
@get-contribution-links="$emit('get-contribution-links')"
@closeContributionForm="closeContributionForm"
/>
<div v-else>{{ $t('contributionLink.noContributionLinks') }}</div>
</b-card-text>
@ -35,8 +42,8 @@
</div>
</template>
<script>
import ContributionLinkForm from './ContributionLinkForm.vue'
import ContributionLinkList from './ContributionLinkList.vue'
import ContributionLinkForm from '../ContributionLink/ContributionLinkForm.vue'
import ContributionLinkList from '../ContributionLink/ContributionLinkList.vue'
export default {
name: 'ContributionLink',
@ -58,12 +65,23 @@ export default {
return {
visible: false,
contributionLinkData: {},
editContributionLink: false,
}
},
methods: {
closeContributionForm() {
if (this.visible) {
this.$root.$emit('bv::toggle::collapse', 'newContribution')
this.editContributionLink = false
this.contributionLinkData = {}
}
},
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.editContributionLink = true
},
},
}

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<template>
<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">
<b-button
variant="danger"
@ -46,7 +46,7 @@
</template>
<script>
import { deleteContributionLink } from '@/graphql/deleteContributionLink.js'
import FigureQrCode from './FigureQrCode.vue'
import FigureQrCode from '../FigureQrCode.vue'
export default {
name: 'ContributionLinkList',
@ -108,6 +108,7 @@ export default {
})
.then(() => {
this.toastSuccess(this.$t('contributionLink.deleted'))
this.$emit('closeContributionForm')
this.$emit('get-contribution-links')
})
.catch((err) => {

View File

@ -1,7 +1,15 @@
<template>
<div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index">
<b-link v-if="type === 'link'" :to="text">{{ text }}</b-link>
<span v-for="({ type, text }, index) in parsedMessage" :key="index">
<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>
</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
export default {
name: 'LinkifyMessage',
name: 'ParseMessage',
props: {
message: {
type: String,
required: true,
},
type: {
type: String,
reuired: true,
},
},
computed: {
linkifiedMessage() {
const linkified = []
parsedMessage() {
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
while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0)
@ -31,6 +50,7 @@ export default {
string = string.substring(match.index + match[0].length)
}
if (string.length > 0) linkified.push({ type: 'text', text: string })
if (amount) linkified.push({ type: 'amount', text: amount })
return linkified
},
},

View File

@ -3,12 +3,16 @@ import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
const localVue = global.localVue
const dateMock = jest.fn((d) => d)
const numberMock = jest.fn((n) => n)
describe('ContributionMessagesListItem', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$d: dateMock,
$n: numberMock,
}
describe('if message author has moderator role', () => {
@ -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">{{ $d(new Date(message.createdAt), 'short') }}</span>
<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 v-else class="text-left is-not-moderator">
<b-avatar variant="info"></b-avatar>
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</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>
</template>
<script>
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue'
import ParseMessage from '@/components/ContributionMessages/ParseMessage.vue'
export default {
name: 'ContributionMessagesListItem',
components: {
LinkifyMessage,
ParseMessage,
},
props: {
message: {

View File

@ -7,6 +7,10 @@ const apolloMutateMock = jest.fn()
const storeDispatchMock = jest.fn()
const routerPushMock = jest.fn()
const stubs = {
RouterLink: true,
}
const mocks = {
$t: jest.fn((t) => t),
$apollo: {
@ -28,7 +32,7 @@ describe('NavBar', () => {
let wrapper
const Wrapper = () => {
return mount(NavBar, { mocks, localVue })
return mount(NavBar, { mocks, localVue, stubs })
}
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', () => {
const assignLocationSpy = jest.fn()
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')
})
@ -63,7 +97,7 @@ describe('NavBar', () => {
window.location = {
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', () => {

View File

@ -19,6 +19,10 @@
>
{{ $store.state.openCreations }} {{ $t('navbar.open_creation') }}
</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="logout">{{ $t('navbar.logout') }}</b-nav-item>
</b-navbar-nav>

View File

@ -118,12 +118,11 @@ export default {
},
methods: {
updateCreationData(data) {
this.creationUserData = data
// this.creationUserData.amount = data.amount
// this.creationUserData.date = data.date
// this.creationUserData.memo = data.memo
// this.creationUserData.moderator = data.moderator
data.row.toggleDetails()
const row = data.row
this.$emit('update-contributions', data)
delete data.row
this.creationUserData = { ...this.creationUserData, ...data }
row.toggleDetails()
},
updateUserData(rowItem, 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",
"contributionLink": {
"amount": "Betrag",
"changeSaved": "Änderungen gespeichert",
"clear": "Löschen",
"close": "Schließen",
"contributionLinks": "Beitragslinks",
"create": "Anlegen",
"cycle": "Zyklus",
@ -23,6 +25,7 @@
"once": "einmalig"
}
},
"saveChange": "Änderungen speichern",
"validFrom": "Startdatum",
"validTo": "Enddatum"
},
@ -82,7 +85,6 @@
"hide_details": "Details verbergen",
"lastname": "Nachname",
"math": {
"colon": ":",
"equals": "=",
"exclaim": "!",
"pipe": "|",
@ -95,11 +97,13 @@
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
"name": "Name",
"navbar": {
"automaticContributions": "Automatische Beiträge",
"logout": "Abmelden",
"multi_creation": "Mehrfachschöpfung",
"my-account": "Mein Konto",
"open_creation": "Offene Schöpfungen",
"overview": "Übersicht",
"statistic": "Statistik",
"user_search": "Nutzersuche"
},
"not_open_creations": "Keine offenen Schöpfungen",
@ -121,8 +125,9 @@
"save": "Speichern",
"statistic": {
"activeUsers": "Aktive Mitglieder",
"count": "Menge",
"deletedUsers": "Gelöschte Mitglieder",
"name": "Statistik",
"details": "Details",
"totalGradidoAvailable": "GDD insgesamt im Umlauf",
"totalGradidoCreated": "GDD insgesamt geschöpft",
"totalGradidoDecayed": "GDD insgesamt verfallen",

View File

@ -3,7 +3,9 @@
"back": "back",
"contributionLink": {
"amount": "Amount",
"changeSaved": "Changes saved",
"clear": "Clear",
"close": "Close",
"contributionLinks": "Contribution Links",
"create": "Create",
"cycle": "Cycle",
@ -23,6 +25,7 @@
"once": "once"
}
},
"saveChange": "Save Changes",
"validFrom": "Start-date",
"validTo": "End-Date"
},
@ -82,7 +85,6 @@
"hide_details": "Hide details",
"lastname": "Lastname",
"math": {
"colon": ":",
"equals": "=",
"exclaim": "!",
"pipe": "|",
@ -95,11 +97,13 @@
"multiple_creation_text": "Please select one or more members for which you would like to perform creations.",
"name": "Name",
"navbar": {
"automaticContributions": "Automatic Contributions",
"logout": "Logout",
"multi_creation": "Multiple creation",
"my-account": "My Account",
"open_creation": "Open creations",
"overview": "Overview",
"statistic": "Statistic",
"user_search": "User search"
},
"not_open_creations": "No open creations",
@ -121,8 +125,9 @@
"save": "Speichern",
"statistic": {
"activeUsers": "Active members",
"count": "Count",
"deletedUsers": "Deleted members",
"name": "Statistic",
"details": "Details",
"totalGradidoAvailable": "Total GDD in circulation",
"totalGradidoCreated": "Total created GDD",
"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 CreationConfirm from './CreationConfirm.vue'
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
import { confirmContribution } from '../graphql/confirmContribution'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
import VueApollo from 'vue-apollo'
import { createMockClient } from 'mock-apollo-client'
const mockClient = createMockClient()
const apolloProvider = new VueApollo({
defaultClient: mockClient,
})
const localVue = global.localVue
const storeCommitMock = jest.fn()
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
listUnconfirmedContributions: [
{
id: 1,
firstName: 'Bibi',
lastName: 'Bloxberg',
userId: 99,
email: 'bibi@bloxberg.de',
amount: 500,
memo: 'Danke für alles',
date: new Date(),
moderator: 1,
},
{
id: 2,
firstName: 'Räuber',
lastName: 'Hotzenplotz',
userId: 100,
email: 'raeuber@hotzenplotz.de',
amount: 1000000,
memo: 'Gut Ergattert',
date: new Date(),
moderator: 1,
},
],
},
})
localVue.use(VueApollo)
const apolloMutateMock = jest.fn().mockResolvedValue({})
const storeCommitMock = jest.fn()
const mocks = {
$t: jest.fn((t) => t),
@ -53,17 +33,69 @@ const mocks = {
},
},
},
$apollo: {
query: apolloQueryMock,
mutate: apolloMutateMock,
},
}
const defaultData = () => {
return {
listUnconfirmedContributions: [
{
id: 1,
firstName: 'Bibi',
lastName: 'Bloxberg',
userId: 99,
email: 'bibi@bloxberg.de',
amount: 500,
memo: 'Danke für alles',
date: new Date(),
moderator: 1,
state: 'PENDING',
creation: [500, 500, 500],
messageCount: 0,
},
{
id: 2,
firstName: 'Räuber',
lastName: 'Hotzenplotz',
userId: 100,
email: 'raeuber@hotzenplotz.de',
amount: 1000000,
memo: 'Gut Ergattert',
date: new Date(),
moderator: 1,
state: 'PENDING',
creation: [500, 500, 500],
messageCount: 0,
},
],
}
}
describe('CreationConfirm', () => {
let wrapper
const listUnconfirmedContributionsMock = jest.fn()
const adminDeleteContributionMock = jest.fn()
const confirmContributionMock = jest.fn()
mockClient.setRequestHandler(
listUnconfirmedContributions,
listUnconfirmedContributionsMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: defaultData() }),
)
mockClient.setRequestHandler(
adminDeleteContribution,
adminDeleteContributionMock.mockResolvedValue({ data: { adminDeleteContribution: true } }),
)
mockClient.setRequestHandler(
confirmContribution,
confirmContributionMock.mockResolvedValue({ data: { confirmContribution: true } }),
)
const Wrapper = () => {
return mount(CreationConfirm, { localVue, mocks })
return mount(CreationConfirm, { localVue, mocks, apolloProvider })
}
describe('mount', () => {
@ -72,12 +104,20 @@ describe('CreationConfirm', () => {
wrapper = Wrapper()
})
it('has a DIV element with the class.creation-confirm', () => {
expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy()
describe('server response for get pending creations is error', () => {
it('toast an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
})
it('has two pending creations', () => {
expect(wrapper.vm.pendingCreations).toHaveLength(2)
describe('server response is succes', () => {
it('has a DIV element with the class.creation-confirm', () => {
expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy()
})
it('has two pending creations', () => {
expect(wrapper.vm.pendingCreations).toHaveLength(2)
})
})
describe('store', () => {
@ -105,10 +145,7 @@ describe('CreationConfirm', () => {
})
it('calls the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: adminDeleteContribution,
variables: { id: 1 },
})
expect(adminDeleteContributionMock).toBeCalledWith({ id: 1 })
})
it('commits openCreationsMinus to store', () => {
@ -128,7 +165,7 @@ describe('CreationConfirm', () => {
})
it('does not call the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).not.toBeCalled()
expect(adminDeleteContributionMock).not.toBeCalled()
})
})
})
@ -139,7 +176,7 @@ describe('CreationConfirm', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve('some value'))
apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' })
adminDeleteContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
@ -150,7 +187,6 @@ describe('CreationConfirm', () => {
describe('confirm creation with success', () => {
beforeEach(async () => {
apolloMutateMock.mockResolvedValue({})
await wrapper.findAll('tr').at(2).findAll('button').at(2).trigger('click')
})
@ -179,10 +215,7 @@ describe('CreationConfirm', () => {
})
it('calls the confirmContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: confirmContribution,
variables: { id: 2 },
})
expect(confirmContributionMock).toBeCalledWith({ id: 2 })
})
it('commits openCreationsMinus to store', () => {
@ -200,7 +233,7 @@ describe('CreationConfirm', () => {
describe('confirm creation with error', () => {
beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' })
confirmContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
@ -210,19 +243,5 @@ describe('CreationConfirm', () => {
})
})
})
describe('server response for get pending creations is error', () => {
beforeEach(() => {
jest.clearAllMocks()
apolloQueryMock.mockRejectedValue({
message: 'Ouch!',
})
wrapper = Wrapper()
})
it('toast an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
})
})
})

View File

@ -10,6 +10,7 @@
@remove-creation="removeCreation"
@show-overlay="showOverlay"
@update-state="updateState"
@update-contributions="$apollo.queries.PendingContributions.refetch()"
/>
</div>
</template>
@ -71,21 +72,6 @@ export default {
this.toastError(error.message)
})
},
getPendingCreations() {
this.$apollo
.query({
query: listUnconfirmedContributions,
fetchPolicy: 'network-only',
})
.then((result) => {
this.$store.commit('resetOpenCreations')
this.pendingCreations = result.data.listUnconfirmedContributions
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
})
.catch((error) => {
this.toastError(error.message)
})
},
updatePendingCreations(id) {
this.pendingCreations = this.pendingCreations.filter((obj) => obj.id !== id)
this.$store.commit('openCreationsMinus', 1)
@ -127,8 +113,24 @@ export default {
]
},
},
async created() {
await this.getPendingCreations()
apollo: {
PendingContributions: {
query() {
return listUnconfirmedContributions
},
variables() {
// may be at some point we need a pagination here
return {}
},
update({ listUnconfirmedContributions }) {
this.$store.commit('resetOpenCreations')
this.pendingCreations = listUnconfirmedContributions
this.$store.commit('setOpenCreations', listUnconfirmedContributions.length)
},
error({ message }) {
this.toastError(message)
},
},
},
}
</script>

View File

@ -1,7 +1,5 @@
import { mount } from '@vue/test-utils'
import Overview from './Overview.vue'
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
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({
data: {
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', () => {
expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3)
})

View File

@ -28,42 +28,13 @@
</b-link>
</b-card-text>
</b-card>
<contribution-link
:items="items"
:count="count"
@get-contribution-links="getContributionLinks"
/>
<community-statistic class="mt-5" v-model="statistics" />
</div>
</template>
<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'
export default {
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: {
getPendingCreations() {
this.$apollo
@ -75,45 +46,9 @@ export default {
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() {
this.getPendingCreations()
this.getCommunityStatistics()
this.getContributionLinks()
},
}
</script>

View File

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

View File

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

View File

@ -44,8 +44,8 @@ describe('router', () => {
})
describe('routes', () => {
it('has seven routes defined', () => {
expect(routes).toHaveLength(7)
it('has nine routes defined', () => {
expect(routes).toHaveLength(9)
})
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', () => {
it('renders the "NotFound" component', async () => {
const component = await routes.find((r) => r.path === '*').component()

View File

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

View File

@ -2,6 +2,25 @@
# yarn lockfile v1
"@apollo/client@^3.7.1":
version "3.7.1"
resolved "https://registry.yarnpkg.com/@apollo/client/-/client-3.7.1.tgz#86ce47c18a0714e229231148b0306562550c2248"
integrity sha512-xu5M/l7p9gT9Fx7nF3AQivp0XukjB7TM7tOd5wifIpI8RskYveL4I+rpTijzWrnqCPZabkbzJKH7WEAKdctt9w==
dependencies:
"@graphql-typed-document-node/core" "^3.1.1"
"@wry/context" "^0.7.0"
"@wry/equality" "^0.5.0"
"@wry/trie" "^0.3.0"
graphql-tag "^2.12.6"
hoist-non-react-statics "^3.3.2"
optimism "^0.16.1"
prop-types "^15.7.2"
response-iterator "^0.2.6"
symbol-observable "^4.0.0"
ts-invariant "^0.10.3"
tslib "^2.3.0"
zen-observable-ts "^1.2.5"
"@babel/code-frame@7.12.11":
version "7.12.11"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
@ -1030,6 +1049,11 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@graphql-typed-document-node/core@^3.1.1":
version "3.1.1"
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052"
integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg==
"@hapi/address@2.x.x":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
@ -2419,6 +2443,20 @@
"@types/node" ">=6"
tslib "^1.9.3"
"@wry/context@^0.6.0":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.6.1.tgz#c3c29c0ad622adb00f6a53303c4f965ee06ebeb2"
integrity sha512-LOmVnY1iTU2D8tv4Xf6MVMZZ+juIJ87Kt/plMijjN20NMAXGmH4u8bS1t0uT74cZ5gwpocYueV58YwyI8y+GKw==
dependencies:
tslib "^2.3.0"
"@wry/context@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.7.0.tgz#be88e22c0ddf62aeb0ae9f95c3d90932c619a5c8"
integrity sha512-LcDAiYWRtwAoSOArfk7cuYvFXytxfVrdX7yxoUmK7pPITLk5jYh2F8knCwS7LjgYL8u1eidPlKKV6Ikqq0ODqQ==
dependencies:
tslib "^2.3.0"
"@wry/equality@^0.1.2":
version "0.1.11"
resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790"
@ -2426,6 +2464,20 @@
dependencies:
tslib "^1.9.3"
"@wry/equality@^0.5.0":
version "0.5.3"
resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.5.3.tgz#fafebc69561aa2d40340da89fa7dc4b1f6fb7831"
integrity sha512-avR+UXdSrsF2v8vIqIgmeTY0UR91UT+IyablCyKe/uk22uOJ8fusKZnH9JH9e1/EtLeNJBtagNmL3eJdnOV53g==
dependencies:
tslib "^2.3.0"
"@wry/trie@^0.3.0":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@wry/trie/-/trie-0.3.2.tgz#a06f235dc184bd26396ba456711f69f8c35097e6"
integrity sha512-yRTyhWSls2OY/pYLfwff867r8ekooZ4UI+/gxot5Wj8EFwSf2rG+n+Mo/6LoLQm1TKA4GRj2+LCpbfS937dClQ==
dependencies:
tslib "^2.3.0"
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@ -6704,6 +6756,13 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
graphql-tag@^2.12.6:
version "2.12.6"
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.6.tgz#d441a569c1d2537ef10ca3d1633b48725329b5f1"
integrity sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==
dependencies:
tslib "^2.1.0"
graphql-tag@^2.4.2:
version "2.12.5"
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.12.5.tgz#5cff974a67b417747d05c8d9f5f3cb4495d0db8f"
@ -6880,6 +6939,13 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
dependencies:
react-is "^16.7.0"
homedir-polyfill@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
@ -9174,7 +9240,7 @@ lolex@^5.0.0:
dependencies:
"@sinonjs/commons" "^1.7.0"
loose-envify@^1.0.0:
loose-envify@^1.0.0, loose-envify@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@ -9498,6 +9564,11 @@ mkdirp@0.x, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1:
dependencies:
minimist "^1.2.5"
mock-apollo-client@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/mock-apollo-client/-/mock-apollo-client-1.2.1.tgz#e3bfdc3ff73b1fea28fa7e91ec82e43ba8cbfa39"
integrity sha512-QYQ6Hxo+t7hard1bcHHbsHxlNQYTQsaMNsm2Psh/NbwLMi2R4tGzplJKt97MUWuARHMq3GHB4PTLj/gxej4Caw==
moo-color@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
@ -9987,6 +10058,14 @@ optimism@^0.10.0:
dependencies:
"@wry/context" "^0.4.0"
optimism@^0.16.1:
version "0.16.1"
resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.16.1.tgz#7c8efc1f3179f18307b887e18c15c5b7133f6e7d"
integrity sha512-64i+Uw3otrndfq5kaoGNoY7pvOhSsjFEN4bdEFh80MWVk/dbgJfMv7VFDeCT8LxNAlEVhQmdVEbfE7X2nWNIIg==
dependencies:
"@wry/context" "^0.6.0"
"@wry/trie" "^0.3.0"
optionator@^0.8.1:
version "0.8.3"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
@ -10901,6 +10980,15 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.7.2:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.13.1"
proto-list@~1.2.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
@ -11080,7 +11168,7 @@ raw-body@2.4.0:
iconv-lite "0.4.24"
unpipe "1.0.0"
react-is@^16.8.4:
react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.4:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@ -11423,6 +11511,11 @@ resolve@1.x, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.12.0, resolve@^1.14.2,
is-core-module "^2.2.0"
path-parse "^1.0.6"
response-iterator@^0.2.6:
version "0.2.6"
resolved "https://registry.yarnpkg.com/response-iterator/-/response-iterator-0.2.6.tgz#249005fb14d2e4eeb478a3f735a28fd8b4c9f3da"
integrity sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==
restore-cursor@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
@ -12446,6 +12539,11 @@ symbol-observable@^1.0.2:
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
symbol-observable@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205"
integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==
symbol-tree@^3.2.2, symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@ -12727,6 +12825,13 @@ tryer@^1.0.1:
resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==
ts-invariant@^0.10.3:
version "0.10.3"
resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.10.3.tgz#3e048ff96e91459ffca01304dbc7f61c1f642f6c"
integrity sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==
dependencies:
tslib "^2.1.0"
ts-invariant@^0.4.0:
version "0.4.4"
resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86"
@ -12785,6 +12890,11 @@ tslib@^2.1.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
tslib@^2.3.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e"
integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==
tsutils@^3.21.0:
version "3.21.0"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
@ -13844,7 +13954,14 @@ zen-observable-ts@^0.8.21:
tslib "^1.9.3"
zen-observable "^0.8.0"
zen-observable@^0.8.0:
zen-observable-ts@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz#6c6d9ea3d3a842812c6e9519209365a122ba8b58"
integrity sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==
dependencies:
zen-observable "0.8.15"
zen-observable@0.8.15, zen-observable@^0.8.0:
version "0.8.15"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"
integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v10.2022-09-20
CONFIG_VERSION=v11.2022-10-27
# Server
PORT=4000
@ -60,3 +60,8 @@ EVENT_PROTOCOL_DISABLED=false
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
# LOG_LEVEL=info
# DHT
# if you set this value, the DHT hyperswarm will start to announce and listen
# on an hash created from this tpoic
# DHT_TOPIC=GRADIDO_HUB

View File

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

View File

@ -1,7 +1,7 @@
##################################################################################
# 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)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame

View File

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

View File

@ -10,14 +10,14 @@ Decimal.set({
})
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
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v10.2022-09-20',
EXPECTED: 'v11.2022-10-27',
CURRENT: '',
},
}
@ -116,6 +116,10 @@ if (
)
}
const federation = {
DHT_TOPIC: process.env.DHT_TOPIC || null,
}
const CONFIG = {
...constants,
...server,
@ -126,6 +130,7 @@ const CONFIG = {
...loginServer,
...webhook,
...eventProtocol,
...federation,
}
export default CONFIG

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 EventTransactionReceiveRedeem extends EventBasicTxX {}
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 EventAdminCreateContributionMessage extends EventBasicCtMsg {}
export class EventContributionDelete extends EventBasicCt {}
@ -74,6 +77,14 @@ export class EventContributionConfirm extends EventBasicCtX {}
export class EventContributionDeny extends EventBasicCtX {}
export class EventContributionLinkDefine extends EventBasicCt {}
export class EventContributionLinkActivateRedeem extends EventBasicCt {}
export class EventDeleteUser extends EventBasicUserId {}
export class EventUndeleteUser extends EventBasicUserId {}
export class EventChangeUserRole extends EventBasicUserId {}
export class EventAdminUpdateContribution extends EventBasicCt {}
export class EventAdminDeleteContribution extends EventBasicCt {}
export class EventCreateContributionLink extends EventBasicCt {}
export class EventDeleteContributionLink extends EventBasicCt {}
export class EventUpdateContributionLink extends EventBasicCt {}
export class Event {
constructor()
@ -289,6 +300,27 @@ export class Event {
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 {
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE
@ -345,6 +377,62 @@ export class Event {
return this
}
public setEventDeleteUser(ev: EventDeleteUser): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.DELETE_USER
return this
}
public setEventUndeleteUser(ev: EventUndeleteUser): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.UNDELETE_USER
return this
}
public setEventChangeUserRole(ev: EventChangeUserRole): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.CHANGE_USER_ROLE
return this
}
public setEventAdminUpdateContribution(ev: EventAdminUpdateContribution): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_UPDATE_CONTRIBUTION
return this
}
public setEventAdminDeleteContribution(ev: EventAdminDeleteContribution): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_DELETE_CONTRIBUTION
return this
}
public setEventCreateContributionLink(ev: EventCreateContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CREATE_CONTRIBUTION_LINK
return this
}
public setEventDeleteContributionLink(ev: EventDeleteContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.DELETE_CONTRIBUTION_LINK
return this
}
public setEventUpdateContributionLink(ev: EventUpdateContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.UPDATE_CONTRIBUTION_LINK
return this
}
setByBasicUser(userId: number): Event {
this.setEventBasic()
this.userId = userId

View File

@ -33,6 +33,17 @@ export enum EventProtocolType {
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE',
CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE',
ADMIN_CONTRIBUTION_CREATE = 'ADMIN_CONTRIBUTION_CREATE',
ADMIN_CONTRIBUTION_DELETE = 'ADMIN_CONTRIBUTION_DELETE',
ADMIN_CONTRIBUTION_UPDATE = 'ADMIN_CONTRIBUTION_UPDATE',
USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE',
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
DELETE_USER = 'DELETE_USER',
UNDELETE_USER = 'UNDELETE_USER',
CHANGE_USER_ROLE = 'CHANGE_USER_ROLE',
ADMIN_UPDATE_CONTRIBUTION = 'ADMIN_UPDATE_CONTRIBUTION',
ADMIN_DELETE_CONTRIBUTION = 'ADMIN_DELETE_CONTRIBUTION',
CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK',
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
}

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 */
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 { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index'
@ -42,6 +42,9 @@ import { Contribution } from '@entity/Contribution'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { EventProtocol } from '@entity/EventProtocol'
import { EventProtocolType } from '@/event/EventProtocolType'
import { logger } from '@test/testSetup'
// mock account activation email to avoid console spam
jest.mock('@/mailer/sendAccountActivationEmail', () => {
@ -80,6 +83,12 @@ let user: User
let creation: Contribution | void
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('set user role', () => {
describe('unauthenticated', () => {
@ -136,6 +145,7 @@ describe('AdminResolver', () => {
describe('user to get a new role does not exist', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }),
).resolves.toEqual(
@ -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', () => {
@ -188,6 +202,7 @@ describe('AdminResolver', () => {
describe('change role with error', () => {
describe('is own role', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }),
).resolves.toEqual(
@ -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('to admin', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await mutate({
mutation: setUserRole,
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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await mutate({
mutation: setUserRole,
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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }),
).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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: deleteUser, variables: { userId: admin.id } }),
).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', () => {
@ -330,6 +368,7 @@ describe('AdminResolver', () => {
describe('delete deleted user', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: deleteUser, variables: { userId: user.id } }),
).resolves.toEqual(
@ -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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }),
).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', () => {
@ -413,6 +461,7 @@ describe('AdminResolver', () => {
})
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: unDeleteUser, variables: { userId: user.id } }),
).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', () => {
beforeAll(async () => {
await mutate({ mutation: deleteUser, variables: { userId: user.id } })
@ -704,7 +757,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de',
amount: new Decimal(300),
memo: 'Danke Bibi!',
creationDate: new Date().toString(),
creationDate: contributionDateFormatter(new Date()),
},
}),
).resolves.toEqual(
@ -814,7 +867,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de',
amount: new Decimal(300),
memo: 'Danke Bibi!',
creationDate: new Date().toString(),
creationDate: contributionDateFormatter(new Date()),
},
}),
).resolves.toEqual(
@ -889,18 +942,25 @@ describe('AdminResolver', () => {
})
describe('adminCreateContribution', () => {
const now = new Date()
beforeAll(async () => {
const now = new Date()
creation = await creationFactory(testEnv, {
email: 'peter@lustig.de',
amount: 400,
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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
await expect(
mutate({ mutation: adminCreateContribution, variables }),
).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', () => {
beforeAll(async () => {
user = await userFactory(testEnv, stephenHawking)
variables.email = 'stephen@hawking.uk'
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
})
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: adminCreateContribution, variables }),
).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', () => {
beforeAll(async () => {
user = await userFactory(testEnv, garrickOllivander)
variables.email = 'garrick@ollivander.com'
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() - 1, 1),
)
})
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({ mutation: adminCreateContribution, variables }),
).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', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
variables.email = 'bibi@bloxberg.de'
variables.creationDate = 'invalid-date'
})
describe('date of creation is not a date string', () => {
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(
mutate({ mutation: adminCreateContribution, variables }),
).resolves.toEqual(
@ -967,36 +1075,21 @@ describe('AdminResolver', () => {
}),
)
})
})
describe('date of creation is four months ago', () => {
it('throws an error', async () => {
const now = new Date()
variables.creationDate = new Date(
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'),
],
}),
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
new Date(variables.creationDate).toString(),
)
})
})
describe('date of creation is in the future', () => {
it('throws an error', async () => {
const now = new Date()
variables.creationDate = new Date(
now.getFullYear(),
now.getMonth() + 4,
1,
).toString()
jest.clearAllMocks()
variables.creationDate = contributionDateFormatter(
new Date(now.getFullYear(), now.getMonth() + 4, 1),
)
await expect(
mutate({ mutation: adminCreateContribution, variables }),
).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', () => {
it('throws an error', async () => {
variables.creationDate = new Date().toString()
jest.clearAllMocks()
variables.creationDate = contributionDateFormatter(now)
await expect(
mutate({ mutation: adminCreateContribution, variables }),
).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', () => {
@ -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 ', () => {
@ -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,
amount: new Decimal(500),
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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: adminUpdateContribution,
@ -1123,7 +1246,7 @@ describe('AdminResolver', () => {
email: 'bob@baumeister.de',
amount: new Decimal(300),
memo: 'Danke Bibi!',
creationDate: new Date().toString(),
creationDate: contributionDateFormatter(new Date()),
},
}),
).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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: adminUpdateContribution,
@ -1146,7 +1276,7 @@ describe('AdminResolver', () => {
email: 'stephen@hawking.uk',
amount: new Decimal(300),
memo: 'Danke Bibi!',
creationDate: new Date().toString(),
creationDate: contributionDateFormatter(new Date()),
},
}),
).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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: adminUpdateContribution,
@ -1167,7 +1302,7 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de',
amount: new Decimal(300),
memo: 'Danke Bibi!',
creationDate: new Date().toString(),
creationDate: contributionDateFormatter(new Date()),
},
}),
).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', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: adminUpdateContribution,
@ -1188,7 +1328,9 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de',
amount: new Decimal(300),
memo: 'Danke Bibi!',
creationDate: new Date().toString(),
creationDate: creation
? contributionDateFormatter(creation.contributionDate)
: contributionDateFormatter(new Date()),
},
}),
).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', () => {
// 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(
mutate({
mutation: adminUpdateContribution,
@ -1214,24 +1363,32 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de',
amount: new Decimal(1900),
memo: 'Danke Peter!',
creationDate: new Date().toString(),
creationDate: creation
? contributionDateFormatter(creation.contributionDate)
: contributionDateFormatter(new Date()),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'The amount (1900 GDD) to be created exceeds the amount (500 GDD) still available for this month.',
'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
)
})
})
describe('creation update is successful changing month', () => {
describe.skip('creation update is successful changing month', () => {
// skipped as changing the month is currently disable
it.skip('returns update creation object', async () => {
it('returns update creation object', async () => {
await expect(
mutate({
mutation: adminUpdateContribution,
@ -1240,7 +1397,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de',
amount: new Decimal(300),
memo: 'Danke Peter!',
creationDate: new Date().toString(),
creationDate: creation
? contributionDateFormatter(creation.contributionDate)
: contributionDateFormatter(new Date()),
},
}),
).resolves.toEqual(
@ -1250,17 +1409,26 @@ describe('AdminResolver', () => {
date: expect.any(String),
memo: 'Danke Peter!',
amount: '300',
creation: ['1000', '1000', '200'],
creation: ['1000', '700', '500'],
},
},
}),
)
})
it('stores the admin update contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE,
userId: admin.id,
}),
)
})
})
describe('creation update is successful without changing month', () => {
// actually this mutation IS changing the month
it.skip('returns update creation object', async () => {
it('returns update creation object', async () => {
await expect(
mutate({
mutation: adminUpdateContribution,
@ -1269,7 +1437,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de',
amount: new Decimal(200),
memo: 'Das war leider zu Viel!',
creationDate: new Date().toString(),
creationDate: creation
? contributionDateFormatter(creation.contributionDate)
: contributionDateFormatter(new Date()),
},
}),
).resolves.toEqual(
@ -1279,12 +1449,21 @@ describe('AdminResolver', () => {
date: expect.any(String),
memo: 'Das war leider zu Viel!',
amount: '200',
creation: ['1000', '1000', '300'],
creation: ['1000', '800', '500'],
},
},
}),
)
})
it('stores the admin update contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE,
userId: admin.id,
}),
)
})
})
})
@ -1304,10 +1483,10 @@ describe('AdminResolver', () => {
lastName: 'Lustig',
email: 'peter@lustig.de',
date: expect.any(String),
memo: 'Herzlich Willkommen bei Gradido!',
amount: '400',
memo: 'Das war leider zu Viel!',
amount: '200',
moderator: admin.id,
creation: ['1000', '600', '500'],
creation: ['1000', '800', '500'],
},
{
id: expect.any(Number),
@ -1318,7 +1497,7 @@ describe('AdminResolver', () => {
memo: 'Grundeinkommen',
amount: '500',
moderator: admin.id,
creation: ['1000', '600', '500'],
creation: ['1000', '800', '500'],
},
{
id: expect.any(Number),
@ -1352,6 +1531,7 @@ describe('AdminResolver', () => {
describe('adminDeleteContribution', () => {
describe('creation id does not exist', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
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', () => {
@ -1378,12 +1562,13 @@ describe('AdminResolver', () => {
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
creationDate: contributionDateFormatter(new Date()),
},
})
})
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
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('creation does not exits', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
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', () => {
@ -1442,7 +1641,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de',
amount: 400,
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', () => {
@ -1469,7 +1674,9 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de',
amount: 450,
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 () => {
const transaction = await DbTransaction.find()
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', () => {
@ -1524,13 +1747,17 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de',
amount: 50,
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, {
email: 'bibi@bloxberg.de',
amount: 50,
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 () => {
await expect(
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 () => {
await expect(
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 () => {
await expect(
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 () => {
await expect(
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 () => {
await expect(
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 () => {
await expect(
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 () => {
await expect(
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 () => {
await expect(
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 () => {
await expect(
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', () => {
@ -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', () => {
let linkId: number
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', () => {

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

View File

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

View File

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

View File

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

View File

@ -6,8 +6,15 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { cleanDB, testEnvironment } from '@test/helpers'
import { userFactory } from '@/seeds/factory/user'
import { login, createContributionLink, redeemTransactionLink } from '@/seeds/graphql/mutations'
import {
login,
createContributionLink,
redeemTransactionLink,
createContribution,
updateContribution,
} from '@/seeds/graphql/mutations'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import Decimal from 'decimal.js-light'
import { GraphQLError } from 'graphql'
@ -32,6 +39,7 @@ describe('TransactionLinkResolver', () => {
describe('redeem daily Contribution Link', () => {
const now = new Date()
let contributionLink: DbContributionLink | undefined
let contribution: UnconfirmedContribution | undefined
beforeAll(async () => {
await mutate({
@ -79,56 +87,59 @@ describe('TransactionLinkResolver', () => {
)
})
it('allows the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
describe('after one day', () => {
describe('user has pending contribution of 1000 GDD', () => {
beforeAll(async () => {
jest.useFakeTimers()
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
setTimeout(() => {}, 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
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(),
},
})
})
afterAll(() => {
jest.useRealTimers()
})
it('allows the user to redeem the contribution link again', async () => {
it('allows the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
@ -160,6 +171,56 @@ describe('TransactionLinkResolver', () => {
],
})
})
describe('after one day', () => {
beforeAll(async () => {
jest.useFakeTimers()
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
setTimeout(() => {}, 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(() => {
jest.useRealTimers()
})
it('allows the user to redeem the contribution link again', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
})
})
})
})

View File

@ -1,5 +1,5 @@
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 {
Resolver,
@ -169,6 +169,7 @@ export class TransactionLinkResolver {
@Arg('code', () => String) code: string,
@Ctx() context: Context,
): Promise<boolean> {
const clientTimezoneOffset = getClientTimezoneOffset(context)
const user = getUser(context)
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)
validateContribution(creations, contributionLink.amount, now)
validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset)
const contribution = new DbContribution()
contribution.userId = user.id
contribution.createdAt = now

View File

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

View File

@ -19,7 +19,7 @@ import { GraphQLError } from 'graphql'
import { User } from '@entity/User'
import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail'
import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { printTimeDuration, activationLink } from './UserResolver'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
@ -29,13 +29,16 @@ import { TransactionLink } from '@entity/TransactionLink'
import { EventProtocolType } from '@/event/EventProtocolType'
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 { peterLustig } from '@/seeds/users/peter-lustig'
import { UserContact } from '@entity/UserContact'
import { OptInType } from '../enum/OptInType'
import { UserContactType } from '../enum/UserContactType'
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'
@ -46,7 +49,7 @@ jest.mock('@/mailer/sendAccountActivationEmail', () => {
}
})
jest.mock('@/mailer/sendAccountMultiRegistrationEmail', () => {
jest.mock('@/emails/sendEmailVariants', () => {
return {
__esModule: true,
sendAccountMultiRegistrationEmail: jest.fn(),
@ -73,7 +76,7 @@ let mutate: any, query: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment(logger)
testEnv = await testEnvironment(logger, localization)
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
@ -146,6 +149,7 @@ describe('UserResolver', () => {
publisherId: 1234,
referrerId: null,
contributionLinkId: null,
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
},
])
const valUUID = validateUUID(user[0].gradidoID)
@ -213,6 +217,7 @@ describe('UserResolver', () => {
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'de',
})
})
@ -490,7 +495,8 @@ describe('UserResolver', () => {
})
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 })
const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email })
emailVerificationCode = emailContact.emailVerificationCode.toString()
result = await mutate({
mutation: setPassword,
variables: { code: emailVerificationCode, password: 'not-valid' },
})
})
afterAll(async () => {
await cleanDB()
})
it('throws an error', () => {
expect(result).toEqual(
it('throws an error', async () => {
jest.clearAllMocks()
expect(
await mutate({
mutation: setPassword,
variables: { code: emailVerificationCode, password: 'not-valid' },
}),
).toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
@ -544,18 +552,20 @@ describe('UserResolver', () => {
describe('no valid optin code', () => {
beforeAll(async () => {
await mutate({ mutation: createUser, variables: createUserVariables })
result = await mutate({
mutation: setPassword,
variables: { code: 'not valid', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
})
it('throws an error', () => {
expect(result).toEqual(
it('throws an error', async () => {
jest.clearAllMocks()
expect(
await mutate({
mutation: setPassword,
variables: { code: 'not valid', password: 'Aa12345_' },
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Could not login with emailVerificationCode')],
}),
@ -582,13 +592,9 @@ describe('UserResolver', () => {
})
describe('no users in database', () => {
beforeAll(async () => {
it('throws an error', async () => {
jest.clearAllMocks()
result = await mutate({ mutation: login, variables })
})
it('throws an error', () => {
expect(result).toEqual(
expect(await mutate({ mutation: login, variables })).toEqual(
expect.objectContaining({
errors: [new GraphQLError('No user with this credentials')],
}),
@ -666,6 +672,7 @@ describe('UserResolver', () => {
describe('logout', () => {
describe('unauthenticated', () => {
it('throws an error', async () => {
jest.clearAllMocks()
resetToken()
await expect(mutate({ mutation: logout })).resolves.toEqual(
expect.objectContaining({
@ -704,6 +711,7 @@ describe('UserResolver', () => {
describe('verifyLogin', () => {
describe('unauthenticated', () => {
it('throws an error', async () => {
jest.clearAllMocks()
resetToken()
await expect(query({ query: verifyLogin })).resolves.toEqual(
expect.objectContaining({
@ -723,6 +731,7 @@ describe('UserResolver', () => {
})
it('throws an error', async () => {
jest.clearAllMocks()
resetToken()
await expect(query({ query: verifyLogin })).resolves.toEqual(
expect.objectContaining({
@ -883,6 +892,7 @@ describe('UserResolver', () => {
describe('wrong optin code', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
query({ query: queryOptIn, variables: { optIn: 'not-valid' } }),
).resolves.toEqual(
@ -919,6 +929,7 @@ describe('UserResolver', () => {
describe('updateUserInfos', () => {
describe('unauthenticated', () => {
it('throws an error', async () => {
jest.clearAllMocks()
resetToken()
await expect(mutate({ mutation: updateUserInfos })).resolves.toEqual(
expect.objectContaining({
@ -976,6 +987,7 @@ describe('UserResolver', () => {
describe('language is not valid', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: updateUserInfos,
@ -998,6 +1010,7 @@ describe('UserResolver', () => {
describe('password', () => {
describe('wrong old password', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: updateUserInfos,
@ -1020,6 +1033,7 @@ describe('UserResolver', () => {
describe('invalid new password', () => {
it('throws an error', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: updateUserInfos,
@ -1108,6 +1122,7 @@ describe('UserResolver', () => {
describe('searchAdminUsers', () => {
describe('unauthenticated', () => {
it('throws an error', async () => {
jest.clearAllMocks()
resetToken()
await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual(
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', () => {

View File

@ -1,6 +1,7 @@
import fs from 'fs'
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 { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
import CONFIG from '@/config'
@ -18,7 +19,7 @@ import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddle
import { OptInType } from '@enum/OptInType'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail'
import { sendAccountMultiRegistrationEmail } from '@/emails/sendEmailVariants'
import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys'
@ -39,17 +40,15 @@ import { SearchAdminUsersResult } from '@model/AdminUser'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
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
const sodium = require('sodium-native')
// eslint-disable-next-line @typescript-eslint/no-var-requires
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 DEFAULT_LANGUAGE = 'de'
const isLanguage = (language: string): boolean => {
@ -106,48 +105,6 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
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 => {
logger.trace('getEmailHash...')
@ -305,8 +262,9 @@ export class UserResolver {
async verifyLogin(@Ctx() context: Context): Promise<User> {
logger.info('verifyLogin...')
// TODO refactor and do not have duplicate code with login(see below)
const clientTimezoneOffset = getClientTimezoneOffset(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')
// Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage(context)
@ -323,6 +281,7 @@ export class UserResolver {
@Ctx() context: Context,
): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`)
const clientTimezoneOffset = getClientTimezoneOffset(context)
email = email.trim().toLowerCase()
const dbUser = await findUserByEmail(email)
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
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 (loginUserPassword !== passwordHash[0].readBigUInt64LE()) {
if (!verifyPassword(dbUser, password)) {
logger.error('The User has no valid 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
logger.addContext('user', dbUser.id)
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)}`)
i18n.setLocale(user.language)
// Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage({ ...context, user: dbUser })
logger.info('user.hasElopage=' + user.hasElopage)
@ -408,6 +374,7 @@ export class UserResolver {
if (!language || !isLanguage(language)) {
language = DEFAULT_LANGUAGE
}
i18n.setLocale(language)
// check if user with email still exists?
email = email.trim().toLowerCase()
@ -416,8 +383,11 @@ export class UserResolver {
logger.info(`DbUser.findOne(email=${email}) = ${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(
`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.
const user = new User(communityDbUser)
@ -430,18 +400,20 @@ export class UserResolver {
user.publisherId = publisherId
logger.debug('partly faked user=' + user)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountMultiRegistrationEmail({
firstName,
lastName,
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: 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,
language: foundUser.language, // use language of the emails owner for sending
})
const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail()
eventSendAccountMultiRegistrationEmail.userId = foundUser.id
eventProtocol.writeEvent(
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 */
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
@ -470,6 +442,7 @@ export class UserResolver {
dbUser.lastName = lastName
dbUser.language = language
dbUser.publisherId = publisherId
dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD
dbUser.passphrase = passphrase.join(' ')
logger.debug('new dbUser=' + dbUser)
if (redeemCode) {
@ -623,7 +596,7 @@ export class UserResolver {
): Promise<boolean> {
logger.info(`setPassword(${code}, ***)...`)
// Validate Password
if (!isPassword(password)) {
if (!isValidPassword(password)) {
logger.error('Password entered is lexically invalid')
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!',
@ -681,10 +654,11 @@ export class UserResolver {
userContact.emailChecked = true
// Update Password
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
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.privKey = encryptedPrivkey
logger.debug('User credentials updated ...')
@ -785,11 +759,12 @@ export class UserResolver {
throw new Error(`"${language}" isn't a valid language`)
}
userEntity.language = language
i18n.setLocale(language)
}
if (password && passwordNew) {
// Validate Password
if (!isPassword(passwordNew)) {
if (!isValidPassword(passwordNew)) {
logger.error('newPassword does not fullfil the rules')
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!',
@ -801,7 +776,7 @@ export class UserResolver {
userEntity.emailContact.email,
password,
)
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) {
if (!verifyPassword(userEntity, password)) {
logger.error(`Old password is invalid`)
throw new Error(`Old password is invalid`)
}
@ -817,7 +792,8 @@ export class UserResolver {
logger.debug('PrivateKey encrypted...')
// 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
}

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 { getConnection } from '@dbTools/typeorm'
import { Contribution } from '@entity/Contribution'
@ -14,9 +13,10 @@ export const validateContribution = (
creations: Decimal[],
amount: Decimal,
creationDate: Date,
timezoneOffset: number,
): void => {
logger.trace('isContributionValid: ', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth())
const index = getCreationIndex(creationDate.getMonth(), timezoneOffset)
if (index < 0) {
logger.error(
@ -38,10 +38,11 @@ export const validateContribution = (
export const getUserCreations = async (
ids: number[],
timezoneOffset: number,
includePending = true,
): Promise<CreationMap[]> => {
logger.trace('getUserCreations:', ids, includePending)
const months = getCreationMonths()
const months = getCreationMonths(timezoneOffset)
logger.trace('getUserCreations months', months)
const queryRunner = getConnection().createQueryRunner()
@ -50,27 +51,27 @@ export const getUserCreations = async (
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
logger.trace('getUserCreations dateFilter=', dateFilter)
const unionString = includePending
? `
UNION
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions
WHERE user_id IN (${ids.toString()})
AND contribution_date >= ${dateFilter}
AND confirmed_at IS NULL AND deleted_at IS NULL`
: ''
logger.trace('getUserCreations unionString=', unionString)
const sumAmountContributionPerUserAndLast3MonthQuery = queryRunner.manager
.createQueryBuilder(Contribution, 'c')
.select('month(contribution_date)', 'month')
.addSelect('user_id', 'userId')
.addSelect('sum(amount)', 'sum')
.where(`user_id in (${ids.toString()})`)
.andWhere(`contribution_date >= ${dateFilter}`)
.andWhere('deleted_at IS NULL')
.andWhere('denied_at IS NULL')
.groupBy('month')
.addGroupBy('userId')
.orderBy('month', 'DESC')
const unionQuery = await queryRunner.manager.query(`
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM
(SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions
WHERE user_id IN (${ids.toString()})
AND type_id = ${TransactionTypeId.CREATION}
AND creation_date >= ${dateFilter}
${unionString}) AS result
GROUP BY month, userId
ORDER BY date DESC
`)
logger.trace('getUserCreations unionQuery=', unionQuery)
if (!includePending) {
sumAmountContributionPerUserAndLast3MonthQuery.andWhere('confirmed_at IS NOT NULL')
}
const sumAmountContributionPerUserAndLast3Month =
await sumAmountContributionPerUserAndLast3MonthQuery.getRawMany()
logger.trace(sumAmountContributionPerUserAndLast3Month)
await queryRunner.release()
@ -78,9 +79,9 @@ export const getUserCreations = async (
return {
id,
creations: months.map((month) => {
const creation = unionQuery.find(
(raw: { month: string; id: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.id) === id,
const creation = sumAmountContributionPerUserAndLast3Month.find(
(raw: { month: string; userId: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.userId) === id,
)
return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0)
}),
@ -88,24 +89,29 @@ export const getUserCreations = async (
})
}
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending)
const creations = await getUserCreations([id], includePending)
export const getUserCreation = async (
id: number,
timezoneOffset: number,
includePending = true,
): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending, timezoneOffset)
const creations = await getUserCreations([id], timezoneOffset, includePending)
logger.trace('getUserCreation creations=', creations)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
}
export const getCreationMonths = (): number[] => {
const now = new Date(Date.now())
const getCreationMonths = (timezoneOffset: number): number[] => {
const clientNow = new Date()
clientNow.setTime(clientNow.getTime() - timezoneOffset * 60 * 1000)
return [
now.getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1,
].reverse()
new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1).getMonth() + 1,
new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1).getMonth() + 1,
clientNow.getMonth() + 1,
]
}
export const getCreationIndex = (month: number): number => {
return getCreationMonths().findIndex((el) => el === month + 1)
const getCreationIndex = (month: number, timezoneOffset: number): number => {
return getCreationMonths(timezoneOffset).findIndex((el) => el === month + 1)
}
export const isStartEndDateValid = (
@ -129,8 +135,12 @@ export const isStartEndDateValid = (
}
}
export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => {
const index = getCreationIndex(contribution.contributionDate.getMonth())
export const updateCreations = (
creations: Decimal[],
contribution: Contribution,
timezoneOffset: number,
): Decimal[] => {
const index = getCreationIndex(contribution.contributionDate.getMonth(), timezoneOffset)
if (index < 0) {
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())
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 */
import createServer from './server/createServer'
import { startDHT } from '@/federation/index'
// config
import CONFIG from './config'
@ -16,6 +17,11 @@ async function main() {
console.log(`GraphIQL available at http://localhost:${CONFIG.PORT}`)
}
})
// start DHT hyperswarm when DHT_TOPIC is set in .env
if (CONFIG.DHT_TOPIC) {
await startDHT(CONFIG.DHT_TOPIC) // con,
}
}
main().catch((e) => {

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', () => {
expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag',
subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining('Peter Lustig') &&
expect.stringContaining(
'Du hast soeben zu deinem eingereichten Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Rückfrage von Peter Lustig erhalten.',
'du hast zu deinem Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Nachricht von Peter Lustig erhalten.',
) &&
expect.stringContaining('Was für ein Besen ist es geworden?') &&
expect.stringContaining('http://localhost/overview'),

View File

@ -26,11 +26,11 @@ describe('sendContributionConfirmedEmail', () => {
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: 'Bibi Bloxberg <bibi@bloxberg.de>',
subject: 'Schöpfung wurde bestätigt',
subject: 'Dein Gemeinwohl-Beitrag wurde bestätigt',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining(
'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben bestätigt.',
'dein Gemeinwohl-Beitrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben von Peter Lustig bestätigt und in deinem Gradido-Konto gutgeschrieben.',
) &&
expect.stringContaining('Betrag: 200,00 GDD') &&
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),

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...')
})

View File

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

View File

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

View File

@ -1,6 +1,6 @@
export const contributionMessageReceived = {
de: {
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag',
subject: 'Nachricht zu deinem Gemeinwohl-Beitrag',
text: (data: {
senderFirstName: string
senderLastName: string
@ -14,15 +14,15 @@ export const contributionMessageReceived = {
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
du hast soeben zu deinem eingereichten Gemeinwohl-Beitrag "${data.contributionMemo}" eine Rückfrage von ${data.senderFirstName} ${data.senderLastName} erhalten.
du hast zu deinem Gemeinwohl-Beitrag "${data.contributionMemo}" eine Nachricht von ${data.senderFirstName} ${data.senderLastName} erhalten.
Bitte beantworte die Rückfrage in deinem Gradido-Konto im Menü "Gemeinschaft" im Tab "Meine Beiträge zum Gemeinwohl"!
Um die Nachricht zu sehen und darauf zu antworten, gehe in deinem Gradido-Konto ins Menü "Gemeinschaft" auf den Tab "Meine Beiträge zum Gemeinwohl"!
Link zu deinem Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen,
Liebe Grüße
dein Gradido-Team`,
},
}

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

View File

@ -2,7 +2,7 @@ import Decimal from 'decimal.js-light'
export const transactionReceived = {
de: {
subject: 'Gradido Überweisung',
subject: 'Du hast Gradidos erhalten',
text: (data: {
senderFirstName: string
senderLastName: string
@ -13,9 +13,9 @@ export const transactionReceived = {
amount: Decimal
overviewURL: string
}): string =>
`Hallo ${data.recipientFirstName} ${data.recipientLastName}
`Hallo ${data.recipientFirstName} ${data.recipientLastName},
Du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${
du hast soeben ${data.amount.toFixed(2).replace('.', ',')} GDD von ${data.senderFirstName} ${
data.senderLastName
} (${data.senderEmail}) erhalten.
@ -23,7 +23,7 @@ Details zur Transaktion findest du in deinem Gradido-Konto: ${data.overviewURL}
Bitte antworte nicht auf diese E-Mail!
Mit freundlichen Grüßen,
Liebe Grüße
dein Gradido-Team`,
},
}

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
forEach: (): void => {},
},
clientTimezoneOffset: 0,
}
export const cleanDB = async () => {

View File

@ -9,7 +9,7 @@ export interface Context {
setHeaders: { key: string; value: string }[]
role?: Role
user?: dbUser
clientRequestTime?: string
clientTimezoneOffset?: number
// hack to use less DB calls for Balance Resolver
lastTransaction?: dbTransaction
transactionCount?: number
@ -19,7 +19,7 @@ export interface Context {
const context = (args: ExpressContext): Context => {
const authorization = args.req.headers.authorization
const clientRequestTime = args.req.headers.clientrequesttime
const clientTimezoneOffset = args.req.headers.clienttimezoneoffset
const context: Context = {
token: null,
setHeaders: [],
@ -27,8 +27,8 @@ const context = (args: ExpressContext): Context => {
if (authorization) {
context.token = authorization.replace(/^Bearer /, '')
}
if (clientRequestTime && typeof clientRequestTime === 'string') {
context.clientRequestTime = clientRequestTime
if (clientTimezoneOffset && typeof clientTimezoneOffset === 'string') {
context.clientTimezoneOffset = Number(clientTimezoneOffset)
}
return context
}
@ -38,4 +38,14 @@ export const getUser = (context: Context): dbUser => {
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

View File

@ -25,6 +25,9 @@ import { Connection } from '@dbTools/typeorm'
import { apolloLogger } from './logger'
import { Logger } from 'log4js'
// i18n
import { i18n } from './localization'
// TODO implement
// import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity";
@ -34,6 +37,7 @@ const createServer = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: any = serverContext,
logger: Logger = apolloLogger,
localization: i18n.I18n = i18n,
): Promise<ServerDef> => {
logger.addContext('user', 'unknown')
logger.debug('createServer...')
@ -63,6 +67,9 @@ const createServer = async (
// bodyparser urlencoded for elopage
app.use(express.urlencoded({ extended: true }))
// i18n
app.use(localization.init)
// Elopage Webhook
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} ...`,
)
logger.debug('createServer...successful')
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 */
import { PasswordEncryptionType } from '@/graphql/enum/PasswordEncryptionType'
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
import { User as dbUser } from '@entity/User'
import { UserContact } from '@entity/UserContact'
@ -26,6 +27,8 @@ const communityDbUser: dbUser = {
isAdmin: null,
publisherId: 0,
passphrase: '',
// default password encryption type
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
hasId: function (): boolean {
throw new Error('Function not implemented.')
},

View File

@ -16,6 +16,7 @@ const context = {
push: headerPushMock,
forEach: jest.fn(),
},
clientTimezoneOffset: 0,
}
export const cleanDB = async () => {
@ -25,8 +26,8 @@ export const cleanDB = async () => {
}
}
export const testEnvironment = async (logger?: any) => {
const server = await createServer(context, logger)
export const testEnvironment = async (logger?: any, localization?: any) => {
const server = await createServer(context, logger, localization)
const con = server.con
const testClient = createTestClient(server.apollo)
const mutate = testClient.mutate
@ -46,3 +47,12 @@ export const resetEntity = async (entity: any) => {
export const resetToken = () => {
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 { i18n } from '@/server/localization'
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/*"]
},
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
"typeRoots": ["src/federation/@types", "node_modules/@types"], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ ENV NODE_ENV="production"
# Labels
LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="gradido:database"
LABEL org.label-schema.description="Gradido GraphQL Backend"
LABEL org.label-schema.description="Gradido Database Migration Service"
LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md"
LABEL org.label-schema.url="https://gradido.net"
LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/database"

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