Merge branch 'master' into 2179-bug-wrong-month-for-contribution-near-turn-of-month

This commit is contained in:
clauspeterhuebner 2022-11-02 17:45:11 +01:00 committed by GitHub
commit 917eb0a5b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
117 changed files with 3900 additions and 1248 deletions

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
@ -145,7 +145,7 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# BUILD NGINX DOCKER IMAGE ###############################################
##########################################################################
@ -154,7 +154,7 @@ jobs:
docker build -t "gradido/nginx:test" nginx/
docker save "gradido/nginx:test" > /tmp/nginx.tar
- name: Upload Artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: docker-nginx-test
path: /tmp/nginx.tar
@ -171,12 +171,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
@ -200,12 +200,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
@ -229,12 +229,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
@ -258,12 +258,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
@ -287,12 +287,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
@ -308,7 +308,7 @@ jobs:
# JOB: LOCALES ADMIN #########################################################
##############################################################################
locales_admin:
name: Locales - Admin
name: Locales - Admin Interface
runs-on: ubuntu-latest
needs: [build_test_admin]
steps:
@ -316,12 +316,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
@ -345,12 +345,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-backend-test
path: /tmp
@ -374,12 +374,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGE ##################################################
##########################################################################
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-database-test_up
path: /tmp
@ -403,12 +403,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Frontend)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-frontend-test
path: /tmp
@ -453,12 +453,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Admin Interface)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-admin-test
path: /tmp
@ -495,12 +495,12 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Mariadb)
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: docker-mariadb-test
path: /tmp
@ -528,7 +528,7 @@ jobs:
report_name: Coverage Backend
type: lcov
result_path: ./backend/coverage/lcov.info
min_coverage: 68
min_coverage: 74
token: ${{ github.token }}
##########################################################################
@ -543,7 +543,7 @@ jobs:
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v3
##########################################################################
# DOCKER COMPOSE DATABASE UP + RESET #####################################
##########################################################################
@ -553,3 +553,110 @@ 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 up --detach mariadb
- name: Sleep for 30 seconds
run: sleep 30s
- name: Boot up test system | docker-compose database
run: docker-compose up --detach --no-deps database
- name: Boot up test system | docker-compose backend
run: docker-compose up --detach --no-deps backend
- name: Sleep for 90 seconds
run: sleep 90s
- 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 up --detach --no-deps frontend admin nginx
- name: Sleep for 2.5 minutes
run: sleep 150s
##########################################################################
# 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,83 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.13.3](https://github.com/gradido/gradido/compare/1.13.2...1.13.3)
- 2294 contribution links on its own page [`#2312`](https://github.com/gradido/gradido/pull/2312)
- fix: Change Orange Color [`#2302`](https://github.com/gradido/gradido/pull/2302)
- fix: Release Statistic Query Runner [`#2320`](https://github.com/gradido/gradido/pull/2320)
- bug: 2295 remove horizontal scrollbar in admin overview [`#2311`](https://github.com/gradido/gradido/pull/2311)
- 2292 community information contact [`#2313`](https://github.com/gradido/gradido/pull/2313)
- bug: 2315 Contribution Month and TEST FAIL in MASTER [`#2316`](https://github.com/gradido/gradido/pull/2316)
- 2291 add button for close contribution messages box [`#2314`](https://github.com/gradido/gradido/pull/2314)
#### [1.13.2](https://github.com/gradido/gradido/compare/1.13.1...1.13.2)
> 28 October 2022
- release: Version 1.13.2 [`#2307`](https://github.com/gradido/gradido/pull/2307)
- fix: 🍰 Links In Contribution Messages Target Blank [`#2306`](https://github.com/gradido/gradido/pull/2306)
- fix: Link in Contribution Messages [`#2305`](https://github.com/gradido/gradido/pull/2305)
- Refactor: 🍰 Change the query so that we only look on the ``contributions`` table. [`#2217`](https://github.com/gradido/gradido/pull/2217)
- Refactor: Admin Resolver Events and Logging [`#2244`](https://github.com/gradido/gradido/pull/2244)
- contibution messages, links are recognised [`#2248`](https://github.com/gradido/gradido/pull/2248)
- fix: Include Deleted Email Contacts in User Search [`#2281`](https://github.com/gradido/gradido/pull/2281)
- fix: Pagination Contributions jumps to wrong Page [`#2284`](https://github.com/gradido/gradido/pull/2284)
- fix: Changed some texts in E-Mails and Frontend [`#2276`](https://github.com/gradido/gradido/pull/2276)
- Feat: 🍰 Add `deletedBy` To Contributions And Admin Can Not Delete Own User Contribution [`#2236`](https://github.com/gradido/gradido/pull/2236)
- deleted contributions are displayed to the user [`#2277`](https://github.com/gradido/gradido/pull/2277)
#### [1.13.1](https://github.com/gradido/gradido/compare/1.13.0...1.13.1)
> 20 October 2022
- release: Version 1.13.1 [`#2279`](https://github.com/gradido/gradido/pull/2279)
- Fix: correctly evaluate to EMAIL_TEST_MODE to false [`#2273`](https://github.com/gradido/gradido/pull/2273)
- Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231)
#### [1.13.0](https://github.com/gradido/gradido/compare/1.12.1...1.13.0)
> 18 October 2022
- release: Version 1.13.0 [`#2269`](https://github.com/gradido/gradido/pull/2269)
- fix: Linked User Email in Transaction List [`#2268`](https://github.com/gradido/gradido/pull/2268)
- concept capturing alias [`#2148`](https://github.com/gradido/gradido/pull/2148)
- fix: 🍰 Daily Redeem Of Contribution Link [`#2265`](https://github.com/gradido/gradido/pull/2265)
- fix: 🐛 Prevent Loosing Redeem Code When Changing Between Register and Login in Auth Navbar [`#2260`](https://github.com/gradido/gradido/pull/2260)
- fix: Disable Change of Month on Update Contribution [`#2264`](https://github.com/gradido/gradido/pull/2264)
- feat: 🍰 Global Jest Extension For Decimal Equal [`#2261`](https://github.com/gradido/gradido/pull/2261)
- feat: 🍰 Daily Rule For Contribution Links In Admin Interface [`#2262`](https://github.com/gradido/gradido/pull/2262)
- feat: 🍰 Do Not Show Expired Contribution Links In Wallet [`#2257`](https://github.com/gradido/gradido/pull/2257)
- fix: 🍰 Disable Change Of Month For Update Contribution (wallet and admin) [`#2258`](https://github.com/gradido/gradido/pull/2258)
- refactor: 🍰 Login And Logout To Mutations [`#2232`](https://github.com/gradido/gradido/pull/2232)
- fix: 🐛 Verify Token Before Redeeming A Link [`#2254`](https://github.com/gradido/gradido/pull/2254)
- Refactor: Add all events to documentation table [`#2240`](https://github.com/gradido/gradido/pull/2240)
- reconfig log4js with rollover feature and userid in logevent-message [`#2221`](https://github.com/gradido/gradido/pull/2221)
- refactor: 🍰 Refactoring Components Of `CotributionMessagesListItem` [`#2251`](https://github.com/gradido/gradido/pull/2251)
- style: add border-radius on send form [`#2233`](https://github.com/gradido/gradido/pull/2233)
- 2198 adminarea more dates on created transaction [`#2212`](https://github.com/gradido/gradido/pull/2212)
- Bug: delete contribution link [`#2213`](https://github.com/gradido/gradido/pull/2213)
- chore: 🍰 Fix Cypress Tests Unreliability [`#2245`](https://github.com/gradido/gradido/pull/2245)
- docs: 🍰 Refine Deployment Documentation [`#2209`](https://github.com/gradido/gradido/pull/2209)
- End-to-end test setup [`#2047`](https://github.com/gradido/gradido/pull/2047)
- config testmodus flag for sending emails to test or team account instead of user account [`#2216`](https://github.com/gradido/gradido/pull/2216)
- GradidoID 1: adapt and migrate database schema [`#2058`](https://github.com/gradido/gradido/pull/2058)
- feat: Add Client Request Time to Context [`#2206`](https://github.com/gradido/gradido/pull/2206)
- 2219 feature rework eventprotocol [`#2234`](https://github.com/gradido/gradido/pull/2234)
- Refactor: Test register with redeem code [`#2214`](https://github.com/gradido/gradido/pull/2214)
- 2203 delete query modal when redeeming the redeem link [`#2211`](https://github.com/gradido/gradido/pull/2211)
- Refactor: 🍰 Change email templates [`#2228`](https://github.com/gradido/gradido/pull/2228)
- Refactor: Events and logs completed in User Resolver [`#2204`](https://github.com/gradido/gradido/pull/2204)
- change support mail [`#2210`](https://github.com/gradido/gradido/pull/2210)
- feat: 🍰 Send email when contribution is confirmed [`#2193`](https://github.com/gradido/gradido/pull/2193)
- feat: 🍰 Send email when admin writes message to contribution [`#2187`](https://github.com/gradido/gradido/pull/2187)
- feat: 🍰 Send Email To Transaction Link Sender After Receiver Redeemed It [`#2063`](https://github.com/gradido/gradido/pull/2063)
#### [1.12.1](https://github.com/gradido/gradido/compare/1.12.0...1.12.1)
> 13 September 2022
- release: Version 1.12.1 [`#2196`](https://github.com/gradido/gradido/pull/2196)
- fix: 🍰 Show Not Icons In `allContribution` List [`#2195`](https://github.com/gradido/gradido/pull/2195)
#### [1.12.0](https://github.com/gradido/gradido/compare/1.11.0...1.12.0)

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "1.12.1",
"version": "1.13.3",
"license": "Apache-2.0",
"private": false,
"scripts": {

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

@ -5,6 +5,7 @@ const localVue = global.localVue
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {

View File

@ -35,8 +35,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',

View File

@ -0,0 +1,142 @@
import { mount } from '@vue/test-utils'
import ContributionLinkForm from './ContributionLinkForm.vue'
import { toastErrorSpy, toastSuccessSpy } from '../../../test/testSetup'
import { createContributionLink } from '@/graphql/createContributionLink.js'
const localVue = global.localVue
global.alert = jest.fn()
const propsData = {
contributionLinkData: {},
}
const apolloMutateMock = jest.fn().mockResolvedValue()
const mocks = {
$t: jest.fn((t) => t),
$apollo: {
mutate: apolloMutateMock,
},
}
// const mockAPIcall = jest.fn()
describe('ContributionLinkForm', () => {
let wrapper
const Wrapper = () => {
return mount(ContributionLinkForm, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".contribution-link-form"', () => {
expect(wrapper.find('div.contribution-link-form').exists()).toBe(true)
})
describe('call onReset', () => {
it('form has the set data', () => {
beforeEach(() => {
wrapper.setData({
form: {
name: 'name',
memo: 'memo',
amount: 100,
validFrom: 'validFrom',
validTo: 'validTo',
cycle: 'ONCE',
maxPerCycle: 1,
maxAmountPerMonth: 100,
},
})
wrapper.vm.onReset()
})
expect(wrapper.vm.form).toEqual({
amount: null,
cycle: 'ONCE',
validTo: null,
maxAmountPerMonth: '0',
memo: null,
name: null,
maxPerCycle: 1,
validFrom: null,
})
})
})
describe('call onSubmit', () => {
it('response with the contribution link url', () => {
wrapper.vm.onSubmit()
})
})
describe('successfull submit', () => {
beforeEach(async () => {
apolloMutateMock.mockResolvedValue({
data: {
createContributionLink: {
link: 'https://localhost/redeem/CL-1a2345678',
},
},
})
await wrapper
.findAllComponents({ name: 'BFormDatepicker' })
.at(0)
.vm.$emit('input', '2022-6-18')
await wrapper
.findAllComponents({ name: 'BFormDatepicker' })
.at(1)
.vm.$emit('input', '2022-7-18')
await wrapper.find('input.test-name').setValue('test name')
await wrapper.find('textarea.test-memo').setValue('test memo')
await wrapper.find('input.test-amount').setValue('100')
await wrapper.find('form').trigger('submit')
})
it('calls the API', () => {
expect(apolloMutateMock).toHaveBeenCalledWith({
mutation: createContributionLink,
variables: {
validFrom: '2022-6-18',
validTo: '2022-7-18',
name: 'test name',
amount: '100',
memo: 'test memo',
cycle: 'ONCE',
maxPerCycle: 1,
maxAmountPerMonth: '0',
},
})
})
it('toasts a succes message', () => {
expect(toastSuccessSpy).toBeCalledWith('https://localhost/redeem/CL-1a2345678')
})
})
describe('send createContributionLink with error', () => {
beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
await wrapper
.findAllComponents({ name: 'BFormDatepicker' })
.at(0)
.vm.$emit('input', '2022-6-18')
await wrapper
.findAllComponents({ name: 'BFormDatepicker' })
.at(1)
.vm.$emit('input', '2022-7-18')
await wrapper.find('input.test-name').setValue('test name')
await wrapper.find('textarea.test-memo').setValue('test memo')
await wrapper.find('input.test-amount').setValue('100')
await wrapper.find('form').trigger('submit')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('OUCH!')
})
})
})
})

View File

@ -1,8 +1,5 @@
<template>
<div class="contribution-link-form">
<div v-if="updateData" class="text-light bg-info p-3">
{{ updateData }}
</div>
<b-form class="m-5" @submit.prevent="onSubmit" ref="contributionLinkForm">
<!-- Date -->
<b-row>
@ -68,34 +65,32 @@
class="test-amount"
></b-form-input>
</b-form-group>
<b-collapse id="collapse-2">
<b-jumbotron>
<b-row class="mb-4">
<b-col>
<!-- Cycle -->
<label for="cycle">{{ $t('contributionLink.cycle') }}</label>
<b-form-select
v-model="form.cycle"
:options="cycle"
:disabled="disabled"
class="mb-3"
size="lg"
></b-form-select>
</b-col>
<b-col>
<!-- maxPerCycle -->
<label for="maxPerCycle">{{ $t('contributionLink.maxPerCycle') }}</label>
<b-form-select
v-model="form.maxPerCycle"
:options="maxPerCycle"
:disabled="disabled"
class="mb-3"
size="lg"
></b-form-select>
</b-col>
</b-row>
<b-row class="mb-4">
<b-col>
<!-- Cycle -->
<label for="cycle">{{ $t('contributionLink.cycle') }}</label>
<b-form-select
v-model="form.cycle"
:options="cycle"
class="mb-3"
size="lg"
></b-form-select>
</b-col>
<b-col>
<!-- maxPerCycle -->
<label for="maxPerCycle">{{ $t('contributionLink.maxPerCycle') }}</label>
<b-form-select
v-model="form.maxPerCycle"
:options="maxPerCycle"
:disabled="disabled"
class="mb-3"
size="lg"
></b-form-select>
</b-col>
</b-row>
<!-- Max amount -->
<!-- Max amount -->
<!--
<b-form-group :label="$t('contributionLink.maximumAmount')">
<b-form-input
v-model="form.maxAmountPerMonth"
@ -105,8 +100,7 @@
placeholder="0"
></b-form-input>
</b-form-group>
</b-jumbotron>
</b-collapse>
-->
<div class="mt-6">
<b-button type="submit" variant="primary">{{ $t('contributionLink.create') }}</b-button>
<b-button type="reset" variant="danger" @click.prevent="onReset">
@ -143,18 +137,18 @@ export default {
min: new Date(),
cycle: [
{ value: 'ONCE', text: this.$t('contributionLink.options.cycle.once') },
{ value: 'hourly', text: this.$t('contributionLink.options.cycle.hourly') },
{ value: 'daily', text: this.$t('contributionLink.options.cycle.daily') },
{ value: 'weekly', text: this.$t('contributionLink.options.cycle.weekly') },
{ value: 'monthly', text: this.$t('contributionLink.options.cycle.monthly') },
{ value: 'yearly', text: this.$t('contributionLink.options.cycle.yearly') },
// { value: 'hourly', text: this.$t('contributionLink.options.cycle.hourly') },
{ value: 'DAILY', text: this.$t('contributionLink.options.cycle.daily') },
// { value: 'weekly', text: this.$t('contributionLink.options.cycle.weekly') },
// { value: 'monthly', text: this.$t('contributionLink.options.cycle.monthly') },
// { value: 'yearly', text: this.$t('contributionLink.options.cycle.yearly') },
],
maxPerCycle: [
{ value: '1', text: '1 x' },
{ value: '2', text: '2 x' },
{ value: '3', text: '3 x' },
{ value: '4', text: '4 x' },
{ value: '5', text: '5 x' },
// { value: '2', text: '2 x' },
// { value: '3', text: '3 x' },
// { value: '4', text: '4 x' },
// { value: '5', text: '5 x' },
],
}
},
@ -195,12 +189,8 @@ export default {
},
},
computed: {
updateData() {
return this.contributionLinkData
},
disabled() {
if (this.form.cycle === 'ONCE') return true
return false
return true
},
},
watch: {

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
@ -9,6 +9,7 @@ const mockAPIcall = jest.fn()
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$apollo: {
mutate: mockAPIcall,
},

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',
@ -64,8 +64,28 @@ export default {
'amount',
{ key: 'cycle', label: this.$t('contributionLink.cycle') },
{ key: 'maxPerCycle', label: this.$t('contributionLink.maxPerCycle') },
{ key: 'validFrom', label: this.$t('contributionLink.validFrom') },
{ key: 'validTo', label: this.$t('contributionLink.validTo') },
{
key: 'validFrom',
label: this.$t('contributionLink.validFrom'),
formatter: (value, key, item) => {
if (value) {
return this.$d(new Date(value))
} else {
return null
}
},
},
{
key: 'validTo',
label: this.$t('contributionLink.validTo'),
formatter: (value, key, item) => {
if (value) {
return this.$d(new Date(value))
} else {
return null
}
},
},
'delete',
'edit',
'show',

View File

@ -1,119 +0,0 @@
import { mount } from '@vue/test-utils'
import ContributionLinkForm from './ContributionLinkForm.vue'
import { toastErrorSpy } from '../../test/testSetup'
const localVue = global.localVue
global.alert = jest.fn()
const propsData = {
contributionLinkData: {},
}
const apolloMutateMock = jest.fn().mockResolvedValue()
const mocks = {
$t: jest.fn((t) => t),
$apollo: {
mutate: apolloMutateMock,
},
}
// const mockAPIcall = jest.fn()
describe('ContributionLinkForm', () => {
let wrapper
const Wrapper = () => {
return mount(ContributionLinkForm, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".contribution-link-form"', () => {
expect(wrapper.find('div.contribution-link-form').exists()).toBe(true)
})
describe('call onReset', () => {
it('form has the set data', () => {
beforeEach(() => {
wrapper.setData({
form: {
name: 'name',
memo: 'memo',
amount: 100,
validFrom: 'validFrom',
validTo: 'validTo',
cycle: 'ONCE',
maxPerCycle: 1,
maxAmountPerMonth: 100,
},
})
wrapper.vm.onReset()
})
expect(wrapper.vm.form).toEqual({
amount: null,
cycle: 'ONCE',
validTo: null,
maxAmountPerMonth: '0',
memo: null,
name: null,
maxPerCycle: 1,
validFrom: null,
})
})
})
describe('call onSubmit', () => {
it('response with the contribution link url', () => {
wrapper.vm.onSubmit()
})
})
// describe('successfull submit', () => {
// beforeEach(async () => {
// mockAPIcall.mockResolvedValue({
// data: {
// createContributionLink: {
// link: 'https://localhost/redeem/CL-1a2345678',
// },
// },
// })
// await wrapper.find('input.test-validFrom').setValue('2022-6-18')
// await wrapper.find('input.test-validTo').setValue('2022-7-18')
// await wrapper.find('input.test-name').setValue('test name')
// await wrapper.find('input.test-memo').setValue('test memo')
// await wrapper.find('input.test-amount').setValue('100')
// await wrapper.find('form').trigger('submit')
// })
// it('calls the API', () => {
// expect(mockAPIcall).toHaveBeenCalledWith(
// expect.objectContaining({
// variables: {
// link: 'https://localhost/redeem/CL-1a2345678',
// },
// }),
// )
// })
// it('displays the new username', () => {
// expect(wrapper.find('div.display-username').text()).toEqual('@username')
// })
// })
})
describe('send createContributionLink with error', () => {
beforeEach(() => {
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
wrapper = Wrapper()
wrapper.vm.onSubmit()
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('contributionLink.noStartDate')
})
})
})

View File

@ -0,0 +1,38 @@
<template>
<div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index">
<b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else>{{ text }}</span>
</span>
</div>
</template>
<script>
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',
props: {
message: {
type: String,
required: true,
},
},
computed: {
linkifiedMessage() {
const linkified = []
let string = this.message
let match
while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0)
linkified.push({ type: 'text', text: string.substring(0, match.index) })
linkified.push({ type: 'link', text: match[0] })
string = string.substring(match.index + match[0].length)
}
if (string.length > 0) linkified.push({ type: 'text', text: string })
return linkified
},
},
}
</script>

View File

@ -9,50 +9,184 @@ describe('ContributionMessagesListItem', () => {
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$store: {
state: {
moderator: {
id: 107,
},
}
describe('if message author has moderator role', () => {
const propsData = {
contributionId: 42,
state: 'PENDING',
message: {
id: 111,
message: 'Lorem ipsum?',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
isModerator: true,
__typename: 'ContributionMessage',
},
},
}
}
const propsData = {
contributionId: 42,
state: 'PENDING',
message: {
id: 111,
message: 'asd asda sda sda',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const ModeratorItemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
const Wrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
describe('mount', () => {
beforeAll(() => {
wrapper = ModeratorItemWrapper()
})
it('has a DIV .text-right.is-moderator', () => {
expect(wrapper.find('div.text-right.is-moderator').exists()).toBe(true)
})
it('has the complete user name', () => {
expect(wrapper.find('div.text-right.is-moderator > span:nth-child(2)').text()).toBe(
'Peter Lustig',
)
})
it('has the message creation date', () => {
expect(wrapper.find('div.text-right.is-moderator > span:nth-child(3)').text()).toMatch(
'Mon Aug 29 2022 12:23:27 GMT+0000',
)
})
it('has the moderator label', () => {
expect(wrapper.find('div.text-right.is-moderator > small:nth-child(4)').text()).toBe(
'moderator',
)
})
it('has the message', () => {
expect(wrapper.find('div.text-right.is-moderator > div:nth-child(5)').text()).toBe(
'Lorem ipsum?',
)
})
})
}
})
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
describe('if message author does not have moderator role', () => {
const propsData = {
contributionId: 42,
state: 'PENDING',
message: {
id: 113,
message: 'Asda sdad ad asdasd, das Ass das Das. ',
createdAt: '2022-08-29T12:25:34.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Bibi',
userLastName: 'Bloxberg',
userId: 108,
__typename: 'ContributionMessage',
},
}
const ItemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeAll(() => {
wrapper = ItemWrapper()
})
it('has a DIV .text-left.is-not-moderator', () => {
expect(wrapper.find('div.text-left.is-not-moderator').exists()).toBe(true)
})
it('has the complete user name', () => {
expect(wrapper.find('div.is-not-moderator.text-left > span:nth-child(2)').text()).toBe(
'Bibi Bloxberg',
)
})
it('has the message creation date', () => {
expect(wrapper.find('div.is-not-moderator.text-left > span:nth-child(3)').text()).toMatch(
'Mon Aug 29 2022 12:25:34 GMT+0000',
)
})
it('has the message', () => {
expect(wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)').text()).toBe(
'Asda sdad ad asdasd, das Ass das Das.',
)
})
})
})
describe('links in contribtion message', () => {
const propsData = {
message: {
id: 111,
message: 'Lorem ipsum?',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const ModeratorItemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
let messageField
describe('message of only one link', () => {
beforeEach(() => {
propsData.message.message = 'https://gradido.net/de/'
wrapper = ModeratorItemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)')
})
it('contains the link as text', () => {
expect(messageField.text()).toBe('https://gradido.net/de/')
})
it('contains a link to the given address', () => {
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
})
})
it('has a DIV .contribution-messages-list-item', () => {
expect(wrapper.find('div.contribution-messages-list-item').exists()).toBe(true)
})
describe('message with text and two links', () => {
beforeEach(() => {
propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/
and here is the link to the repository: https://github.com/gradido/gradido`
wrapper = ModeratorItemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)')
})
it('props.message.default', () => {
expect(wrapper.vm.$options.props.message.default.call()).toEqual({})
it('contains the whole text', () => {
expect(messageField.text())
.toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/
and here is the link to the repository: https://github.com/gradido/gradido`)
})
it('contains the two links', () => {
expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/')
expect(messageField.findAll('a').at(1).attributes('href')).toBe(
'https://github.com/gradido/gradido',
)
})
})
})
})

View File

@ -1,27 +1,49 @@
<template>
<div class="contribution-messages-list-item">
<is-moderator v-if="message.isModerator" :message="message"></is-moderator>
<is-not-moderator v-else :message="message"></is-not-moderator>
<div v-if="message.isModerator" class="text-right is-moderator">
<b-avatar square variant="warning"></b-avatar>
<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>
</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>
</div>
</div>
</template>
<script>
import IsModerator from '@/components/ContributionMessages/slots/IsModerator.vue'
import IsNotModerator from '@/components/ContributionMessages/slots/IsNotModerator.vue'
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue'
export default {
name: 'ContributionMessagesListItem',
components: {
IsModerator,
IsNotModerator,
LinkifyMessage,
},
props: {
message: {
type: Object,
required: true,
default() {
return {}
},
},
},
}
</script>
<style>
.is-not-moderator {
clear: both;
width: 75%;
margin-top: 20px;
/* background-color: rgb(261, 204, 221); */
}
.is-moderator {
clear: both;
float: right;
width: 75%;
margin-top: 20px;
margin-bottom: 20px;
/* background-color: rgb(255, 255, 128); */
}
</style>

View File

@ -1,49 +0,0 @@
import { mount } from '@vue/test-utils'
import IsModerator from './IsModerator.vue'
const localVue = global.localVue
describe('IsModerator', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {
message: {
id: 111,
message: 'asd asda sda sda',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const Wrapper = () => {
return mount(IsModerator, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .slot-is-moderator', () => {
expect(wrapper.find('div.slot-is-moderator').exists()).toBe(true)
})
it('props.message.default', () => {
expect(wrapper.vm.$options.props.message.default.call()).toEqual({})
})
})
})

View File

@ -1,37 +0,0 @@
<template>
<div class="slot-is-moderator">
<div class="text-right">
<b-avatar square :text="initialLetters" variant="warning"></b-avatar>
<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>
<div class="mt-2">{{ message.message }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
message: {
type: Object,
default() {
return {}
},
},
},
computed: {
initialLetters() {
return `${this.message.userFirstName[0]} ${this.message.userLastName[0]}`
},
},
}
</script>
<style>
.slot-is-moderator {
clear: both;
float: right;
width: 75%;
margin-top: 20px;
margin-bottom: 20px;
}
</style>

View File

@ -1,49 +0,0 @@
import { mount } from '@vue/test-utils'
import IsNotModerator from './IsNotModerator.vue'
const localVue = global.localVue
describe('IsNotModerator', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {
message: {
id: 113,
message: 'asda sdad ad asdasd ',
createdAt: '2022-08-29T12:25:34.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Bibi',
userLastName: 'Bloxberg',
userId: 108,
__typename: 'ContributionMessage',
},
}
const Wrapper = () => {
return mount(IsNotModerator, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .slot-is-not-moderator', () => {
expect(wrapper.find('div.slot-is-not-moderator').exists()).toBe(true)
})
it('props.message.default', () => {
expect(wrapper.vm.$options.props.message.default.call()).toEqual({})
})
})
})

View File

@ -1,34 +0,0 @@
<template>
<div class="slot-is-not-moderator">
<div>
<b-avatar :text="initialLetters" 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>
<div class="mt-2">{{ message.message }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
message: {
type: Object,
default() {
return {}
},
},
},
computed: {
initialLetters() {
return `${this.message.userFirstName[0]} ${this.message.userLastName[0]}`
},
},
}
</script>
<style>
.slot-is-not-moderator {
clear: both;
width: 75%;
margin-top: 20px;
}
</style>

View File

@ -12,6 +12,7 @@
value-field="item"
text-field="name"
name="month-selection"
:disabled="true"
></b-form-radio-group>
</b-row>
<div class="m-4">

View File

@ -3,11 +3,19 @@ import NavBar from './NavBar.vue'
const localVue = global.localVue
const apolloMutateMock = jest.fn()
const storeDispatchMock = jest.fn()
const routerPushMock = jest.fn()
const stubs = {
RouterLink: true,
}
const mocks = {
$t: jest.fn((t) => t),
$apollo: {
mutate: apolloMutateMock,
},
$store: {
state: {
openCreations: 1,
@ -24,7 +32,7 @@ describe('NavBar', () => {
let wrapper
const Wrapper = () => {
return mount(NavBar, { mocks, localVue })
return mount(NavBar, { mocks, localVue, stubs })
}
describe('mount', () => {
@ -37,13 +45,35 @@ 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',
)
})
})
describe('wallet', () => {
const assignLocationSpy = jest.fn()
beforeEach(async () => {
await wrapper.findAll('a').at(5).trigger('click')
await wrapper.findAll('.nav-item').at(5).find('a').trigger('click')
})
it.skip('changes widnow location to wallet', () => {
it.skip('changes window location to wallet', () => {
expect(assignLocationSpy).toBeCalledWith('valid-token')
})
@ -59,7 +89,7 @@ describe('NavBar', () => {
window.location = {
assign: windowLocationMock,
}
await wrapper.findAll('a').at(6).trigger('click')
await wrapper.findAll('.nav-item').at(6).find('a').trigger('click')
})
it('redirects to /logout', () => {
@ -69,5 +99,9 @@ describe('NavBar', () => {
it('dispatches logout to store', () => {
expect(storeDispatchMock).toBeCalledWith('logout')
})
it('has called logout mutation', () => {
expect(apolloMutateMock).toBeCalled()
})
})
})

View File

@ -19,6 +19,9 @@
>
{{ $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 @click="wallet">{{ $t('navbar.my-account') }}</b-nav-item>
<b-nav-item @click="logout">{{ $t('navbar.logout') }}</b-nav-item>
</b-navbar-nav>
@ -28,14 +31,18 @@
</template>
<script>
import CONFIG from '../config'
import { logout } from '../graphql/logout'
export default {
name: 'navbar',
methods: {
logout() {
async logout() {
window.location.assign(CONFIG.WALLET_URL)
// window.location = CONFIG.WALLET_URL
this.$store.dispatch('logout')
await this.$apollo.mutate({
mutation: logout,
})
},
wallet() {
window.location = CONFIG.WALLET_AUTH_URL.replace('{token}', this.$store.state.token)

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const logout = gql`
mutation {
logout
}
`

View File

@ -9,7 +9,6 @@
"cycle": "Zyklus",
"deleted": "Automatische Schöpfung gelöscht!",
"deleteNow": "Automatische Creations '{name}' wirklich löschen?",
"maximumAmount": "maximaler Betrag",
"maxPerCycle": "Wiederholungen",
"memo": "Nachricht",
"name": "Name",
@ -21,11 +20,7 @@
"options": {
"cycle": {
"daily": "täglich",
"hourly": "stündlich",
"monthly": "monatlich",
"once": "einmalig",
"weekly": "wöchentlich",
"yearly": "jährlich"
"once": "einmalig"
}
},
"validFrom": "Startdatum",
@ -100,6 +95,7 @@
"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",

View File

@ -9,7 +9,6 @@
"cycle": "Cycle",
"deleted": "Automatic creation deleted!",
"deleteNow": "Do you really delete automatic creations '{name}'?",
"maximumAmount": "Maximum amount",
"maxPerCycle": "Repetition",
"memo": "Memo",
"name": "Name",
@ -21,11 +20,7 @@
"options": {
"cycle": {
"daily": "daily",
"hourly": "hourly",
"monthly": "monthly",
"once": "once",
"weekly": "weekly",
"yearly": "yearly"
"once": "once"
}
},
"validFrom": "Start-date",
@ -100,6 +95,7 @@
"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",

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,6 +1,5 @@
import { mount } from '@vue/test-utils'
import Overview from './Overview.vue'
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
@ -36,27 +35,6 @@ const apolloQueryMock = jest
},
},
})
.mockResolvedValueOnce({
data: {
listContributionLinks: {
links: [
{
id: 1,
name: 'Meditation',
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
amount: '200',
validFrom: '2022-04-01',
validTo: '2022-08-01',
cycle: 'täglich',
maxPerCycle: '3',
maxAmountPerMonth: 0,
link: 'https://localhost/redeem/CL-1a2345678',
},
],
count: 1,
},
},
})
.mockResolvedValue({
data: {
listUnconfirmedContributions: [
@ -78,6 +56,7 @@ const storeCommitMock = jest.fn()
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
$d: jest.fn((d) => d),
$apollo: {
query: apolloQueryMock,
},
@ -117,14 +96,6 @@ describe('Overview', () => {
)
})
it('calls listContributionLinks', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: listContributionLinks,
}),
)
})
it('commits three pending creations to store', () => {
expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3)
})

View File

@ -28,31 +28,21 @@
</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,
@ -75,20 +65,6 @@ 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({
@ -113,7 +89,6 @@ export default {
created() {
this.getPendingCreations()
this.getCommunityStatistics()
this.getContributionLinks()
},
}
</script>

View File

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

View File

@ -23,6 +23,10 @@ const routes = [
path: '/creation-confirm',
component: () => import('@/pages/CreationConfirm.vue'),
},
{
path: '/contribution-links',
component: () => import('@/pages/ContributionLinks.vue'),
},
{
path: '*',
component: () => import('@/components/NotFoundPage.vue'),

View File

@ -5,6 +5,7 @@ module.exports = {
collectCoverage: true,
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
setupFiles: ['<rootDir>/test/testSetup.ts'],
setupFilesAfterEnv: ['<rootDir>/test/extensions.ts'],
modulePathIgnorePatterns: ['<rootDir>/build/'],
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',

View File

@ -5,41 +5,66 @@
{
"type": "dateFile",
"filename": "../logs/backend/access.log",
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_"
"fileNameSep" : "_",
"numBackups" : 30
},
"apollo":
{
"type": "dateFile",
"filename": "../logs/backend/apollo.log",
"pattern": "%d{ISO8601} %p %c %m",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_"
"fileNameSep" : "_",
"numBackups" : 30
},
"backend":
{
"type": "dateFile",
"filename": "../logs/backend/backend.log",
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_"
"fileNameSep" : "_",
"numBackups" : 30
},
"klicktipp":
{
"type": "dateFile",
"filename": "../logs/backend/klicktipp.log",
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_"
"fileNameSep" : "_",
"numBackups" : 30
},
"errorFile":
{
"type": "dateFile",
"filename": "../logs/backend/errors.log",
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_"
"fileNameSep" : "_",
"numBackups" : 30
},
"errors":
{
@ -52,7 +77,7 @@
"type": "stdout",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m"
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
}
},
"apolloOut":
@ -60,7 +85,7 @@
"type": "stdout",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c %m"
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
}
}
},

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "1.12.1",
"version": "1.13.3",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",

View File

@ -10,7 +10,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0049-add_user_contacts_table',
DB_VERSION: '0051-add_delete_by_to_contributions',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
@ -67,7 +67,7 @@ const loginServer = {
const email = {
EMAIL: process.env.EMAIL === 'true' || false,
EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' || 'false',
EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' || false,
EMAIL_TEST_RECEIVER: process.env.EMAIL_TEST_RECEIVER || 'stage1@gradido.net',
EMAIL_USERNAME: process.env.EMAIL_USERNAME || 'gradido_email',
EMAIL_SENDER: process.env.EMAIL_SENDER || 'info@gradido.net',

View File

@ -11,50 +11,80 @@ export class EventBasicUserId extends EventBasic {
}
export class EventBasicTx extends EventBasicUserId {
xUserId: number
xCommunityId: number
transactionId: number
amount: decimal
}
export class EventBasicTxX extends EventBasicTx {
xUserId: number
xCommunityId: number
}
export class EventBasicCt extends EventBasicUserId {
contributionId: number
amount: decimal
}
export class EventBasicCtX extends EventBasicCt {
xUserId: number
xCommunityId: number
}
export class EventBasicRedeem extends EventBasicUserId {
transactionId?: number
contributionId?: number
}
export class EventBasicCtMsg extends EventBasicCt {
messageId: number
}
export class EventVisitGradido extends EventBasic {}
export class EventRegister extends EventBasicUserId {}
export class EventRedeemRegister extends EventBasicRedeem {}
export class EventVerifyRedeem extends EventBasicRedeem {}
export class EventInactiveAccount extends EventBasicUserId {}
export class EventSendConfirmationEmail extends EventBasicUserId {}
export class EventSendAccountMultiRegistrationEmail extends EventBasicUserId {}
export class EventSendForgotPasswordEmail extends EventBasicUserId {}
export class EventSendTransactionSendEmail extends EventBasicTxX {}
export class EventSendTransactionReceiveEmail extends EventBasicTxX {}
export class EventSendTransactionLinkRedeemEmail extends EventBasicTxX {}
export class EventSendAddedContributionEmail extends EventBasicCt {}
export class EventSendContributionConfirmEmail extends EventBasicCt {}
export class EventConfirmationEmail extends EventBasicUserId {}
export class EventRegisterEmailKlicktipp extends EventBasicUserId {}
export class EventLogin extends EventBasicUserId {}
export class EventLogout extends EventBasicUserId {}
export class EventRedeemLogin extends EventBasicRedeem {}
export class EventActivateAccount extends EventBasicUserId {}
export class EventPasswordChange extends EventBasicUserId {}
export class EventTransactionSend extends EventBasicTx {}
export class EventTransactionSendRedeem extends EventBasicTx {}
export class EventTransactionRepeateRedeem extends EventBasicTx {}
export class EventTransactionCreation extends EventBasicUserId {
transactionId: number
amount: decimal
}
export class EventTransactionReceive extends EventBasicTx {}
export class EventTransactionReceiveRedeem extends EventBasicTx {}
export class EventTransactionSend extends EventBasicTxX {}
export class EventTransactionSendRedeem extends EventBasicTxX {}
export class EventTransactionRepeateRedeem extends EventBasicTxX {}
export class EventTransactionCreation extends EventBasicTx {}
export class EventTransactionReceive extends EventBasicTxX {}
export class EventTransactionReceiveRedeem extends EventBasicTxX {}
export class EventContributionCreate extends EventBasicCt {}
export class EventContributionConfirm extends EventBasicCt {
xUserId: number
xCommunityId: number
}
export class EventAdminContributionCreate extends EventBasicCt {}
export class EventAdminContributionDelete extends EventBasicCt {}
export class EventAdminContributionUpdate extends EventBasicCt {}
export class EventUserCreateContributionMessage extends EventBasicCtMsg {}
export class EventAdminCreateContributionMessage extends EventBasicCtMsg {}
export class EventContributionDelete extends EventBasicCt {}
export class EventContributionUpdate extends EventBasicCt {}
export class EventContributionConfirm extends EventBasicCtX {}
export class EventContributionDeny extends EventBasicCtX {}
export class EventContributionLinkDefine extends EventBasicCt {}
export class EventContributionLinkActivateRedeem extends EventBasicCt {}
export class EventDeleteUser extends EventBasicUserId {}
export class EventUndeleteUser extends EventBasicUserId {}
export class EventChangeUserRole extends EventBasicUserId {}
export class EventAdminUpdateContribution extends EventBasicCt {}
export class EventAdminDeleteContribution extends EventBasicCt {}
export class EventCreateContributionLink extends EventBasicCt {}
export class EventDeleteContributionLink extends EventBasicCt {}
export class EventUpdateContributionLink extends EventBasicCt {}
export class Event {
constructor()
@ -100,6 +130,13 @@ export class Event {
return this
}
public setEventVerifyRedeem(ev: EventVerifyRedeem): Event {
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
this.type = EventProtocolType.VERIFY_REDEEM
return this
}
public setEventInactiveAccount(ev: EventInactiveAccount): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.INACTIVE_ACCOUNT
@ -118,7 +155,49 @@ export class Event {
ev: EventSendAccountMultiRegistrationEmail,
): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL
this.type = EventProtocolType.SEND_ACCOUNT_MULTIREGISTRATION_EMAIL
return this
}
public setEventSendForgotPasswordEmail(ev: EventSendForgotPasswordEmail): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.SEND_FORGOT_PASSWORD_EMAIL
return this
}
public setEventSendTransactionSendEmail(ev: EventSendTransactionSendEmail): Event {
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.SEND_TRANSACTION_SEND_EMAIL
return this
}
public setEventSendTransactionReceiveEmail(ev: EventSendTransactionReceiveEmail): Event {
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.SEND_TRANSACTION_RECEIVE_EMAIL
return this
}
public setEventSendTransactionLinkRedeemEmail(ev: EventSendTransactionLinkRedeemEmail): Event {
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.SEND_TRANSACTION_LINK_REDEEM_EMAIL
return this
}
public setEventSendAddedContributionEmail(ev: EventSendAddedContributionEmail): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.SEND_ADDED_CONTRIBUTION_EMAIL
return this
}
public setEventSendContributionConfirmEmail(ev: EventSendContributionConfirmEmail): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.SEND_CONTRIBUTION_CONFIRM_EMAIL
return this
}
@ -144,6 +223,13 @@ export class Event {
return this
}
public setEventLogout(ev: EventLogout): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.LOGOUT
return this
}
public setEventRedeemLogin(ev: EventRedeemLogin): Event {
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
this.type = EventProtocolType.REDEEM_LOGIN
@ -166,44 +252,42 @@ export class Event {
}
public setEventTransactionSend(ev: EventTransactionSend): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_SEND
return this
}
public setEventTransactionSendRedeem(ev: EventTransactionSendRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_SEND_REDEEM
return this
}
public setEventTransactionRepeateRedeem(ev: EventTransactionRepeateRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_REPEATE_REDEEM
return this
}
public setEventTransactionCreation(ev: EventTransactionCreation): Event {
this.setByBasicUser(ev.userId)
if (ev.transactionId) this.transactionId = ev.transactionId
if (ev.amount) this.amount = ev.amount
this.setByBasicTx(ev.userId, ev.transactionId, ev.amount)
this.type = EventProtocolType.TRANSACTION_CREATION
return this
}
public setEventTransactionReceive(ev: EventTransactionReceive): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_RECEIVE
return this
}
public setEventTransactionReceiveRedeem(ev: EventTransactionReceiveRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.TRANSACTION_RECEIVE_REDEEM
return this
@ -216,15 +300,69 @@ export class Event {
return this
}
public setEventContributionConfirm(ev: EventContributionConfirm): Event {
public setEventAdminContributionCreate(ev: EventAdminContributionCreate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
if (ev.xUserId) this.xUserId = ev.xUserId
if (ev.xCommunityId) this.xCommunityId = ev.xCommunityId
this.type = EventProtocolType.ADMIN_CONTRIBUTION_CREATE
return this
}
public setEventAdminContributionDelete(ev: EventAdminContributionDelete): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_CONTRIBUTION_DELETE
return this
}
public setEventAdminContributionUpdate(ev: EventAdminContributionUpdate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_CONTRIBUTION_UPDATE
return this
}
public setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event {
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE
return this
}
public setEventAdminCreateContributionMessage(ev: EventAdminCreateContributionMessage): Event {
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
this.type = EventProtocolType.ADMIN_CREATE_CONTRIBUTION_MESSAGE
return this
}
public setEventContributionDelete(ev: EventContributionDelete): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_DELETE
return this
}
public setEventContributionUpdate(ev: EventContributionUpdate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_UPDATE
return this
}
public setEventContributionConfirm(ev: EventContributionConfirm): Event {
this.setByBasicCtX(ev.userId, ev.contributionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.CONTRIBUTION_CONFIRM
return this
}
public setEventContributionDeny(ev: EventContributionDeny): Event {
this.setByBasicCtX(ev.userId, ev.contributionId, ev.amount, ev.xUserId, ev.xCommunityId)
this.type = EventProtocolType.CONTRIBUTION_DENY
return this
}
public setEventContributionLinkDefine(ev: EventContributionLinkDefine): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_LINK_DEFINE
@ -239,6 +377,62 @@ export class Event {
return this
}
public setEventDeleteUser(ev: EventDeleteUser): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.DELETE_USER
return this
}
public setEventUndeleteUser(ev: EventUndeleteUser): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.UNDELETE_USER
return this
}
public setEventChangeUserRole(ev: EventChangeUserRole): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.CHANGE_USER_ROLE
return this
}
public setEventAdminUpdateContribution(ev: EventAdminUpdateContribution): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_UPDATE_CONTRIBUTION
return this
}
public setEventAdminDeleteContribution(ev: EventAdminDeleteContribution): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_DELETE_CONTRIBUTION
return this
}
public setEventCreateContributionLink(ev: EventCreateContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CREATE_CONTRIBUTION_LINK
return this
}
public setEventDeleteContributionLink(ev: EventDeleteContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.DELETE_CONTRIBUTION_LINK
return this
}
public setEventUpdateContributionLink(ev: EventUpdateContributionLink): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.UPDATE_CONTRIBUTION_LINK
return this
}
setByBasicUser(userId: number): Event {
this.setEventBasic()
this.userId = userId
@ -246,26 +440,58 @@ export class Event {
return this
}
setByBasicTx(
userId: number,
xUserId?: number,
xCommunityId?: number,
transactionId?: number,
amount?: decimal,
): Event {
setByBasicTx(userId: number, transactionId: number, amount: decimal): Event {
this.setByBasicUser(userId)
if (xUserId) this.xUserId = xUserId
if (xCommunityId) this.xCommunityId = xCommunityId
if (transactionId) this.transactionId = transactionId
if (amount) this.amount = amount
this.transactionId = transactionId
this.amount = amount
return this
}
setByBasicCt(userId: number, contributionId: number, amount?: decimal): Event {
setByBasicTxX(
userId: number,
transactionId: number,
amount: decimal,
xUserId: number,
xCommunityId: number,
): Event {
this.setByBasicTx(userId, transactionId, amount)
this.xUserId = xUserId
this.xCommunityId = xCommunityId
return this
}
setByBasicCt(userId: number, contributionId: number, amount: decimal): Event {
this.setByBasicUser(userId)
if (contributionId) this.contributionId = contributionId
if (amount) this.amount = amount
this.contributionId = contributionId
this.amount = amount
return this
}
setByBasicCtMsg(
userId: number,
contributionId: number,
amount: decimal,
messageId: number,
): Event {
this.setByBasicCt(userId, contributionId, amount)
this.messageId = messageId
return this
}
setByBasicCtX(
userId: number,
contributionId: number,
amount: decimal,
xUserId: number,
xCommunityId: number,
): Event {
this.setByBasicCt(userId, contributionId, amount)
this.xUserId = xUserId
this.xCommunityId = xCommunityId
return this
}
@ -278,27 +504,6 @@ export class Event {
return this
}
setByEventTransactionCreation(event: EventTransactionCreation): Event {
this.type = event.type
this.createdAt = event.createdAt
this.userId = event.userId
this.transactionId = event.transactionId
this.amount = event.amount
return this
}
setByEventContributionConfirm(event: EventContributionConfirm): Event {
this.type = event.type
this.createdAt = event.createdAt
this.userId = event.userId
this.xUserId = event.xUserId
this.xCommunityId = event.xCommunityId
this.amount = event.amount
return this
}
id: number
type: string
createdAt: Date
@ -308,4 +513,5 @@ export class Event {
transactionId?: number
contributionId?: number
amount?: decimal
messageId?: number
}

View File

@ -3,23 +3,47 @@ export enum EventProtocolType {
VISIT_GRADIDO = 'VISIT_GRADIDO',
REGISTER = 'REGISTER',
REDEEM_REGISTER = 'REDEEM_REGISTER',
VERIFY_REDEEM = 'VERIFY_REDEEM',
INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT',
SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL',
SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTI_REGISTRATION_EMAIL',
SEND_ACCOUNT_MULTIREGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTIREGISTRATION_EMAIL',
CONFIRM_EMAIL = 'CONFIRM_EMAIL',
REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP',
LOGIN = 'LOGIN',
LOGOUT = 'LOGOUT',
REDEEM_LOGIN = 'REDEEM_LOGIN',
ACTIVATE_ACCOUNT = 'ACTIVATE_ACCOUNT',
SEND_FORGOT_PASSWORD_EMAIL = 'SEND_FORGOT_PASSWORD_EMAIL',
PASSWORD_CHANGE = 'PASSWORD_CHANGE',
SEND_TRANSACTION_SEND_EMAIL = 'SEND_TRANSACTION_SEND_EMAIL',
SEND_TRANSACTION_RECEIVE_EMAIL = 'SEND_TRANSACTION_RECEIVE_EMAIL',
TRANSACTION_SEND = 'TRANSACTION_SEND',
TRANSACTION_SEND_REDEEM = 'TRANSACTION_SEND_REDEEM',
TRANSACTION_REPEATE_REDEEM = 'TRANSACTION_REPEATE_REDEEM',
TRANSACTION_CREATION = 'TRANSACTION_CREATION',
TRANSACTION_RECEIVE = 'TRANSACTION_RECEIVE',
TRANSACTION_RECEIVE_REDEEM = 'TRANSACTION_RECEIVE_REDEEM',
SEND_TRANSACTION_LINK_REDEEM_EMAIL = 'SEND_TRANSACTION_LINK_REDEEM_EMAIL',
SEND_ADDED_CONTRIBUTION_EMAIL = 'SEND_ADDED_CONTRIBUTION_EMAIL',
SEND_CONTRIBUTION_CONFIRM_EMAIL = 'SEND_CONTRIBUTION_CONFIRM_EMAIL',
CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE',
CONTRIBUTION_CONFIRM = 'CONTRIBUTION_CONFIRM',
CONTRIBUTION_DENY = 'CONTRIBUTION_DENY',
CONTRIBUTION_LINK_DEFINE = 'CONTRIBUTION_LINK_DEFINE',
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE',
CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE',
ADMIN_CONTRIBUTION_CREATE = 'ADMIN_CONTRIBUTION_CREATE',
ADMIN_CONTRIBUTION_DELETE = 'ADMIN_CONTRIBUTION_DELETE',
ADMIN_CONTRIBUTION_UPDATE = 'ADMIN_CONTRIBUTION_UPDATE',
USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE',
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
DELETE_USER = 'DELETE_USER',
UNDELETE_USER = 'UNDELETE_USER',
CHANGE_USER_ROLE = 'CHANGE_USER_ROLE',
ADMIN_UPDATE_CONTRIBUTION = 'ADMIN_UPDATE_CONTRIBUTION',
ADMIN_DELETE_CONTRIBUTION = 'ADMIN_DELETE_CONTRIBUTION',
CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK',
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
}

View File

@ -1,13 +1,14 @@
import { registerEnumType } from 'type-graphql'
// lowercase values are not implemented yet
export enum ContributionCycleType {
ONCE = 'once',
ONCE = 'ONCE',
HOUR = 'hour',
TWO_HOURS = 'two_hours',
FOUR_HOURS = 'four_hours',
EIGHT_HOURS = 'eight_hours',
HALF_DAY = 'half_day',
DAY = 'day',
DAILY = 'DAILY',
TWO_DAYS = 'two_days',
THREE_DAYS = 'three_days',
FOUR_DAYS = 'four_days',

View File

@ -13,9 +13,11 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
import { stephenHawking } from '@/seeds/users/stephen-hawking'
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
import {
login,
setUserRole,
deleteUser,
unDeleteUser,
createContribution,
adminCreateContribution,
adminCreateContributions,
adminUpdateContribution,
@ -27,7 +29,6 @@ import {
} from '@/seeds/graphql/mutations'
import {
listUnconfirmedContributions,
login,
searchUsers,
listTransactionLinksAdmin,
listContributionLinks,
@ -41,6 +42,9 @@ import { Contribution } from '@entity/Contribution'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { EventProtocol } from '@entity/EventProtocol'
import { EventProtocolType } from '@/event/EventProtocolType'
import { logger } from '@test/testSetup'
// mock account activation email to avoid console spam
jest.mock('@/mailer/sendAccountActivationEmail', () => {
@ -77,6 +81,7 @@ afterAll(async () => {
let admin: User
let user: User
let creation: Contribution | void
let result: any
describe('AdminResolver', () => {
describe('set user role', () => {
@ -96,8 +101,8 @@ describe('AdminResolver', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -121,8 +126,8 @@ describe('AdminResolver', () => {
describe('with admin rights', () => {
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
@ -142,6 +147,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', () => {
@ -194,6 +203,9 @@ 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', () => {
@ -211,6 +223,10 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already admin!')
})
})
describe('to usual user', () => {
@ -227,6 +243,10 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already a usual user!')
})
})
})
})
@ -249,8 +269,8 @@ describe('AdminResolver', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -274,8 +294,8 @@ describe('AdminResolver', () => {
describe('with admin rights', () => {
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
@ -295,6 +315,10 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
})
})
describe('delete self', () => {
@ -307,6 +331,10 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not delete his own account!')
})
})
describe('delete with success', () => {
@ -336,6 +364,10 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`)
})
})
})
})
@ -357,8 +389,8 @@ describe('AdminResolver', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -382,8 +414,8 @@ describe('AdminResolver', () => {
describe('with admin rights', () => {
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
@ -403,6 +435,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', () => {
@ -420,6 +456,10 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is not deleted')
})
describe('undelete deleted user', () => {
beforeAll(async () => {
await mutate({ mutation: deleteUser, variables: { userId: user.id } })
@ -469,8 +509,8 @@ describe('AdminResolver', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -514,8 +554,8 @@ describe('AdminResolver', () => {
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
@ -766,8 +806,8 @@ describe('AdminResolver', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -875,8 +915,8 @@ describe('AdminResolver', () => {
describe('with admin rights', () => {
beforeAll(async () => {
admin = await userFactory(testEnv, peterLustig)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
@ -907,6 +947,12 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Could not find user with email: bibi@bloxberg.de',
)
})
})
describe('user to create for is deleted', () => {
@ -926,6 +972,12 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'This user was deleted. Cannot create a contribution.',
)
})
})
describe('user to create for has email not confirmed', () => {
@ -945,6 +997,12 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Contribution could not be saved, Email is not activated',
)
})
})
describe('valid user to create for', () => {
@ -965,6 +1023,13 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
'Invalid Date',
)
})
})
describe('date of creation is four months ago', () => {
@ -985,6 +1050,13 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
variables.creationDate,
)
})
})
describe('date of creation is in the future', () => {
@ -1005,6 +1077,13 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
variables.creationDate,
)
})
})
describe('amount of creation is too high', () => {
@ -1022,6 +1101,12 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
)
})
})
describe('creation is valid', () => {
@ -1037,6 +1122,15 @@ describe('AdminResolver', () => {
}),
)
})
it('stores the admin create contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_CREATE,
userId: admin.id,
}),
)
})
})
describe('second creation surpasses the available amount ', () => {
@ -1054,6 +1148,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.',
)
})
})
})
})
@ -1132,6 +1232,12 @@ 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', () => {
@ -1153,6 +1259,10 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User was deleted (stephen@hawking.uk)')
})
})
describe('creation does not exist', () => {
@ -1174,6 +1284,10 @@ 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', () => {
@ -1186,7 +1300,9 @@ describe('AdminResolver', () => {
email: 'bibi@bloxberg.de',
amount: new Decimal(300),
memo: 'Danke Bibi!',
creationDate: new Date().toString(),
creationDate: creation
? creation.contributionDate.toString()
: new Date().toString(),
},
}),
).resolves.toEqual(
@ -1199,9 +1315,16 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'user of the pending contribution and send user does not correspond',
)
})
})
describe('creation update is not valid', () => {
// as this test has not clearly defined that date, it is a false positive
it('throws an error', async () => {
await expect(
mutate({
@ -1211,22 +1334,31 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de',
amount: new Decimal(1900),
memo: 'Danke Peter!',
creationDate: new Date().toString(),
creationDate: creation
? creation.contributionDate.toString()
: new Date().toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'The amount (1900 GDD) to be created exceeds the amount (500 GDD) still available for this month.',
'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount (1900 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
)
})
})
describe('creation update is successful changing month', () => {
describe.skip('creation update is successful changing month', () => {
// skipped as changing the month is currently disable
it('returns update creation object', async () => {
await expect(
mutate({
@ -1236,7 +1368,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de',
amount: new Decimal(300),
memo: 'Danke Peter!',
creationDate: new Date().toString(),
creationDate: creation
? creation.contributionDate.toString()
: new Date().toString(),
},
}),
).resolves.toEqual(
@ -1246,15 +1380,25 @@ describe('AdminResolver', () => {
date: expect.any(String),
memo: 'Danke Peter!',
amount: '300',
creation: ['1000', '1000', '200'],
creation: ['1000', '700', '500'],
},
},
}),
)
})
it('stores the admin update contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE,
userId: admin.id,
}),
)
})
})
describe('creation update is successful without changing month', () => {
// actually this mutation IS changing the month
it('returns update creation object', async () => {
await expect(
mutate({
@ -1264,7 +1408,9 @@ describe('AdminResolver', () => {
email: 'peter@lustig.de',
amount: new Decimal(200),
memo: 'Das war leider zu Viel!',
creationDate: new Date().toString(),
creationDate: creation
? creation.contributionDate.toString()
: new Date().toString(),
},
}),
).resolves.toEqual(
@ -1274,12 +1420,21 @@ describe('AdminResolver', () => {
date: expect.any(String),
memo: 'Das war leider zu Viel!',
amount: '200',
creation: ['1000', '1000', '300'],
creation: ['1000', '800', '500'],
},
},
}),
)
})
it('stores the admin update contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.ADMIN_CONTRIBUTION_UPDATE,
userId: admin.id,
}),
)
})
})
})
@ -1302,7 +1457,7 @@ describe('AdminResolver', () => {
memo: 'Das war leider zu Viel!',
amount: '200',
moderator: admin.id,
creation: ['1000', '1000', '300'],
creation: ['1000', '800', '500'],
},
{
id: expect.any(Number),
@ -1313,7 +1468,7 @@ describe('AdminResolver', () => {
memo: 'Grundeinkommen',
amount: '500',
moderator: admin.id,
creation: ['1000', '1000', '300'],
creation: ['1000', '800', '500'],
},
{
id: expect.any(Number),
@ -1360,6 +1515,42 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution not found for given id: -1')
})
})
describe('admin deletes own user contribution', () => {
beforeAll(async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
result = await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
it('throws an error', async () => {
await expect(
mutate({
mutation: adminDeleteContribution,
variables: {
id: result.data.createContribution.id,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Own contribution can not be deleted as admin')],
}),
)
})
})
describe('creation id does exist', () => {
@ -1377,6 +1568,15 @@ 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,
}),
)
})
})
})
@ -1396,6 +1596,10 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution not found for given id: -1')
})
})
describe('confirm own creation', () => {
@ -1423,6 +1627,10 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not confirm own contribution')
})
})
describe('confirm creation for other user', () => {
@ -1451,6 +1659,14 @@ describe('AdminResolver', () => {
)
})
it('stores the contribution confirm event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.CONTRIBUTION_CONFIRM,
}),
)
})
it('creates a transaction', async () => {
const transaction = await DbTransaction.find()
expect(transaction[0].amount.toString()).toBe('450')
@ -1475,6 +1691,14 @@ describe('AdminResolver', () => {
}),
)
})
it('stores the send confirmation email event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.SEND_CONFIRMATION_EMAIL,
}),
)
})
})
describe('confirm two creations one after the other quickly', () => {
@ -1556,8 +1780,8 @@ describe('AdminResolver', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -1602,8 +1826,8 @@ describe('AdminResolver', () => {
}
// admin: only now log in
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
@ -1792,13 +2016,14 @@ describe('AdminResolver', () => {
})
describe('Contribution Links', () => {
const now = new Date()
const variables = {
amount: new Decimal(200),
name: 'Dokumenta 2022',
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
cycle: 'once',
validFrom: new Date(2022, 5, 18).toISOString(),
validTo: new Date(2022, 7, 14).toISOString(),
validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
}
@ -1862,8 +2087,8 @@ describe('AdminResolver', () => {
describe('without admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -1936,8 +2161,8 @@ describe('AdminResolver', () => {
describe('with admin rights', () => {
beforeAll(async () => {
user = await userFactory(testEnv, peterLustig)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
@ -1980,7 +2205,7 @@ describe('AdminResolver', () => {
name: 'Dokumenta 2022',
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
validFrom: new Date('2022-06-18T00:00:00.000Z'),
validTo: new Date('2022-08-14T00:00:00.000Z'),
validTo: expect.any(Date),
cycle: 'once',
maxPerCycle: 1,
totalMaxCountOfContribution: null,
@ -1990,8 +2215,8 @@ describe('AdminResolver', () => {
deletedAt: null,
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
linkEnabled: true,
// amount: '200',
// maxAmountPerMonth: '200',
amount: expect.decimalEqual(200),
maxAmountPerMonth: expect.decimalEqual(200),
}),
)
})
@ -2014,6 +2239,12 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'Start-Date is not initialized. A Start-Date must be set!',
)
})
it('returns an error if missing endDate', async () => {
await expect(
mutate({
@ -2030,6 +2261,12 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'End-Date is not initialized. An End-Date must be set!',
)
})
it('returns an error if endDate is before startDate', async () => {
await expect(
mutate({
@ -2049,6 +2286,12 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of validFrom must before or equals the validTo!`,
)
})
it('returns an error if name is an empty string', async () => {
await expect(
mutate({
@ -2065,6 +2308,10 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The name must be initialized!')
})
it('returns an error if name is shorter than 5 characters', async () => {
await expect(
mutate({
@ -2085,6 +2332,12 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`,
)
})
it('returns an error if name is longer than 100 characters', async () => {
await expect(
mutate({
@ -2105,6 +2358,12 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`,
)
})
it('returns an error if memo is an empty string', async () => {
await expect(
mutate({
@ -2121,6 +2380,10 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The memo must be initialized!')
})
it('returns an error if memo is shorter than 5 characters', async () => {
await expect(
mutate({
@ -2141,6 +2404,12 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`,
)
})
it('returns an error if memo is longer than 255 characters', async () => {
await expect(
mutate({
@ -2161,6 +2430,12 @@ describe('AdminResolver', () => {
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`,
)
})
it('returns an error if amount is not positive', async () => {
await expect(
mutate({
@ -2178,6 +2453,12 @@ describe('AdminResolver', () => {
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount=0 must be initialized with a positiv value!',
)
})
})
describe('listContributionLinks', () => {
@ -2233,6 +2514,10 @@ describe('AdminResolver', () => {
})
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
})
describe('valid id', () => {
let linkId: number
beforeAll(async () => {
@ -2280,7 +2565,7 @@ describe('AdminResolver', () => {
id: linkId,
name: 'Dokumenta 2023',
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
// amount: '400',
amount: expect.decimalEqual(400),
}),
)
})
@ -2298,6 +2583,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

@ -64,6 +64,15 @@ import { ContributionMessageType } from '@enum/MessageType'
import { ContributionMessage } from '@model/ContributionMessage'
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import {
Event,
EventAdminContributionCreate,
EventAdminContributionDelete,
EventAdminContributionUpdate,
EventContributionConfirm,
EventSendConfirmationEmail,
} from '@/event/Event'
import { ContributionListResult } from '../model/Contribution'
// const EMAIL_OPT_IN_REGISTER = 1
@ -145,11 +154,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 +169,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 +177,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 +196,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 +216,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()
@ -240,6 +257,8 @@ export class AdminResolver {
logger.error('Contribution could not be saved, Email is not activated')
throw new Error('Contribution could not be saved, Email is not activated')
}
const event = new Event()
const moderator = getUser(context)
logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(emailContact.userId)
@ -258,7 +277,17 @@ export class AdminResolver {
contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution)
await DbContribution.save(contribution)
const eventAdminCreateContribution = new EventAdminContributionCreate()
eventAdminCreateContribution.userId = moderator.id
eventAdminCreateContribution.amount = amount
eventAdminCreateContribution.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionCreate(eventAdminCreateContribution),
)
return getUserCreation(emailContact.userId)
}
@ -319,7 +348,6 @@ export class AdminResolver {
const contributionToUpdate = await DbContribution.findOne({
where: { id, confirmedAt: IsNull() },
})
if (!contributionToUpdate) {
logger.error('No contribution found to given id.')
throw new Error('No contribution found to given id.')
@ -337,8 +365,12 @@ export class AdminResolver {
const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate)
} else {
logger.error('Currently the month of the contribution cannot change.')
throw new Error('Currently the month of the contribution cannot change.')
}
// all possible cases not to be true are thrown in this function
@ -350,6 +382,7 @@ export class AdminResolver {
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await DbContribution.save(contributionToUpdate)
const result = new AdminUpdateContribution()
result.amount = amount
result.memo = contributionToUpdate.memo
@ -357,6 +390,15 @@ export class AdminResolver {
result.creation = await getUserCreation(user.id)
const event = new Event()
const eventAdminContributionUpdate = new EventAdminContributionUpdate()
eventAdminContributionUpdate.userId = user.id
eventAdminContributionUpdate.amount = amount
eventAdminContributionUpdate.contributionId = contributionToUpdate.id
await eventProtocol.writeEvent(
event.setEventAdminContributionUpdate(eventAdminContributionUpdate),
)
return result
}
@ -397,15 +439,36 @@ export class AdminResolver {
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
@Mutation(() => Boolean)
async adminDeleteContribution(@Arg('id', () => Int) id: number): Promise<boolean> {
async adminDeleteContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const contribution = await DbContribution.findOne(id)
if (!contribution) {
logger.error(`Contribution not found for given id: ${id}`)
throw new Error('Contribution not found for given id.')
}
const moderator = getUser(context)
if (
contribution.contributionType === ContributionType.USER &&
contribution.userId === moderator.id
) {
throw new Error('Own contribution can not be deleted as admin')
}
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),
)
return !!res
}
@ -501,6 +564,13 @@ export class AdminResolver {
} finally {
await queryRunner.release()
}
const event = new Event()
const eventContributionConfirm = new EventContributionConfirm()
eventContributionConfirm.userId = user.id
eventContributionConfirm.amount = contribution.amount
eventContributionConfirm.contributionId = contribution.id
await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm))
return true
}
@ -562,6 +632,13 @@ export class AdminResolver {
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
logger.info(`Account confirmation link: ${activationLink}`)
} else {
const event = new Event()
const eventSendConfirmationEmail = new EventSendConfirmationEmail()
eventSendConfirmationEmail.userId = user.id
await eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendConfirmationEmail),
)
}
return true
@ -675,6 +752,7 @@ export class AdminResolver {
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
): Promise<ContributionLinkList> {
const [links, count] = await DbContributionLink.findAndCount({
where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }],
order: { createdAt: order },
skip: (currentPage - 1) * pageSize,
take: pageSize,
@ -753,9 +831,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

@ -7,8 +7,9 @@ import {
adminCreateContributionMessage,
createContribution,
createContributionMessage,
login,
} from '@/seeds/graphql/mutations'
import { listContributionMessages, login } from '@/seeds/graphql/queries'
import { listContributionMessages } from '@/seeds/graphql/queries'
import { userFactory } from '@/seeds/factory/user'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
@ -21,14 +22,13 @@ jest.mock('@/mailer/sendAddedContributionMessageEmail', () => {
}
})
let mutate: any, query: any, con: any
let mutate: any, con: any
let testEnv: any
let result: any
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
})
@ -59,8 +59,8 @@ describe('ContributionMessageResolver', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
result = await mutate({
@ -71,8 +71,8 @@ describe('ContributionMessageResolver', () => {
creationDate: new Date().toString(),
},
})
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
@ -103,8 +103,8 @@ describe('ContributionMessageResolver', () => {
})
it('throws error when contribution.userId equals user.id', async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
const result2 = await mutate({
@ -195,8 +195,8 @@ describe('ContributionMessageResolver', () => {
describe('authenticated', () => {
beforeAll(async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -227,8 +227,8 @@ describe('ContributionMessageResolver', () => {
})
it('throws error when other user tries to send createContributionMessage', async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await expect(
@ -253,8 +253,8 @@ describe('ContributionMessageResolver', () => {
describe('valid input', () => {
beforeAll(async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -304,8 +304,8 @@ describe('ContributionMessageResolver', () => {
describe('authenticated', () => {
beforeAll(async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})

View File

@ -8,14 +8,18 @@ import {
createContribution,
deleteContribution,
updateContribution,
login,
} from '@/seeds/graphql/mutations'
import { listAllContributions, listContributions, login } from '@/seeds/graphql/queries'
import { listAllContributions, listContributions } from '@/seeds/graphql/queries'
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { GraphQLError } from 'graphql'
import { userFactory } from '@/seeds/factory/user'
import { creationFactory } from '@/seeds/factory/creation'
import { creations } from '@/seeds/creation/index'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { EventProtocol } from '@entity/EventProtocol'
import { EventProtocolType } from '@/event/EventProtocolType'
import { logger } from '@test/testSetup'
let mutate: any, query: any, con: any
let testEnv: any
@ -35,6 +39,8 @@ afterAll(async () => {
})
describe('ContributionResolver', () => {
let bibi: any
describe('createContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
@ -54,8 +60,9 @@ describe('ContributionResolver', () => {
describe('authenticated with valid user', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
bibi = await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -84,6 +91,10 @@ describe('ContributionResolver', () => {
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`memo text is too short: memo.length=4 < (5)`)
})
it('throws error when memo length greater than 255 chars', async () => {
const date = new Date()
await expect(
@ -102,6 +113,10 @@ describe('ContributionResolver', () => {
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`memo text is too long: memo.length=259 > (255)`)
})
it('throws error when creationDate not-valid', async () => {
await expect(
mutate({
@ -121,6 +136,13 @@ describe('ContributionResolver', () => {
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
'Invalid Date',
)
})
it('throws error when creationDate 3 month behind', async () => {
const date = new Date()
await expect(
@ -140,20 +162,31 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
'Invalid Date',
)
})
})
describe('valid input', () => {
let contribution: any
beforeAll(async () => {
contribution = await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
it('creates contribution', async () => {
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
}),
).resolves.toEqual(
expect(contribution).toEqual(
expect.objectContaining({
data: {
createContribution: {
@ -165,6 +198,17 @@ describe('ContributionResolver', () => {
}),
)
})
it('stores the create contribution event in the database', async () => {
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.CONTRIBUTION_CREATE,
amount: expect.decimalEqual(100),
contributionId: contribution.data.createContribution.id,
userId: bibi.data.login.id,
}),
)
})
})
})
})
@ -197,8 +241,8 @@ describe('ContributionResolver', () => {
const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await creationFactory(testEnv, bibisCreation!)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
@ -310,8 +354,8 @@ describe('ContributionResolver', () => {
beforeAll(async () => {
await userFactory(testEnv, peterLustig)
await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
result = await mutate({
@ -347,6 +391,10 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('No contribution found to given id')
})
})
describe('Memo length smaller than 5 chars', () => {
@ -368,6 +416,10 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < (5)')
})
})
describe('Memo length greater than 255 chars', () => {
@ -389,12 +441,16 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('memo text is too long: memo.length=259 > (255)')
})
})
describe('wrong user tries to update the contribution', () => {
beforeAll(async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
@ -420,6 +476,12 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'user of the pending contribution and send user does not correspond',
)
})
})
describe('admin tries to update a user contribution', () => {
@ -441,12 +503,14 @@ describe('ContributionResolver', () => {
}),
)
})
// TODO check that the error is logged (need to modify AdminResolver, avoid conflicts)
})
describe('update too much so that the limit is exceeded', () => {
beforeAll(async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
@ -472,6 +536,12 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'The amount (1019 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
)
})
})
describe('update creation to a date that is older than 3 months', () => {
@ -489,12 +559,17 @@ describe('ContributionResolver', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError('No information for available creations for the given date'),
],
errors: [new GraphQLError('Currently the month of the contribution cannot change.')],
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'No information for available creations with the given creationDate=',
'Invalid Date',
)
})
})
describe('valid input', () => {
@ -521,6 +596,22 @@ describe('ContributionResolver', () => {
}),
)
})
it('stores the update contribution event in the database', async () => {
bibi = await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.CONTRIBUTION_UPDATE,
amount: expect.decimalEqual(10),
contributionId: result.data.createContribution.id,
userId: bibi.data.login.id,
}),
)
})
})
})
})
@ -553,8 +644,8 @@ describe('ContributionResolver', () => {
const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await creationFactory(testEnv, bibisCreation!)
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
@ -627,11 +718,13 @@ describe('ContributionResolver', () => {
})
describe('authenticated', () => {
let peter: any
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
await query({
query: login,
peter = await userFactory(testEnv, peterLustig)
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
result = await mutate({
@ -664,12 +757,16 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('Contribution not found for given id')
})
})
describe('other user sends a deleteContribtuion', () => {
describe('other user sends a deleteContribution', () => {
it('returns an error', async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await expect(
@ -685,6 +782,10 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('Can not delete contribution of another user')
})
})
describe('User deletes own contribution', () => {
@ -698,12 +799,39 @@ describe('ContributionResolver', () => {
}),
).resolves.toBeTruthy()
})
it('stores the delete contribution event in the database', async () => {
const contribution = await mutate({
mutation: createContribution,
variables: {
amount: 166.0,
memo: 'Whatever contribution',
creationDate: new Date().toString(),
},
})
await mutate({
mutation: deleteContribution,
variables: {
id: contribution.data.createContribution.id,
},
})
await expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.CONTRIBUTION_DELETE,
contributionId: contribution.data.createContribution.id,
amount: expect.decimalEqual(166),
userId: peter.id,
}),
)
})
})
describe('User deletes already confirmed contribution', () => {
it('throws an error', async () => {
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
@ -712,8 +840,8 @@ describe('ContributionResolver', () => {
id: result.data.createContribution.id,
},
})
await query({
query: login,
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await expect(
@ -729,6 +857,10 @@ describe('ContributionResolver', () => {
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('A confirmed contribution can not be deleted')
})
})
})
})

View File

@ -13,6 +13,13 @@ 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 {
Event,
EventContributionCreate,
EventContributionDelete,
EventContributionUpdate,
} from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
@Resolver()
export class ContributionResolver {
@ -23,15 +30,17 @@ export class ContributionResolver {
@Ctx() context: Context,
): Promise<UnconfirmedContribution> {
if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`)
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS})`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
}
if (memo.length < MEMO_MIN_CHARS) {
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`)
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS})`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
const event = new Event()
const user = getUser(context)
const creations = await getUserCreation(user.id)
logger.trace('creations', creations)
@ -49,6 +58,13 @@ export class ContributionResolver {
logger.trace('contribution to save', contribution)
await dbContribution.save(contribution)
const eventCreateContribution = new EventContributionCreate()
eventCreateContribution.userId = user.id
eventCreateContribution.amount = amount
eventCreateContribution.contributionId = contribution.id
await eventProtocol.writeEvent(event.setEventContributionCreate(eventCreateContribution))
return new UnconfirmedContribution(contribution, user, creations)
}
@ -58,19 +74,33 @@ export class ContributionResolver {
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const event = new Event()
const user = getUser(context)
const contribution = await dbContribution.findOne(id)
if (!contribution) {
logger.error('Contribution not found for given id')
throw new Error('Contribution not found for given id.')
}
if (contribution.userId !== user.id) {
logger.error('Can not delete contribution of another user')
throw new Error('Can not delete contribution of another user')
}
if (contribution.confirmedAt) {
logger.error('A confirmed contribution can not be deleted')
throw new Error('A confirmed contribution can not be deleted')
}
contribution.contributionStatus = ContributionStatus.DELETED
contribution.deletedBy = user.id
contribution.deletedAt = new Date()
await contribution.save()
const eventDeleteContribution = new EventContributionDelete()
eventDeleteContribution.userId = user.id
eventDeleteContribution.contributionId = contribution.id
eventDeleteContribution.amount = contribution.amount
await eventProtocol.writeEvent(event.setEventContributionDelete(eventDeleteContribution))
const res = await contribution.softRemove()
return !!res
}
@ -98,6 +128,7 @@ export class ContributionResolver {
.from(dbContribution, 'c')
.leftJoinAndSelect('c.messages', 'm')
.where(where)
.withDeleted()
.orderBy('c.createdAt', order)
.limit(pageSize)
.offset((currentPage - 1) * pageSize)
@ -154,9 +185,11 @@ export class ContributionResolver {
where: { id: contributionId, confirmedAt: IsNull() },
})
if (!contributionToUpdate) {
logger.error('No contribution found to given id')
throw new Error('No contribution found to given id.')
}
if (contributionToUpdate.userId !== user.id) {
logger.error('user of the pending contribution and send user does not correspond')
throw new Error('user of the pending contribution and send user does not correspond')
}
@ -164,6 +197,9 @@ export class ContributionResolver {
let creations = await getUserCreation(user.id)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate)
} else {
logger.error('Currently the month of the contribution cannot change.')
throw new Error('Currently the month of the contribution cannot change.')
}
// all possible cases not to be true are thrown in this function
@ -174,6 +210,14 @@ export class ContributionResolver {
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
dbContribution.save(contributionToUpdate)
const event = new Event()
const eventUpdateContribution = new EventContributionUpdate()
eventUpdateContribution.userId = user.id
eventUpdateContribution.contributionId = contributionId
eventUpdateContribution.amount = amount
await eventProtocol.writeEvent(event.setEventContributionUpdate(eventUpdateContribution))
return new UnconfirmedContribution(contributionToUpdate, user, creations)
}
}

View File

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

View File

@ -1,4 +1,229 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { transactionLinkCode } from './TransactionLinkResolver'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { cleanDB, testEnvironment } from '@test/helpers'
import { userFactory } from '@/seeds/factory/user'
import {
login,
createContributionLink,
redeemTransactionLink,
createContribution,
updateContribution,
} from '@/seeds/graphql/mutations'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import Decimal from 'decimal.js-light'
import { GraphQLError } from 'graphql'
let mutate: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
mutate = testEnv.mutate
con = testEnv.con
await cleanDB()
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('TransactionLinkResolver', () => {
describe('redeem daily Contribution Link', () => {
const now = new Date()
let contributionLink: DbContributionLink | undefined
let contribution: UnconfirmedContribution | undefined
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContributionLink,
variables: {
amount: new Decimal(5),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
cycle: 'DAILY',
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
maxAmountPerMonth: new Decimal(200),
maxPerCycle: 1,
},
})
})
it('has a daily contribution link in the database', async () => {
const cls = await DbContributionLink.find()
expect(cls).toHaveLength(1)
contributionLink = cls[0]
expect(contributionLink).toEqual(
expect.objectContaining({
id: expect.any(Number),
name: 'Daily Contribution Link',
memo: 'Thank you for contribute daily to the community',
validFrom: new Date(now.getFullYear(), 0, 1),
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0),
cycle: 'DAILY',
maxPerCycle: 1,
totalMaxCountOfContribution: null,
maxAccountBalance: null,
minGapHours: null,
createdAt: expect.any(Date),
deletedAt: null,
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
linkEnabled: true,
amount: expect.decimalEqual(5),
maxAmountPerMonth: expect.decimalEqual(200),
}),
)
})
describe('user has pending contribution of 1000 GDD', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
const result = await mutate({
mutation: createContribution,
variables: {
amount: new Decimal(1000),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
contribution = result.data.createContribution
})
it('does not allow the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.',
),
],
})
})
})
describe('user has no pending contributions that would not allow to redeem the link', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: updateContribution,
variables: {
contributionId: contribution ? contribution.id : -1,
amount: new Decimal(800),
memo: 'I was brewing potions for the community the whole month',
creationDate: now.toISOString(),
},
})
})
it('allows the user to redeem the contribution link', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
describe('after one day', () => {
beforeAll(async () => {
jest.useFakeTimers()
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
setTimeout(() => {}, 1000 * 60 * 60 * 24)
jest.runAllTimers()
await mutate({
mutation: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(() => {
jest.useRealTimers()
})
it('allows the user to redeem the contribution link again', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
data: {
redeemTransactionLink: true,
},
errors: undefined,
})
})
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
await expect(
mutate({
mutation: redeemTransactionLink,
variables: {
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
},
}),
).resolves.toMatchObject({
errors: [
new GraphQLError(
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
),
],
})
})
})
})
})
})
describe('transactionLinkCode', () => {
const date = new Date()

View File

@ -34,6 +34,7 @@ import { getUserCreation, validateContribution } from './util/creations'
import { Decay } from '@model/Decay'
import Decimal from 'decimal.js-light'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { ContributionCycleType } from '@enum/ContributionCycleType'
const QueryLinkResult = createUnionType({
name: 'QueryLinkResult', // the name of the GraphQL union
@ -73,10 +74,7 @@ export class TransactionLinkResolver {
const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay)
// validate amount
const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate)
if (!sendBalance) {
throw new Error("user hasn't enough GDD or amount is < 0")
}
await calculateBalance(user.id, holdAvailableAmount, createdDate)
const transactionLink = dbTransactionLink.create()
transactionLink.userId = user.id
@ -204,26 +202,63 @@ export class TransactionLinkResolver {
throw new Error('Contribution link is depricated')
}
}
if (contributionLink.cycle !== 'ONCE') {
logger.error('contribution link has unknown cycle', contributionLink.cycle)
throw new Error('Contribution link has unknown cycle')
}
// Test ONCE rule
const alreadyRedeemed = await queryRunner.manager
.createQueryBuilder()
.select('contribution')
.from(DbContribution, 'contribution')
.where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', {
linkId: contributionLink.id,
id: user.id,
})
.getOne()
if (alreadyRedeemed) {
logger.error('contribution link with rule ONCE already redeemed by user with id', user.id)
throw new Error('Contribution link already redeemed')
let alreadyRedeemed: DbContribution | undefined
switch (contributionLink.cycle) {
case ContributionCycleType.ONCE: {
alreadyRedeemed = await queryRunner.manager
.createQueryBuilder()
.select('contribution')
.from(DbContribution, 'contribution')
.where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', {
linkId: contributionLink.id,
id: user.id,
})
.getOne()
if (alreadyRedeemed) {
logger.error(
'contribution link with rule ONCE already redeemed by user with id',
user.id,
)
throw new Error('Contribution link already redeemed')
}
break
}
case ContributionCycleType.DAILY: {
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date()
end.setHours(23, 59, 59, 999)
alreadyRedeemed = await queryRunner.manager
.createQueryBuilder()
.select('contribution')
.from(DbContribution, 'contribution')
.where(
`contribution.contributionLinkId = :linkId AND contribution.userId = :id
AND Date(contribution.confirmedAt) BETWEEN :start AND :end`,
{
linkId: contributionLink.id,
id: user.id,
start,
end,
},
)
.getOne()
if (alreadyRedeemed) {
logger.error(
'contribution link with rule DAILY already redeemed by user with id',
user.id,
)
throw new Error('Contribution link already redeemed today')
}
break
}
default: {
logger.error('contribution link has unknown cycle', contributionLink.cycle)
throw new Error('Contribution link has unknown cycle')
}
}
const creations = await getUserCreation(user.id, false)
const creations = await getUserCreation(user.id)
logger.info('open creations', creations)
validateContribution(creations, contributionLink.amount, now)
const contribution = new DbContribution()

View File

@ -0,0 +1,366 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { EventProtocolType } from '@/event/EventProtocolType'
import { userFactory } from '@/seeds/factory/user'
import {
confirmContribution,
createContribution,
login,
sendCoins,
} from '@/seeds/graphql/mutations'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { stephenHawking } from '@/seeds/users/stephen-hawking'
import { EventProtocol } from '@entity/EventProtocol'
import { Transaction } from '@entity/Transaction'
import { User } from '@entity/User'
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { logger } from '@test/testSetup'
import { GraphQLError } from 'graphql'
import { findUserByEmail } from './UserResolver'
let mutate: any, query: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment(logger)
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
let bobData: any
let peterData: any
let user: User[]
describe('send coins', () => {
beforeAll(async () => {
await userFactory(testEnv, peterLustig)
await userFactory(testEnv, bobBaumeister)
await userFactory(testEnv, stephenHawking)
await userFactory(testEnv, garrickOllivander)
bobData = {
email: 'bob@baumeister.de',
password: 'Aa12345_',
}
peterData = {
email: 'peter@lustig.de',
password: 'Aa12345_',
}
user = await User.find({ relations: ['emailContact'] })
})
afterAll(async () => {
await cleanDB()
})
describe('unknown recipient', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: bobData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'wrong@email.com',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('No user with this credentials')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`UserContact with email=wrong@email.com does not exists`)
})
describe('deleted recipient', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: peterData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'stephen@hawking.uk',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The recipient account was deleted')],
}),
)
})
it('logs the error thrown', async () => {
// find peter to check the log
const user = await findUserByEmail(peterData.email)
expect(logger.error).toBeCalledWith(
`The recipient account was deleted: recipientUser=${user}`,
)
})
})
describe('recipient account not activated', () => {
it('throws an error', async () => {
await mutate({
mutation: login,
variables: peterData,
})
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'garrick@ollivander.com',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The recipient account is not activated')],
}),
)
})
it('logs the error thrown', async () => {
// find peter to check the log
const user = await findUserByEmail(peterData.email)
expect(logger.error).toBeCalledWith(
`The recipient account is not activated: recipientUser=${user}`,
)
})
})
})
describe('errors in the transaction itself', () => {
beforeAll(async () => {
await mutate({
mutation: login,
variables: bobData,
})
})
describe('sender and recipient are the same', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'bob@baumeister.de',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Sender and Recipient are the same.')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Sender and Recipient are the same.')
})
})
describe('memo text is too long', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test test t',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('memo text is too long: memo.length=256 > 255')
})
})
describe('memo text is too short', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'test',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < 5')
})
})
describe('user has not enough GDD', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 100,
memo: 'testing',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError(`User has not received any GDD yet`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`No prior transaction found for user with id: ${user[1].id}`,
)
})
})
describe('sending negative amount', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: -50,
memo: 'testing negative',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Transaction amount must be greater than 0')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Transaction amount must be greater than 0: -50')
})
})
})
describe('user has some GDD', () => {
beforeAll(async () => {
resetToken()
// login as bob again
await query({ mutation: login, variables: bobData })
// create contribution as user bob
const contribution = await mutate({
mutation: createContribution,
variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() },
})
// login as admin
await query({ mutation: login, variables: peterData })
// confirm the contribution
await mutate({
mutation: confirmContribution,
variables: { id: contribution.data.createContribution.id },
})
// login as bob again
await query({ mutation: login, variables: bobData })
})
describe('good transaction', () => {
it('sends the coins', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: 50,
memo: 'unrepeatable memo',
},
}),
).toEqual(
expect.objectContaining({
data: {
sendCoins: 'true',
},
}),
)
})
it('stores the send transaction event in the database', async () => {
// Find the exact transaction (sent one is the one with user[1] as user)
const transaction = await Transaction.find({
userId: user[1].id,
memo: 'unrepeatable memo',
})
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.TRANSACTION_SEND,
userId: user[1].id,
transactionId: transaction[0].id,
xUserId: user[0].id,
}),
)
})
it('stores the receive event in the database', async () => {
// Find the exact transaction (received one is the one with user[0] as user)
const transaction = await Transaction.find({
userId: user[0].id,
memo: 'unrepeatable memo',
})
expect(EventProtocol.find()).resolves.toContainEqual(
expect.objectContaining({
type: EventProtocolType.TRANSACTION_RECEIVE,
userId: user[0].id,
transactionId: transaction[0].id,
xUserId: user[1].id,
}),
)
})
})
})
})

View File

@ -6,7 +6,7 @@ import CONFIG from '@/config'
import { Context, getUser } from '@/server/context'
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
import { getCustomRepository, getConnection } from '@dbTools/typeorm'
import { getCustomRepository, getConnection, In } from '@dbTools/typeorm'
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
@ -37,6 +37,9 @@ import { BalanceResolver } from './BalanceResolver'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
import { findUserByEmail } from './UserResolver'
import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { Decay } from '../model/Decay'
export const executeTransaction = async (
amount: Decimal,
@ -55,28 +58,19 @@ export const executeTransaction = async (
}
if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`)
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
}
if (memo.length < MEMO_MIN_CHARS) {
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`)
logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
// validate amount
const receivedCallDate = new Date()
const sendBalance = await calculateBalance(
sender.id,
amount.mul(-1),
receivedCallDate,
transactionLink,
)
logger.debug(`calculated Balance=${sendBalance}`)
if (!sendBalance) {
logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`)
throw new Error("user hasn't enough GDD or amount is < 0")
}
const sendBalance = await calculateBalance(sender.id, amount, receivedCallDate, transactionLink)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
@ -106,7 +100,24 @@ export const executeTransaction = async (
transactionReceive.userId = recipient.id
transactionReceive.linkedUserId = sender.id
transactionReceive.amount = amount
const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
// state received balance
let receiveBalance: {
balance: Decimal
decay: Decay
lastTransactionId: number
} | null
// try received balance
try {
receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
} catch (e) {
logger.info(
`User with no transactions sent: ${recipient.id}, has received a transaction of ${amount} GDD from user: ${sender.id}`,
)
receiveBalance = null
}
transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount
transactionReceive.balanceDate = receivedCallDate
transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0)
@ -135,6 +146,20 @@ export const executeTransaction = async (
await queryRunner.commitTransaction()
logger.info(`commit Transaction successful...`)
const eventTransactionSend = new EventTransactionSend()
eventTransactionSend.userId = transactionSend.userId
eventTransactionSend.xUserId = transactionSend.linkedUserId
eventTransactionSend.transactionId = transactionSend.id
eventTransactionSend.amount = transactionSend.amount.mul(-1)
await eventProtocol.writeEvent(new Event().setEventTransactionSend(eventTransactionSend))
const eventTransactionReceive = new EventTransactionReceive()
eventTransactionReceive.userId = transactionReceive.userId
eventTransactionReceive.xUserId = transactionReceive.linkedUserId
eventTransactionReceive.transactionId = transactionReceive.id
eventTransactionReceive.amount = transactionReceive.amount
await eventProtocol.writeEvent(new Event().setEventTransactionReceive(eventTransactionReceive))
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Transaction was not successful: ${e}`)
@ -224,11 +249,11 @@ export class TransactionResolver {
logger.debug(`involvedUserIds=${involvedUserIds}`)
// We need to show the name for deleted users for old transactions
const involvedDbUsers = await dbUser
.createQueryBuilder()
.withDeleted()
.where('id IN (:...userIds)', { userIds: involvedUserIds })
.getMany()
const involvedDbUsers = await dbUser.find({
where: { id: In(involvedUserIds) },
withDeleted: true,
relations: ['emailContact'],
})
const involvedUsers = involvedDbUsers.map((u) => new User(u))
logger.debug(`involvedUsers=${involvedUsers}`)
@ -316,6 +341,10 @@ export class TransactionResolver {
}
*/
// const recipientUser = await dbUser.findOne({ id: emailContact.userId })
/* Code inside this if statement is unreachable (useless by so),
in findUserByEmail() an error is already thrown if the user is not found
*/
if (!recipientUser) {
logger.error(`unknown recipient to UserContact: email=${email}`)
throw new Error('unknown recipient')

View File

@ -5,6 +5,8 @@ import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/help
import { userFactory } from '@/seeds/factory/user'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import {
login,
logout,
createUser,
setPassword,
forgotPassword,
@ -12,7 +14,7 @@ import {
createContribution,
confirmContribution,
} from '@/seeds/graphql/mutations'
import { login, logout, verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries'
import { verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries'
import { GraphQLError } from 'graphql'
import { User } from '@entity/User'
import CONFIG from '@/config'
@ -358,7 +360,7 @@ describe('UserResolver', () => {
beforeAll(async () => {
await userFactory(testEnv, peterLustig)
await userFactory(testEnv, bobBaumeister)
await query({ query: login, variables: bobData })
await mutate({ mutation: login, variables: bobData })
// create contribution as user bob
contribution = await mutate({
@ -367,7 +369,7 @@ describe('UserResolver', () => {
})
// login as admin
await query({ query: login, variables: peterData })
await mutate({ mutation: login, variables: peterData })
// confirm the contribution
contribution = await mutate({
@ -376,7 +378,7 @@ describe('UserResolver', () => {
})
// login as user bob
bob = await query({ query: login, variables: bobData })
bob = await mutate({ mutation: login, variables: bobData })
// create transaction link
await transactionLinkFactory(testEnv, {
@ -582,7 +584,7 @@ describe('UserResolver', () => {
describe('no users in database', () => {
beforeAll(async () => {
jest.clearAllMocks()
result = await query({ query: login, variables })
result = await mutate({ mutation: login, variables })
})
it('throws an error', () => {
@ -603,7 +605,7 @@ describe('UserResolver', () => {
describe('user is in database and correct login data', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
result = await query({ query: login, variables })
result = await mutate({ mutation: login, variables })
})
afterAll(async () => {
@ -640,7 +642,7 @@ describe('UserResolver', () => {
describe('user is in database and wrong password', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
result = await query({ query: login, variables: { ...variables, password: 'wrong' } })
result = await mutate({ mutation: login, variables: { ...variables, password: 'wrong' } })
})
afterAll(async () => {
@ -665,7 +667,7 @@ describe('UserResolver', () => {
describe('unauthenticated', () => {
it('throws an error', async () => {
resetToken()
await expect(query({ query: logout })).resolves.toEqual(
await expect(mutate({ mutation: logout })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
@ -681,7 +683,7 @@ describe('UserResolver', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await query({ query: login, variables })
await mutate({ mutation: login, variables })
})
afterAll(async () => {
@ -689,7 +691,7 @@ describe('UserResolver', () => {
})
it('returns true', async () => {
await expect(query({ query: logout })).resolves.toEqual(
await expect(mutate({ mutation: logout })).resolves.toEqual(
expect.objectContaining({
data: { logout: 'true' },
errors: undefined,
@ -738,7 +740,7 @@ describe('UserResolver', () => {
}
beforeAll(async () => {
await query({ query: login, variables })
await mutate({ mutation: login, variables })
user = await User.find()
})
@ -929,8 +931,8 @@ describe('UserResolver', () => {
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
await mutate({
mutation: login,
variables: {
email: 'bibi@bloxberg.de',
password: 'Aa12345_',
@ -1061,8 +1063,8 @@ describe('UserResolver', () => {
it('can login with new password', async () => {
await expect(
query({
query: login,
mutate({
mutation: login,
variables: {
email: 'bibi@bloxberg.de',
password: 'Bb12345_',
@ -1081,8 +1083,8 @@ describe('UserResolver', () => {
it('cannot login with old password', async () => {
await expect(
query({
query: login,
mutate({
mutation: login,
variables: {
email: 'bibi@bloxberg.de',
password: 'Aa12345_',
@ -1119,8 +1121,8 @@ describe('UserResolver', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
await query({
query: login,
await mutate({
mutation: login,
variables: {
email: 'bibi@bloxberg.de',
password: 'Aa12345_',

View File

@ -316,7 +316,7 @@ export class UserResolver {
}
@Authorized([RIGHTS.LOGIN])
@Query(() => User)
@Mutation(() => User)
@UseMiddleware(klicktippNewsletterStateMiddleware)
async login(
@Args() { email, password, publisherId }: UnsecureLoginArgs,
@ -351,7 +351,7 @@ export class UserResolver {
}
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
logger.addContext('user', dbUser.id)
logger.debug('login credentials valid...')
logger.debug('validation of login credentials successful...')
const user = new User(dbUser, await getUserCreation(dbUser.id))
logger.debug(`user= ${JSON.stringify(user, null, 2)}`)
@ -377,7 +377,7 @@ export class UserResolver {
}
@Authorized([RIGHTS.LOGOUT])
@Query(() => String)
@Mutation(() => String)
async logout(): Promise<boolean> {
// TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token.
// Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login)
@ -396,6 +396,7 @@ export class UserResolver {
@Args()
{ email, firstName, lastName, language, publisherId, redeemCode = null }: CreateUserArgs,
): Promise<User> {
logger.addContext('user', 'unknown')
logger.info(
`createUser(email=${email}, firstName=${firstName}, lastName=${lastName}, language=${language}, publisherId=${publisherId}, redeemCode =${redeemCode})`,
)
@ -548,6 +549,7 @@ export class UserResolver {
}
await queryRunner.commitTransaction()
logger.addContext('user', dbUser.id)
} catch (e) {
logger.error(`error during create user with ${e}`)
await queryRunner.rollbackTransaction()
@ -571,6 +573,7 @@ export class UserResolver {
@Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL])
@Mutation(() => Boolean)
async forgotPassword(@Arg('email') email: string): Promise<boolean> {
logger.addContext('user', 'unknown')
logger.info(`forgotPassword(${email})...`)
email = email.trim().toLowerCase()
const user = await findUserByEmail(email).catch(() => {

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'
@ -21,7 +20,7 @@ export const validateContribution = (
if (index < 0) {
logger.error(
'No information for available creations with the given creationDate=',
creationDate,
creationDate.toString(),
)
throw new Error('No information for available creations for the given date')
}
@ -50,27 +49,27 @@ export const getUserCreations = async (
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
logger.trace('getUserCreations dateFilter=', dateFilter)
const unionString = includePending
? `
UNION
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions
WHERE user_id IN (${ids.toString()})
AND contribution_date >= ${dateFilter}
AND confirmed_at IS NULL AND deleted_at IS NULL`
: ''
logger.trace('getUserCreations unionString=', unionString)
const sumAmountContributionPerUserAndLast3MonthQuery = queryRunner.manager
.createQueryBuilder(Contribution, 'c')
.select('month(contribution_date)', 'month')
.addSelect('user_id', 'userId')
.addSelect('sum(amount)', 'sum')
.where(`user_id in (${ids.toString()})`)
.andWhere(`contribution_date >= ${dateFilter}`)
.andWhere('deleted_at IS NULL')
.andWhere('denied_at IS NULL')
.groupBy('month')
.addGroupBy('userId')
.orderBy('month', 'DESC')
const unionQuery = await queryRunner.manager.query(`
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM
(SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions
WHERE user_id IN (${ids.toString()})
AND type_id = ${TransactionTypeId.CREATION}
AND creation_date >= ${dateFilter}
${unionString}) AS result
GROUP BY month, userId
ORDER BY date DESC
`)
logger.trace('getUserCreations unionQuery=', unionQuery)
if (!includePending) {
sumAmountContributionPerUserAndLast3MonthQuery.andWhere('confirmed_at IS NOT NULL')
}
const sumAmountContributionPerUserAndLast3Month =
await sumAmountContributionPerUserAndLast3MonthQuery.getRawMany()
logger.trace(sumAmountContributionPerUserAndLast3Month)
await queryRunner.release()
@ -78,9 +77,9 @@ export const getUserCreations = async (
return {
id,
creations: months.map((month) => {
const creation = unionQuery.find(
(raw: { month: string; id: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.id) === id,
const creation = sumAmountContributionPerUserAndLast3Month.find(
(raw: { month: string; userId: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.userId) === id,
)
return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0)
}),

View File

@ -26,7 +26,7 @@ describe('sendAddedContributionMessageEmail', () => {
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Bibi Bloxberg <bibi@bloxberg.de>`,
subject: 'Gradido Frage zur Schöpfung',
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag',
text:
expect.stringContaining('Hallo Bibi Bloxberg') &&
expect.stringContaining('Peter Lustig') &&

View File

@ -29,6 +29,7 @@ describe('sendEMail', () => {
let result: boolean
describe('config email is false', () => {
beforeEach(async () => {
jest.clearAllMocks()
result = await sendEMail({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
@ -48,6 +49,7 @@ describe('sendEMail', () => {
describe('config email is true', () => {
beforeEach(async () => {
jest.clearAllMocks()
CONFIG.EMAIL = true
result = await sendEMail({
to: 'receiver@mail.org',
@ -73,7 +75,7 @@ describe('sendEMail', () => {
it('calls sendMail of transporter', () => {
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
to: `${CONFIG.EMAIL_TEST_RECEIVER}`,
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
@ -84,4 +86,28 @@ describe('sendEMail', () => {
expect(result).toBeTruthy()
})
})
describe('with email EMAIL_TEST_MODUS true', () => {
beforeEach(async () => {
jest.clearAllMocks()
CONFIG.EMAIL = true
CONFIG.EMAIL_TEST_MODUS = true
result = await sendEMail({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
it('calls sendMail of transporter with faked to', () => {
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
to: CONFIG.EMAIL_TEST_RECEIVER,
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
})
})
})

View File

@ -1,6 +1,6 @@
export const contributionMessageReceived = {
de: {
subject: 'Gradido Frage zur Schöpfung',
subject: 'Rückfrage zu Deinem Gemeinwohl-Beitrag',
text: (data: {
senderFirstName: string
senderLastName: string

View File

@ -1,6 +1,5 @@
import { ApolloServerTestClient } from 'apollo-server-testing'
import { createContributionLink } from '@/seeds/graphql/mutations'
import { login } from '@/seeds/graphql/queries'
import { login, createContributionLink } from '@/seeds/graphql/mutations'
import { ContributionLink } from '@model/ContributionLink'
import { ContributionLinkInterface } from '@/seeds/contributionLink/ContributionLinkInterface'
@ -8,12 +7,12 @@ export const contributionLinkFactory = async (
client: ApolloServerTestClient,
contributionLink: ContributionLinkInterface,
): Promise<ContributionLink> => {
const { mutate, query } = client
const { mutate } = client
// login as admin
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const user = await query({
query: login,
const user = await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})

View File

@ -2,8 +2,7 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { backendLogger as logger } from '@/server/logger'
import { adminCreateContribution, confirmContribution } from '@/seeds/graphql/mutations'
import { login } from '@/seeds/graphql/queries'
import { login, adminCreateContribution, confirmContribution } from '@/seeds/graphql/mutations'
import { CreationInterface } from '@/seeds/creation/CreationInterface'
import { ApolloServerTestClient } from 'apollo-server-testing'
import { Transaction } from '@entity/Transaction'
@ -19,9 +18,9 @@ export const creationFactory = async (
client: ApolloServerTestClient,
creation: CreationInterface,
): Promise<Contribution | void> => {
const { mutate, query } = client
const { mutate } = client
logger.trace('creationFactory...')
await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } })
await mutate({ mutation: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } })
logger.trace('creationFactory... after login')
// TODO it would be nice to have this mutation return the id
await mutate({ mutation: adminCreateContribution, variables: { ...creation } })

View File

@ -1,6 +1,5 @@
import { ApolloServerTestClient } from 'apollo-server-testing'
import { createTransactionLink } from '@/seeds/graphql/mutations'
import { login } from '@/seeds/graphql/queries'
import { login, createTransactionLink } from '@/seeds/graphql/mutations'
import { TransactionLinkInterface } from '@/seeds/transactionLink/TransactionLinkInterface'
import { transactionLinkExpireDate } from '@/graphql/resolver/TransactionLinkResolver'
import { TransactionLink } from '@entity/TransactionLink'
@ -9,10 +8,13 @@ export const transactionLinkFactory = async (
client: ApolloServerTestClient,
transactionLink: TransactionLinkInterface,
): Promise<void> => {
const { mutate, query } = client
const { mutate } = client
// login
await query({ query: login, variables: { email: transactionLink.email, password: 'Aa12345_' } })
await mutate({
mutation: login,
variables: { email: transactionLink.email, password: 'Aa12345_' },
})
const variables = {
amount: transactionLink.amount,

View File

@ -289,3 +289,33 @@ export const adminCreateContributionMessage = gql`
}
}
`
export const redeemTransactionLink = gql`
mutation ($code: String!) {
redeemTransactionLink(code: $code)
}
`
export const login = gql`
mutation ($email: String!, $password: String!, $publisherId: Int) {
login(email: $email, password: $password, publisherId: $publisherId) {
id
email
firstName
lastName
language
klickTipp {
newsletterState
}
hasElopage
publisherId
isAdmin
}
}
`
export const logout = gql`
mutation {
logout
}
`

View File

@ -1,23 +1,5 @@
import gql from 'graphql-tag'
export const login = gql`
query ($email: String!, $password: String!, $publisherId: Int) {
login(email: $email, password: $password, publisherId: $publisherId) {
id
email
firstName
lastName
language
klickTipp {
newsletterState
}
hasElopage
publisherId
isAdmin
}
}
`
export const verifyLogin = gql`
query {
verifyLogin {
@ -35,12 +17,6 @@ export const verifyLogin = gql`
}
`
export const logout = gql`
query {
logout
}
`
export const queryOptIn = gql`
query ($optIn: String!) {
queryOptIn(optIn: $optIn)

View File

@ -35,6 +35,7 @@ const createServer = async (
context: any = serverContext,
logger: Logger = apolloLogger,
): Promise<ServerDef> => {
logger.addContext('user', 'unknown')
logger.debug('createServer...')
// open mysql connection

View File

@ -28,8 +28,8 @@ export class UserRepository extends Repository<DbUser> {
): Promise<[DbUser[], number]> {
const query = this.createQueryBuilder('user')
.select(select)
.leftJoinAndSelect('user.emailContact', 'emailContact')
.withDeleted()
.leftJoinAndSelect('user.emailContact', 'emailContact')
.where(
new Brackets((qb) => {
qb.where(

View File

@ -1,5 +1,17 @@
import Decimal from 'decimal.js-light'
export const objectValuesToArray = (obj: { [x: string]: string }): Array<string> => {
return Object.keys(obj).map(function (key) {
return obj[key]
})
}
// to improve code readability, as String is needed, it is handled inside this utility function
export const decimalAddition = (a: Decimal, b: Decimal): Decimal => {
return a.add(b.toString())
}
// to improve code readability, as String is needed, it is handled inside this utility function
export const decimalSubtraction = (a: Decimal, b: Decimal): Decimal => {
return a.minus(b.toString())
}

View File

@ -5,6 +5,8 @@ import { Decay } from '@model/Decay'
import { getCustomRepository } from '@dbTools/typeorm'
import { TransactionLinkRepository } from '@repository/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { decimalSubtraction, decimalAddition } from './utilities'
import { backendLogger as logger } from '@/server/logger'
function isStringBoolean(value: string): boolean {
const lowerValue = value.toLowerCase()
@ -23,14 +25,26 @@ async function calculateBalance(
amount: Decimal,
time: Date,
transactionLink?: dbTransactionLink | null,
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> {
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number }> {
// negative or empty amount should not be allowed
if (amount.lessThanOrEqualTo(0)) {
logger.error(`Transaction amount must be greater than 0: ${amount}`)
throw new Error('Transaction amount must be greater than 0')
}
// check if user has prior transactions
const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } })
if (!lastTransaction) return null
if (!lastTransaction) {
logger.error(`No prior transaction found for user with id: ${userId}`)
throw new Error('User has not received any GDD yet')
}
const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time)
// TODO why we have to use toString() here?
const balance = decay.balance.add(amount.toString())
// new balance is the old balance minus the amount used
const balance = decimalSubtraction(decay.balance, amount)
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time)
@ -38,11 +52,16 @@ async function calculateBalance(
// else we cannot redeem links which are more or equal to half of what an account actually owns
const releasedLinkAmount = transactionLink ? transactionLink.holdAvailableAmount : new Decimal(0)
if (
balance.minus(sumHoldAvailableAmount.toString()).plus(releasedLinkAmount.toString()).lessThan(0)
) {
return null
const availableBalance = decimalSubtraction(balance, sumHoldAvailableAmount)
if (decimalAddition(availableBalance, releasedLinkAmount).lessThan(0)) {
logger.error(
`Not enough funds for a transaction of ${amount} GDD, user with id: ${userId} has only ${balance} GDD available`,
)
throw new Error('Not enough funds for transaction')
}
logger.debug(`calculated Balance=${balance}`)
return { balance, lastTransactionId: lastTransaction.id, decay }
}

View File

@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
import Decimal from 'decimal.js-light'
expect.extend({
decimalEqual(received, value) {
const pass = new Decimal(value).equals(received.toString())
if (pass) {
return {
message: () => `expected ${received} to not equal ${value}`,
pass: true,
}
} else {
return {
message: () => `expected ${received} to equal ${value}`,
pass: false,
}
}
},
})
interface CustomMatchers<R = unknown> {
decimalEqual(value: number): R
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace jest {
interface Expect extends CustomMatchers {}
interface Matchers<R> extends CustomMatchers<R> {}
interface InverseAsymmetricMatchers extends CustomMatchers {}
}
}

View File

@ -1668,9 +1668,9 @@ camelcase@^6.2.0:
integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
caniuse-lite@^1.0.30001264:
version "1.0.30001325"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001325.tgz"
integrity sha512-sB1bZHjseSjDtijV1Hb7PB2Zd58Kyx+n/9EotvZ4Qcz2K3d0lWB8dB4nb8wN/TsOGFq3UuAm0zQZNQ4SoR7TrQ==
version "1.0.30001418"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001418.tgz"
integrity sha512-oIs7+JL3K9JRQ3jPZjlH6qyYDp+nBTCais7hjh0s+fuBwufc7uZ7hPYMXrDOJhV360KGMTcczMRObk0/iMqZRg==
chalk@^2.0.0:
version "2.4.2"

View File

@ -0,0 +1,42 @@
import Decimal from 'decimal.js-light'
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
@Entity('event_protocol')
export class EventProtocol extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ length: 100, nullable: false, collation: 'utf8mb4_unicode_ci' })
type: string
@Column({ name: 'created_at', type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@Column({ name: 'x_user_id', unsigned: true, nullable: true })
xUserId: number
@Column({ name: 'x_community_id', unsigned: true, nullable: true })
xCommunityId: number
@Column({ name: 'transaction_id', unsigned: true, nullable: true })
transactionId: number
@Column({ name: 'contribution_id', unsigned: true, nullable: true })
contributionId: number
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: true,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({ name: 'message_id', unsigned: true, nullable: true })
messageId: number
}

View File

@ -0,0 +1,92 @@
import Decimal from 'decimal.js-light'
import {
BaseEntity,
Column,
Entity,
PrimaryGeneratedColumn,
DeleteDateColumn,
JoinColumn,
ManyToOne,
OneToMany,
} from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { User } from '../User'
import { ContributionMessage } from '../ContributionMessage'
@Entity('contributions')
export class Contribution extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ unsigned: true, nullable: false, name: 'user_id' })
userId: number
@ManyToOne(() => User, (user) => user.contributions)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date
@Column({ type: 'datetime', nullable: false, name: 'contribution_date' })
contributionDate: Date
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
memo: string
@Column({
type: 'decimal',
precision: 40,
scale: 20,
nullable: false,
transformer: DecimalTransformer,
})
amount: Decimal
@Column({ unsigned: true, nullable: true, name: 'moderator_id' })
moderatorId: number
@Column({ unsigned: true, nullable: true, name: 'contribution_link_id' })
contributionLinkId: number
@Column({ unsigned: true, nullable: true, name: 'confirmed_by' })
confirmedBy: number
@Column({ nullable: true, name: 'confirmed_at' })
confirmedAt: Date
@Column({ unsigned: true, nullable: true, name: 'denied_by' })
deniedBy: number
@Column({ nullable: true, name: 'denied_at' })
deniedAt: Date
@Column({
name: 'contribution_type',
length: 12,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
contributionType: string
@Column({
name: 'contribution_status',
length: 12,
nullable: false,
collation: 'utf8mb4_unicode_ci',
})
contributionStatus: string
@Column({ unsigned: true, nullable: true, name: 'transaction_id' })
transactionId: number
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null
@DeleteDateColumn({ unsigned: true, nullable: true, name: 'deleted_by' })
deletedBy: number
@OneToMany(() => ContributionMessage, (message) => message.contribution)
@JoinColumn({ name: 'contribution_id' })
messages?: ContributionMessage[]
}

View File

@ -1 +1 @@
export { Contribution } from './0047-messages_tables/Contribution'
export { Contribution } from './0051-add_delete_by_to_contributions/Contribution'

View File

@ -1 +1 @@
export { EventProtocol } from './0043-add_event_protocol_table/EventProtocol'
export { EventProtocol } from './0050-add_messageId_to_event_protocol/EventProtocol'

View File

@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`ALTER TABLE \`event_protocol\` ADD COLUMN \`message_id\` int(10) unsigned NULL DEFAULT NULL;`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`ALTER TABLE \`event_protocol\` DROP COLUMN \`message_id\`;`)
}

View File

@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`ALTER TABLE \`contributions\` ADD COLUMN \`deleted_by\` int(10) unsigned DEFAULT NULL;`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`ALTER TABLE \`contributions\` DROP COLUMN \`deleted_by\`;`)
}

View File

@ -1,6 +1,6 @@
{
"name": "gradido-database",
"version": "1.12.1",
"version": "1.13.3",
"description": "Gradido Database Tool to execute database migrations",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/database",

View File

@ -2,6 +2,18 @@
Die Idee besteht darin, dass ein Administrator eine Contribution mit all seinen Attributen und Regeln im System erfasst. Dabei kann er unter anderem festlegen, ob für diese ein Link oder ein QR-Code generiert und über andere Medien wie Email oder Messenger versendet werden kann. Der Empfänger kann diesen Link bzw QR-Code dann über die Gradido-Anwendung einlösen und bekommt dann den Betrag der Contribution als Schöpfung auf seinem Konto gutgeschrieben.
## Ausbaustufen
Die beschriebenen Anforderungen werden in mehrere Ausbaustufen eingeteilt. Damit können nach und nach die Dialoge und Businesslogik schrittweise in verschiedene Releases gegossen und ausgeliefert werden.
### Ausbaustufe 1
Diese Ausbaustufe wird gezielt für die "Dokumenta" im Juni 2022 zusammengestellt. Details siehe weiter unten im speziellen Kapitel "Ausbaustufe 1".
### Ausbaustufe 2
Diese Ausbaustufe wird gezielt für die Anforderungen für das Medidationsportal von "Abraham" zusammegestellt. Details siehe weiter unten im speziellen Kapitel "Ausbaustufe 2".
## Logischer Ablauf
Der logische Ablauf für das Szenario "Activity-Confirmation and booking of Creations " wird in der nachfolgenden Grafik dargestellt. Dabei wird links das Szenario der "interactive Confirmation and booking of Creations" und rechts "automatic Confirmation and booking of Creations" dargestellt. Ziel dieser Grafik ist neben der logischen Ablaufsübersicht auch die Gemeinsamkeiten und Unterschiede der beiden Szenarien herauszuarbeiten.
@ -28,11 +40,11 @@ Der Gültigkeitsstart wird als Default mit dem aktuellen Erfassungszeitpunkt vor
Wie häufig ein User für diese Contribution eine Schöpfung gutgeschrieben bekommen kann, wird über die Auswahl eines Zyklus - stündlich, 2-stündlich, 4-stündlich, etc. - und innerhalb dieses Zyklus eine Anzahl an Wiederholungen definiert. Voreinstellung sind 1x täglich.
![Zyklus](./image/UC_Send_Contribution_Admin-new ContributionZyklus.png)
![img](./image/UC_Send_Contribution_Admin-new_ContributionZyklus.png)
Ob die Contribution über einen versendeten Link bzw. QR-Code geschöpft werden kann, wird mittels der Auswahl "Versenden möglich als" bestimmt.
![send](./image/UC_Send_Contribution_Admin-new ContributionSend.png)
![img](./image/UC_Send_Contribution_Admin-new_ContributionSend.png)
Für die Schöpfung der Contribution können weitere Regeln definiert werden:
@ -44,11 +56,11 @@ Für die Schöpfung der Contribution können weitere Regeln definiert werden:
![new](./image/UC_Send_Contribution_Admin-newContribution.png)
### Ausbaustufe-1:
## Ausbaustufe-1:
Die Ausbaustufe-1 wird gezielt auf die Anforderungen der "Dokumenta" im Juni 2022 abgestimmt.
Die Ausbaustufe-1 wird gezielt auf die Anforderungen der "Dokumenta" im Juni 2022 abgestimmt.
#### Contribution-Erfassungsdialog (Adminbereich)
### Contribution-Erfassungsdialog (Adminbereich)
Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gestellt:
@ -64,14 +76,12 @@ Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gest
| VersendenMöglich | - hier wird "als Link / QR-Code" voreingestellt |
| alle weiteren Attribute | - entfallen für diese Ausbaustufe<br />- die GUI-Komponenten können optional schon im Dialog eingebaut und angezeigt werden<br />- diese GUI-Komponenten müssen wenn sichtbar disabled sein und dürfen damit keine Eingaben entgegen nehmen |
#### Ablauflogik
### Ablauflogik
Für die Ausbaustufe-1 wird gemäß der Beschreibung aus dem Kapitel "Logischer Ablauf" nur die "automatic Confirmation and booking of Creations" umgesetzt. Die interaktive Variante - sprich Ablösung des EloPage Prozesses - mit "interactive Confirmation and booking of Creations" bleibt für eine spätere Ausbaustufe aussen vor.
Das Regelwerk in der Businesslogik wird gemäß der reduzierten Contribution-Attribute aus dem Erfassungsdialog, den vordefinierten Initialwerten und der daraus resultierenden Variantenvielfalt vereinfacht.
#### Kriterien "Dokumenta"
* Es soll eine "Dokumenta"-Contribution im Admin-Bereich erfassbar sein und in der Datenbank als ContributionLink gespeichert werden.
@ -91,6 +101,66 @@ Das Regelwerk in der Businesslogik wird gemäß der reduzierten Contribution-Att
* es erfolgt eine übliche Schöpfungstransaktion nach der Bestätigung der Contribution
* die Schöpfungstransaktion schreibt den Betrag der Contribution dem Kontostand des Users gut
## Ausbaustufe-2
Die Ausbaustufe-2 wird gezielt auf die Anforderungen zur Anbindung des Meditationsportals von Abraham im Oktober 2022 abgestimmt.
### Contribution-Erfassungsdialog (Adminbereich)
Es werden folgende Anforderungen an den Erfassungsdialog einer Contribution gestellt:
| Attribut | Beschreibung |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GültigBis | - das Datum, wie lange die Contribution gültig und damit einlösbar ist<br />- für diese Ausbaustufe soll ein offenes Ende möglich sein, daher bleibt dieses Attribut leer |
| Zyklus | - Angabe wie häufig eine Contribution gutgeschrieben werden kann<br />- als Auswahlliste (Combobox) geplant, aber für diese Ausbaustufe nur mit dem Wert "täglich" vorbelegt |
| Wiederholungen | - Anzahl an Wiederholungen pro Zyklus<br />- für diese Ausbaustufe wird der Wert "1" vorbelegt -> somit gilt: ein User kann diese Contribution nur 1x täglich einlösen |
| alle weiteren noch nicht vorhandenen Attribute | - entfallen für diese Ausbaustufe<br />- die GUI-Komponenten können optional schon im Dialog eingebaut und angezeigt werden<br />- diese GUI-Komponenten müssen wenn sichtbar disabled sein und dürfen damit keine Eingaben entgegen nehmen |
### Ablauflogik
Für die Ausbaustufe-2 und der inzwischen umgesetzten Ablösung des "EloPage Contribution Erfassungsprozesses" wird gemäß der Beschreibung aus dem Kapitel "Logischer Ablauf" die "automatic Confirmation and booking of Creations" sowie die interaktive Variante "interactive Confirmation and booking of Creations" mit berücksichtigt.
Das Regelwerk in der Businesslogik wird gemäß der noch nicht vollumfänglich geplanten Contribution-Attribute aus dem Erfassungsdialog, den vordefinierten Initialwerten und der daraus resultierenden Variantenvielfalt vereinfacht.
#### Kriterien "Meditationsportal (Abraham)"
* Es soll eine "GlobalMeditation"-Contribution nur im Admin-Bereich erfassbar sein und in der Datenbank als ContributionLink gespeichert werden.
* Es wird ein offenes Ende als Gesamtlaufzeit dieser Contribution benötigt, was durch ein leeres GültigBis-Datum ausgedrückt bzw. erfasst werden soll.
* Die "Meditationsportal"-Contribution kann von einem User maximal 1x täglich aktiviert werden. Dies wird über die Erfassung des Attributes "Zyklus" = täglich und des Attributes "Wiederholungen" = 1 ermöglicht.
* Ein User kann mit diesem Link nur die Menge an GDDs schöpfen, die in der Contribution als "Betrag" festgelegt ist
* Die "GlobalMeditation"-Contribution kann als Link / QR-Code erzeugt, angezeigt und in die Zwischenablage kopiert werden
* Jeder beliebige User kann den Link / QR-Code aktivieren
* der Link führt auf eine Gradido-Seite, wo der User sich anmelden oder registrieren kann
* mit erfolgreichem Login bzw. Registrierung wird der automatische Bestätigungs- und Schöpfungsprozess getriggert
* es erfolgt eine Überprüfung der definierten Contribution-Regeln für den angemeldeten User:
* Gültigkeit: liegt die Aktivierung im Gültigkeitszeitraum der Contribution
* Zyklus und WIederholungen: bei einem Zyklus-Wert = "täglich" und einem Wiederholungswert = 1 darf der User den Betrag dieser Contribution nur einmal am Tag schöpfen. Es gibt keine Überprüfung eines zeitlichen Mindestabstandes zwischen zwei Schöpfungen an zwei aufeinanderfolgenden Tagen.
* max. schöpfbarer Gradido-Betrag pro Monat: wenn der Betrag der Contribution plus der Betrag, den der User in diesem Monat schon geschöpft hat, den maximal schöpfbaren Betrag pro Monat von 1000 GDD übersteigt, dann wird die Schöpfung dieser Contribution abgelehnt
* mit erfolgreich durchlaufenen Regelprüfungen wird ein "besätigter" aber "noch nicht gebuchten" Eintrag in der "Contributions"-Tabelle erzeugt
* ein "bestätigter" aber "noch nicht gebuchter" "Contributions"-Eintrag stößt eine Schöpfungstransaktion für den User an
* es erfolgt eine übliche Schöpfungstransaktion mit automatischer Bestätigung der Contribution
* die Schöpfungstransaktion schreibt den Betrag der Contribution dem Kontostand des Users gut
## Ausbaustufe-3
### Änderungen im Registrierungsprozess
Aktuell treten Probleme mit der Aktivierung des ContributionLinks während des Registrierungsprozesses auf. Sobald der User bei der Registrierung sein Konto zwar angelegt, aber die erhaltene Email-Confirmation nicht abgeschlossen und damit sein Konto noch nicht aktiviert hat, kann derzeit der Redeem-Link nicht als Transaktion durchgeführt werden. Die Gültigkeitsdauer des Redeemlink reicht meist nicht bis der User sein Konto aktiviert. Daher wird nun die Idee verfolgt die Einlösung des Redeemlinks schon während der Anlage des inaktiven Kontos als "pendingRedeem Contribution" anzulegen. Sobald dann der User sein Konto per Email-Confirmation aktiviert, soll die "pendingRedeem Contribution" automatisch zu einer Tranaktion überführt und der Betrag des Redeemlinks auf das Konto des Users gebucht werden.
Folgende Schritte und Änderungen sind dabei vorgesehen (siehe in der Grafik rechts im orange markierten Bereich im Vergleich zur Grafik im Kapitel "Logischer Ablauf"):
![img](./image/Ablauf_manuelle_auto_Creations_2.png)
* Der User landet mit Aktivierung eines Redeem-Links wie bisher auf der Login/Registrierungsseite, wobei wie bisher schon der Redeemlink als Parameter in den Registrierungsprozess übergeben wird.
* Mit der Anlage des neuen aber noch inaktiven User-Kontos und einer Übergabe eines Redeemlinks wird der Redeemlink zu einer "pendingRedeem Contribution" für den neuen User angelegt, aber noch nicht als Transaktion gebucht
* nach Anlage des inaktiven User-Kontos und bevor die Confirmation-Email abgeschickt wird, erfolgt das Schreiben eines neuen Contribution-Eintrages mit den Daten des Redeem-Links.
* Die neu angelegte Contribution wird im Status "pendingRedeem" gespeichert. Dieser neue Status ist notwendig, um im AdminInterface die normalen "pending Contributions" von den "pendingRedeem Contributions" zu unterscheiden. Denn der Admin soll zum Einen diese "pendingRedeem Contributions" weder bestätigen noch ablehnen können und zum Anderen sollen die "pendingRedeem Contributions" automatisiert bestätigt und gebucht werden können. Daher wird eine Unterscheidung zwischen den interaktiv angelegten Contributions im Status pending und den per Redeem-Link angelegten Contributions im Status pending benötigt.
* Damit endet erst einmal die weitere Verarbeitung der Redeem-Link-Aktivierung
* Mit Aktivierung des Links in der Email-Confirmation und damit der Aktivierung des User-Kontos erfolgt automatisch die Buchung der "pendingRedeem Contribution" und führt damit zur eigentlichen Buchung des Redeem-Betrages auf das User Konto.
* mit Erhalt der Email-Confirmation Aktivierung wird das User-Konto aktiviert
* Nach der Aktivierung des User-Kontos erfolgt eine Prüfung auf schon vorhandene "pendingRedeem Contributions" aus vorherigen Redeem-Link-Aktivierungen
* Jede vorhandene "pendingRedeem Contribution" wird jetzt automatisch bestätigt und zu einer Transaktion überführt
* Mit der bestätigten Contribution und daraus überführten Transaktion erhält der User eine Bestätigungsemail mit den Contribution spezifischen Daten.
## Datenbank-Modell
@ -100,34 +170,36 @@ Das nachfolgende Bild zeigt das Datenmodell vor der Einführung und Migration au
![Datenbankmodell](./image/DB-Diagramm_20220518.png)
### Datenbank-Änderungen
### Ausbaustufe-1
#### Datenbank-Änderungen
Die Datenbank wird in ihrer vollständigen Ausprägung trotz Ausbaustufe-1 wie folgt beschrieben umgesetzt.
#### neue Tabellen
##### neue Tabellen
##### contribution_links - Tabelle
###### contribution_links - Tabelle
| Name | Typ | Nullable | Default | Kommentar |
| ------------------------------- | ------------ | :------: | :------------: | -------------------------------------------------------------------------------------------------------------------------------------- |
| id | INT UNSIGNED | NOT NULL | auto increment | PrimaryKey |
| name | varchar(100) | NOT NULL | | unique Name |
| description | varchar(255) | | | |
| name | varchar(100) | NOT NULL | | unique Name |
| description | varchar(255) | | | |
| valid_from | DATETIME | NOT NULL | NOW | |
| valid_to | DATETIME | | NULL | |
| amount | DECIMAL | NOT NULL | | |
| valid_to | DATETIME | | NULL | |
| amount | DECIMAL | NOT NULL | | |
| cycle | ENUM | NOT NULL | ONCE | ONCE, HOUR, 2HOUR, 4HOUR, 8HOUR, HALFDAY, DAY, 2DAYS, 3DAYS, 4DAYS, 5DAYS, 6DAYS, WEEK, 2WEEKS, MONTH, 2MONTH, QUARTER, HALFYEAR, YEAR |
| max_per_cycle | INT UNSIGNED | NOT NULL | 1 | |
| max_amount_per_month | DECIMAL | | NULL | |
| total_max_count_of_contribution | INT UNSIGNED | | NULL | |
| max_account_balance | DECIMAL | | NULL | |
| min_gap_hours | INT UNSIGNED | | NULL | |
| created_at | DATETIME | | NOW | |
| deleted_at | DATETIMEBOOL | | NULL | |
| code | varchar(24) | | NULL | |
| link_enabled | BOOL | | NULL | |
| max_amount_per_month | DECIMAL | | NULL | |
| total_max_count_of_contribution | INT UNSIGNED | | NULL | |
| max_account_balance | DECIMAL | | NULL | |
| min_gap_hours | INT UNSIGNED | | NULL | |
| created_at | DATETIME | | NOW | |
| deleted_at | DATETIMEBOOL | | NULL | |
| code | varchar(24) | | NULL | |
| link_enabled | BOOL | | NULL | |
##### contributions -Tabelle
###### contributions -Tabelle
| Name | Typ | Nullable | Default | Kommentar |
| --------------------- | ------------ | -------- | -------------- | -------------------------------------------------------------------------------- |
@ -145,9 +217,9 @@ Die Datenbank wird in ihrer vollständigen Ausprägung trotz Ausbaustufe-1 wie f
| booked_at | DATETIME | | NULL | date, when the system has booked the amount of the activity on the users account |
| deleted_at | DATETIME | | NULL | soft delete |
#### zu migrierende Tabellen
##### zu migrierende Tabellen
##### Tabelle admin_pending_creations
###### Tabelle admin_pending_creations
Diese Tabelle wird im Rahmen dieses UseCase migriert in die neue Tabelle contributions...
@ -168,6 +240,18 @@ Diese Tabelle wird im Rahmen dieses UseCase migriert in die neue Tabelle contrib
...und kann nach Übernahme der Daten in die neue Tabelle gelöscht werden oder es erfolgen die Änderungen sofort auf der Ursprungstabelle.
### Zielmodell
#### Zielmodell
![Contributions-DB](./image/DB-Diagramm_Contributions.png)
### Ausbaustufe-2
Für die Ausbaustufe-2 sind keine Datenbank-Änderungen notwendig. Gemäß dem Zielmodell sind alle notwendigen Tabellen und Attribute schon vorhanden.
#### Zielmodell
![img](./image/DB-Diagramm_Contributions_Stufe_2.png)
### Ausbaustufe-3
Für die Ausbaustufe-3 dürften im Grunde ebenfalls keine zusätzlichen Datenbankänderungen notwendig sein. Denn für eine "pending Contribution" und deren Confirmation mit Tranaktionsüberführng sind ebenfalls schon alle Attribute vorhanden.

View File

@ -0,0 +1,198 @@
# User Alias
Mit dem *Alias* für ein User wird zusätzlich ein eindeutiger menschenlesbarer Identifier neben der GradidoID als technischer Identifier eingeführt. Mit beiden Identifiern kann ein User eindeutig identifziert werden und beide können als Key nach aussen gegeben bzw. für Schnittstellen als Eingabe-Parameter in die Anwendung verwendet werden.
Ziel dieser beiden Identifier ist von dem bisherig verwendeten Email-Identifier wegzukommen. Denn eine Email-Adresse hat für einen User einen anderen schützenswerten Privatsphären-Level als ein *Alias* oder die *GradidoID*, die beide rein auf die Gradido-Anwendung begrenzt sind. Über Email-Adressen wird gerne eine Social Engineering betrieben, um über einen User ein Profil aus den Social-Media-Netzwerken zu erstellen. Dies gilt es so weit wie möglich zu verhindern bzw. Gradido aus diesem Kreis der möglichen Datenquellen für Ausspähungen von Privatdaten herauszuhalten.
## Identifizierung eines Users
In der Gradido-Anwendung muss ein User eindeutig identifzierbar sein. Zur Identifikation eines Users gibt es unterschiedliche Anforderungen:
* eindeutiger Schlüsselwert für einen User
* leicht für den Anwender zu merken
* einfache maschinelle Verarbeitung im System
* leichte Weitergabe des Schlüsselwertes ausserhalb des Systems
* u.a.
### Schlüsselwerte
Die hier aufgeführten Schlüsselwerte dienen in der Gradido-Anwendung zur eindeutigen Identifzierung eines Users:
#### UserID
Dies ist ein rein technischer Key und wird nur **innerhalb** der Anwendung zur Identifikation eines Users verwendet. Dieser Key wird niemals nach aussen gereicht und auch niemals zwischen mehreren Communities als Schlüsselwert eingesetzt oder ausgetauscht. Die UserID wird innerhalb des Systems bei der Registrierung mit dem Speichern eines neuen Users in der Datenbank erzeugt. Die Eindeutigkeit der UserID ist damit nur innerhalb dieser einen Datenbank der Gradido-Community sichergestellt.
#### GradidoID
Die GradidoID ist zwar auch ein rein technischer Key, doch wird dieser als eine UUID der Version 4 erstellt. Dies basiert auf einer (pseudo)zufällig generierten Zahl aus 16 Bytes mit einer theoretischen Konfliktfreiheit von ![2^{{122}}\approx 5{,}3169\cdot 10^{{36}}](https://wikimedia.org/api/rest_v1/media/math/render/svg/1924927d783e2d3969734633e134f643b6f9a8cd) in hexadezimaler Notation nach einem Pattern von fünf Gruppen durch Bindestrich getrennt - z.B. `550e8400-e29b-41d4-a716-446655440000`
Somit kann die GradidoID auch System übergreifend zwischen Communities ausgetauscht werden und bietet dennoch eine weitestgehende eindeutige theoretisch konfliktfreie Identifikation des Users. System intern ist die Eindeutigkeit bei der Erstellung eines neuen Users auf jedenfall sichergestellt. Sollte ein User den Wechsel von einer Community in eine andere gradido-Community wünschen, so soll falls möglich die GradidoID für den User erhalten bleiben und übernommen werden können. Dies muss beim Umzug in der Ziel-Community geprüft werden. Falls diese GradidoID aus der Quell-Community wider erwarten existieren sollte, dann muss doch einen neue GradidoID für den User erzeugt werden.
#### Alias
Der Alias eines Users ist als rein fachlicher Key ausgelegt, der frei vom User definiert werden kann. Bei der Definition dieses frei definierbaren und menschenlesbaren Schlüsselwertes stellt die Gradido-Anwendung sicher, dass der vom User eingegebene Wert nicht schon von einem anderen User dieser Community verwendet wird. Für die Anlage eines Alias gelten folgende Konventionen:
- mindestens 5 Zeichen
* alphanumerisch
* keine Umlaute
* nach folgender Regel erlaubt (RegEx: [a-zA-Z0-9]-|_[a-zA-Z0-9])
- Blacklist für Schlüsselworte, die frei definiert werden können
- vordefinierte/reservierte System relevante Namen dürfen maximal aus 4 Zeichen bestehen
#### Email
Die Email eines Users als fachlicher Key bleibt zwar weiterhin bestehen, doch wird diese schrittweise durch die GradidoID und den Alias in den verschiedenen Anwendungsfällen des Gradido-Systems ersetzt. Das bedeutet zum Beispiel, dass die bisher alleinige Verwendung der Email für die Registrierung bzw. den Login nun und durch die GradidoID bzw. den Alias ergänzt wird.
Die Email wird weiterhin als Kommunikationskanal ausserhalb der Gradido-Anwendung mit dem User benötigt. Es soll aber zukünftig möglich sein, dass ein User ggf. mehrere Email-Adressen für unterschiedliche fachliche Kommunikationskanäle angeben kann. Eine dieser Email-Adressen muss aber als primäre Email-Adresse gekennzeichnet sein, da diese wie bisher auch als Identifier beim Login bzw. der Registrierung erhalten bleiben soll.
## Erfassung des Alias
Die Erfassung des Alias erfolgt als zusätzliche Eingabe direkt bei der Registrierung eines neuen Users oder als weiterer Schritt direkt nach dem Login.
Dieser UseCase ist in die **Ausbaustufe-1** und **Ausbaustufe-x** unterteilt.
Alle beschriebenen Anforderungen der **Ausbaustufe-1** können mit Produktivsetzung des Issues #1798 - [GradidoID 1: adapt and migrate database schema](https://github.com/gradido/gradido/issues/1798) und dem [PR #2058 - GradidoID 1: adapt and migrate database schema](https://github.com/gradido/gradido/pull/2058) umgesetzt werden.
Die beschriebenen Anforderungen der Ausbaustufe-x müssen solange verschoben werden, bis der UseCase "GradidoID 2: changeRegisterLoginProzess" konzeptioniert und umgesetzt ist. Erst dann kann auf der Profil-Seite die Email zur Bearbeitung durch den User freigegeben werden.
### Registrierung
#### Ausbaustufe-1
In der Eingabemaske der Registrierung wird nun zusätzlich das Feld *Alias* angezeigt, das der User als Pflichtfeld ausfüllen muss.
![img](./image/RegisterWithAlias.png)
Mit dem (optionalen ?) Button "Eindeutigkeit prüfen" wird dem User die Möglichkeit gegeben vorab die Eindeutigkeit seiner *Alias*-Eingabe zu verifizieren ohne den Dialog über den "Registrieren"-Button zu verlassen. Denn es muss sichergestellt sein, dass noch kein existierender User der Community genau diesen *Alias* evtl. schon verwendet.
Wird diese Prüfung vom User nicht ausgeführt bevor er den Dialog mit dem "Registrieren"-Button abschließt, so erfolgt die *Alias*-Eindeutigkeitsprüfung als erster Schritt bevor die anderen Eingaben als neuer User geprüft und angelegt werden.
Wird bei der Eindeutigkeitsprüfung des *Alias* festgestellt, dass es schon einen exitierenden User mit dem gleichen *Alias* gibt, dann wird wieder zurück in den Registrierungsdialog gesprungen, damit der User seine *Alias*-Eingabe korrigieren kann. Das *Alias*-Feld wird als fehlerhaft optisch markiert und mit einer aussagekräftigen Fehlermeldung dem User der *Alias*-Konflikt mitgeteilt. Dabei bleiben alle vorher eingegebenen Daten in den Eingabefeldern erhalten und es muss nur der *Alias* geändert werden.
Wurde vom User nun eine konfliktfreie *Alias*-Eingabe und alle Angaben der Registrierung ordnungsgemäß ausgefüllt, so kann der Registrierungsprozess wie bisher ausgeführt werden. Einziger Unterschied ist der zusätzliche *Alias*-Parameter, der nun an das Backend zur Erzeugung des Users übergeben und dann in der Users-Tabelle gespeichert wird.
Falls über ein Redeem-Link in den Registrierungsdialog eingestiegen wurde, so bleiben die existierenden Schritte zur Redeem-Link-Verarbeitung durch die *Alias*-Eingabe erhalten und werden unverändert durchlaufen.
### Login
#### Ausbaustufe-1
Meldet sich ein schon registrierter User erfolgreich an - das passiert wie bisher noch mit seiner Email-Adresse - dann wird geprüft, ob für diesen User schon ein *Alias* gespeichert, sprich im aktuellen Context nach dem Login im User-Objekt das Attribut *alias* initialisiert ist. Wenn nicht dann erfolgt direkt nach dem Schließen des Login-Dialoges die Anzeige der User-Profilseite.
![img](./image/LoginProfileWithAlias.png)
Auf der erweiterten User-Profil-Seite sind folgende Elemente neu hinzugekommen bzw. erweitert:
* *Alias*: **neu hinzugekommen** ist die Gruppe Alias mit dem Label, dem Eingabefeld und dem Link "Alias ändern" mit Stift-Icon
* E-Mail: **ergänzt** wurde die Gruppe E-Mail, in dem das Label "E-Mail", das Eingabefeld, das Label "bestätigt" mit zugehörigem Icon darunter und dem Link "E-Mail ändern" mit Stift-Icon. In der **Ausbaustufe-1** ist der Link "E-Mail ändern" und das Stift-Icon **immer disabled**, so dass das Eingabefeld der E-Mail lediglich zur Anzeige der aktuell gesetzten E-Mail-Addresse dient. Da mit der Änderungsmöglichkeit der E-Mail gleichzeitig auch der Login-Prozess und die Passwort-Verschlüsselung angepasst werden muss, wird dieses Feature auf eine spätere Ausbaustufe verschoben. **Neu hinzugekommen** ist die Status-Anzeige der Email-Bestätigung ausgedrückt durch das Häckchen-Icon, wenn die Email-Adresse durch die Email-Confirmation-Mail vom User schon bestätigt ist und durch ein Kreuz-Icon, wenn die Email-Adress-Bestätigung noch aussteht.
Der Sprung nach der Login-Seite nach erfolgreichem Login auf die Profil-Seite öffnet diese schon direkt im Bearbeitungs-Modus des Alias, so dass der User direkt seine Eingabe des Alias vornehmen kann.
![img](./image/LoginProfileEditAlias.png)
Im Eingabe-Modus der Alias-Gruppe hat das Eingabefeld den Fokus und darin wird:
* wenn noch kein Alias für den User in der Datenbank vorhanden ist, vom System ein Vorschlag unterbreitet. Der Vorschlag basiert auf dem Vornamen des Users und wird durch folgende Logik ermittelt:
* es wird mit dem Vorname des Users eine Datenbankabfrage durchgeführt, die zählt, wieviele User-Aliase es schon mit diesem Vornamen gibt und falls notwendig direkt mit einer nachfolgenden Nummer als Postfix versehen sind.
* Aufgrund der Konvention, dass ein Alias mindestens 5 Zeichen lang sein muss, sind ggf. führende Nullen mitzuberücksichten.
* **Beispiel-1**: *Max* als Vorname
* in der Datenbank gibt es schon mehrer User mit den Aliasen: *Maximilian*, *Max01*, *Max_M*, *Max-M*, *MaxMu* und *Max02*.
* Dann schlägt das System den Alias *Max03* vor, da *Max* nur 3 Zeichen lang ist und es schon zwei Aliase *Max* gefolgt mit einer Nummer gibt (*Max01* und *Max02*)
* Die Aliase *Maximilian*, *Max_M*, *Max-M* und *MaxMu* werden nicht mitgezählt, das diese nach *Max* keine direkt folgende Ziffern haben
* **Beispiel-2**: *August* als Vorname
* in der Datenbank gibt es schon mehrer User mit den Aliasen: *Augusta*, *Augustus*, *Augustinus*
* Dann schlägt das System den Alias *August* vor, da *August* schon 6 Zeichen lang ist und es noch keinen anderen User mit Alias *August* gibt
* die Aliase *Augusta*, *Augustus* und *Augustinus* werden nicht mit gezählt, da diese länger als 5 Zeichen sind und sich von *August* unterscheiden
* **Beispiel-3**: *Nick* als Vorname
* in der Datenbank gibt es schon mehrer User mit den Aliasen: *Nicko*, *Nickodemus*
* Dann schlägt das System den Alias *Nick1* vor, da *Nick* kürzer als 5 Zeichen ist und es noch keinen anderen User mit dem Alias *Nick1* gibt
* die Aliase *Nicko* und *Nickodemus* werden nicht mit gezählt, da diese länger als 5 Zeichen sind und sich von *Nick* unterscheiden
* wenn schon ein Alias für den User in der Datenbank vorhanden ist, dann wird dieser unverändert aus der Datenbank und ohne Systemvorschlag einfach angezeigt.
Der User kann nun den im Eingabefeld angezeigten Alias verändern, wobei die Alias-Konventionen, wie oben im ersten Kapitel beschrieben einzuhalten und zu validieren sind.
Mit dem Button "Eindeutigkeit prüfen" kann der im Eingabefeld stehende *Alias* auf Eindeutigkeit verifziert werden. Dabei wird dieser als Parameter einem Datenbank-Statement übergeben, das auf das Feld *Alias* in der *Users*-Tabelle ein Count mit dem übergebenen Parameter durchführt. Kommt als Ergebnis =0 zurück, ist der eingegebene *Alias* noch nicht vorhanden und kann genutzt werden. Liefert das Count-Statement einen Wert >0, dann ist dieser *Alias* schon von einem anderen User in Gebrauch und darf nicht gespeichert werden. Der User muss also seinen *Alias* erneut ändern.
Mit dem "Speichern"-Button wird die Eindeutigkeitsprüfung erneut implizit durchgeführt, um sicherzustellen, dass keine *Alias*-Konflikte in der Datenbank gespeichert werden. Sollte wider erwarten doch ein Konflikt bei der Eindeutigkeitsprüfung auftauchen, so bleibt der Dialog im Eingabe-Modus des *Alias* geöffnet und zeigt dem User eine aussagekräftige Fehlermeldung an.
Über das rote Icon (x) hinter dem Label "Alias ändern" kann die Eingabe bzw. das Ändern des Alias abgebrochen werden.
Die erweiterte Gruppe E-Mail bleibt immer im Anzeige-Modus und kann selbst über den Link "E-Mail ändern" und das Stift-Icon, die beide disabled sind, nicht in den Bearbeitungsmodus versetzt werden. Die aktuell gesetzte E-Mail des Users wird im disabled Eingabefeld nur angezeigt. Das Icon unter dem Label "bestätigt" zeigt den Status der E-Mail, ob diese schon vom User bestätigt wurde oder nicht. Der Schalter für das Label "Informationen per E-Mail" bleibt von dem Switch zwischen Anzeige-Modus und Bearbeitungs-Modus unberührt, dh. es kann zu jeder Zeit vom User definiert werden, ob er über die gesetzte E-Mail Informationen erhält oder nicht.
Es gibt in dieser Ausbaustufe-1 noch keine Möglichkeit seine E-Mail-Adresse zu ändern, da vorher die Passwort-Verschlüsselung mit allen Auswirkungen auf den Registrierungs- und Login-Prozess umgebaut werden müssen.
#### Ausbaustufe-x
In der weiteren Ausbaustufe, die erst möglich ist, sobald der Login-Prozess und die Passwort-Verschlüsselung darauf umgestellt ist, wird der Link "E-Mail ändern" und das Sift-Icon enabled. Damit kann der User dann das E-Mail Eingabefeld in den Bearbeitungs-Modus versetzen.
![img](./image/LoginProfileEditEmail.png)
Im Eingabe-Modus des E-Mail-Feldes kann der User seine E-Mail-Adresse ändern. Sobald der User die vorhandene und schon bestätigte Email-Adresse ändert, wechselt die Anzeige des Icons unter dem Label "bestätigt" vom Icon (Hacken) zum Icon (X), um die Änderung dem User gleich sichtbar zu machen. Über den Button "Speichern & Bestätigen" wird die veränderte E-Mail gegenüber den bisher gespeicherten E-Mails aller User verifiziert, dass es keine Dupletten gibt.
Ist diese Eindeutigkeits-Prüfung erfolgreich, dann wird die geänderte E-Mail-Adresse in der Datenbank gespeichert, das Flag E-Mail-Checked auf FALSE gesetzt, damit das Bestätigt-Icon von "bestätigt" auf "unbestätigt" dem User angezeigt wird und zurück in den Anzeige-Modus der Gruppe E-Mail gewechselt. Mit der Speicherung der geänderten E-Mail wird eine Comfirmation-Email an diese E-Mail-Adresse zur Bestätigung durch den User gesendet.
Ist diese Prüfung fehlgeschlagen, sprich es gibt die zuspeichernde E-Mail-Adresse schon in der Datenbank, dann wird das Speichern der geänderten E-Mail abgebrochen und es bleibt die zuvor gespeicherte E-Mail gültig und auch das E-Mail-Checked Flag bleibt auf dem vorherigen Status. Ob und welche Meldung dem User in dieser Situation angezeigt wird, ist noch zu definieren, um kein Ausspionieren von anderen E-Mail-Adressen zu unterstützen. Ebenfalls noch offen ist, ob an die gefundene E-Mail-Duplette eine Info-Email geschickt wird, um den User, der diese bestätigte E-Mail-Adresse besitzt, zu informieren, dass es einen Versuch gab seine E-Mail zu verwenden.
## Backend-Services
### UserResolver.createUser - erweitern
#### Ausbaustufe-1
Der Service *createUser* wird um den Pflicht-Parameter *alias: String* erweitert. Der Wert wurde, wie oben beschrieben, im Dialog Register erfasst und gemäß den Konventionen für das Feld *alias* auch validiert - Länge und erlaubte Zeichen.
Es wird vor jeder anderen Aktion die Eindeutigkeitsprüfung des übergebenen alias-Wertes geprüft. Dazu wird der neue Service verifyUniqueAlias() im UserResolver aufgerufen, der auch direkt vom Frontend aufgerufen werden kann.
Liefert diese Prüfung den Wert FALSE, dann wird das Anlegen und Speichern des neuen Users abgebrochen und mit entsprechend aussagekräftiger Fehlermeldung, dass der Alias nicht eindeutig ist, an das Frontend zurückgegeben.
Ist die Eindeutigkeitsprüfung hingegen erfolgreich, dann wird die existierende Logik zur Anlage eines neuen Users weiter ausgeführt. Dabei ist der neue Parameter *alias* in den neu angelegten User zu übertragen und in der Datenbank zu speichern.
Alle weiteren Ausgabe-Kanäle wie Logging, EventProtokoll und Emails sind entsprechend einzubauen, aber mindestens um das neue Attribut *alias* zu ergänzen.
### UserResolver.verifyUniqueAlias - neu
#### Ausbaustufe-1
Dieser neue Service bekommt als Parameter das Attribut *alias: String* übergeben und liefert im Ergebnis TRUE, wenn der übergebene *alias* noch nicht in der Datenbank von einem anderen User verwendet wird, andernfalls FALSE.
Dabei wird ein einfaches Datenbank-Statement auf die *Users* Tabelle abgesetzt mit einem casesensitiven Vergleich auf den Parameter mit den Werten aus der Spalte *alias*
`SELECT count(*) FROM users where BINARY users.alias = {alias}`
### UserResolver.updateUserInfos - erweitern
#### Ausbaustufe-1
Der schon existierende Service *updateUserInfos()* wird erweitert um den Parameter *alias: String*. Sobald der User nach dem Login automatisch oder selbst interaktiv auf die Profil-Seite navigiert und dort sein Profil, insbesonderen neu das Attribut *alias* erfasst oder ändert, wird dieser Service aufgerufen.
Die Parameter *firstName, lastName, language, password, passwordNew, alias* werden alle als optional definiert, da der User auf der Profil-Seite auswählen kann, welche Profil-Parameter er verändern möchte und somit meist nie alle Parameter gleichzeitig dieses Service initialisiert sind.
Sobald der *alias*-Parameter gesetzt ist, wird für diesen der Service *verifyUniqueAlias()* zur Eindeutigekeitsprüfung aufgerufen, um sicherzustellen, dass der übergebene *alias* wirklich nicht schon in der Datenbank existiert. Liefert das Ergebnis von *verifyUniqueAlias()* den Wert TRUE, dann kann der übergebene *alias* in der Datenbank gespeichert werden. Anderfalls muss mit einer aussagekräftigen Fehlermeldung abgebrochen werden und es wird keiner der übergebenen Parameter in die Datenbank geschrieben.
Alle weiteren Ausgabe-Kanäle wie Logging, EventProtokoll sind entsprechend einzubauen, aber mindestens um das neue Attribut *alias* zu ergänzen.
#### Ausbaustufe-x
Sobald in einer weiteren Ausbaustufe die Email auf der Profil-Seite vom User verändert werden kann, dann wird dieser Service um den optionalen Parameter *email: String* erweitert.
Sobald der *email*-Parameter gesetzt ist, wird für diesen der Service *verifyUniqueEmail()* zur Eindeutigekeitsprüfung aufgerufen, um sicherzustellen, dass die übergebene *email* wirklich nicht schon für einen anderen User in der Datenbank existiert. Liefert das Ergebnis von *verifyUniqueEmail()* den Wert TRUE, dann kann die übergebene *email* in der Datenbank gespeichert werden. Anderfalls muss mit einer aussagekräftigen Fehlermeldung abgebrochen werden und es wird keiner der übergebenen Parameter in die Datenbank geschrieben.
Mit dem Speichern der geänderten Email muss auch das Flag *emailChecked* auf FALSE gesetzt und gespeichert werden. Damit wird sichergestellt, dass die veränderte Email-Adresse erst noch vom User bestätigt werden muss. Dies wird direkt nach dem Speichern der Email-Adresse mit dem Versenden einer *confirmChangedEmail* an die neue Email-Adresse initiiert. Der darin enthaltene Bestätigungs-Link wird analog dem Aktivierungs-Link bei der Registrierung der Email gehandhabt. Die *confirmChangedEmail* muss nur inhaltlich vom Text anders formuliert werden als die *AccountActivation*-Email, aber bzgl. der Parameter und des enthaltenen Bestätigungslinks unterscheiden sich beide nicht.
Sobald der User in seiner erhaltenen *confirmChangedEmail* den Link aktiviert, erfolgt der Aufruf des Service *UserResolver.queryOptIn*, um zu prüfen, ob der in dem Link enthaltene OptInCode valide und gültig ist. Falls ja, dann wird das Flag *emailChecked* auf TRUE gesetzt, anderfalls bleibt es auf FALSE und es wird mit einer aussagekräftigen Fehlermeldung abgebrochen.
Alle weiteren Ausgabe-Kanäle wie Logging, EventProtokoll sind entsprechend einzubauen, aber mindestens um das neue Attribut *email* zu ergänzen.
### UserResolver.verifyUniqueEmail - neu
#### Ausbaustufe-x
Dieser neue Service bekommt als Parameter das Attribut *email: String* übergeben und liefert im Ergebnis TRUE, wenn die übergebene *email* noch nicht in der Datenbank von einem anderen User verwendet wird, andernfalls FALSE.
Dabei wird ein einfaches Datenbank-Statement auf die *Users* Tabelle abgesetzt mit einem casesensitiven Vergleich auf den Parameter mit den Werten aus der Spalte *email*
`SELECT count(*) FROM users where BINARY users.email = {email}`
## Datenbank-Migration
Es ist für diesen UseCase keine Datenbank-Migration notwendig, da im Rahmen der Einführung der GradidoID die Spalte *alias* schon in die *Users*-Tabelle mit aufgenommen wurde.

View File

@ -1,6 +1,6 @@
<mxfile host="65bd71144e">
<diagram id="-Bvenr9G4hMm7q4_ZwMA" name="Seite-1">
<mxGraphModel dx="3755" dy="1067" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2336" pageHeight="1654" math="0" shadow="0">
<mxGraphModel dx="3699" dy="1067" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2336" pageHeight="1654" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
@ -183,31 +183,31 @@
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="63" value="contribution_links&lt;br style=&quot;font-size: 24px&quot;&gt;&lt;span style=&quot;font-size: 20px&quot;&gt;id = X&lt;br&gt;code = X-link&lt;br&gt;&lt;/span&gt;" style="shape=process;whiteSpace=wrap;html=1;backgroundOutline=1;fontSize=24;size=0.05263157894736842;" parent="1" vertex="1">
<mxGeometry x="1560" y="670" width="380" height="100" as="geometry"/>
<mxGeometry x="1560" y="592.5" width="380" height="85" as="geometry"/>
</mxCell>
<mxCell id="121" value="" style="edgeStyle=none;html=1;fontSize=16;" parent="1" source="65" target="71" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="65" value="users&lt;br style=&quot;font-size: 24px&quot;&gt;&lt;font style=&quot;font-size: 20px&quot;&gt;ID=Y&lt;/font&gt;" style="shape=process;whiteSpace=wrap;html=1;backgroundOutline=1;fontSize=24;size=0.05263157894736842;" parent="1" vertex="1">
<mxGeometry x="1990" y="930" width="170" height="60" as="geometry"/>
<mxGeometry x="1990" y="925" width="170" height="60" as="geometry"/>
</mxCell>
<mxCell id="128" value="" style="edgeStyle=none;html=1;fontSize=20;strokeWidth=1;" parent="1" source="67" target="127" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="67" value="lese Contribution zu aktiviertem Link" style="rounded=1;whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;" parent="1" vertex="1">
<mxGeometry x="1250" y="690" width="240" height="60" as="geometry"/>
<mxGeometry x="1250" y="605" width="240" height="60" as="geometry"/>
</mxCell>
<mxCell id="120" value="" style="edgeStyle=none;html=1;fontSize=16;" parent="1" source="69" target="71" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="122" value="" style="edgeStyle=none;html=1;fontSize=16;" parent="1" source="69" target="79" edge="1">
<mxCell id="122" value="" style="edgeStyle=none;html=1;fontSize=16;entryX=0.25;entryY=0;entryDx=0;entryDy=0;exitX=0.417;exitY=0.991;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="69" target="79" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="69" value="erzeuge aus ContributionLink zu angemeldetem User eine bestätigte Contribution" style="rounded=1;whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;" parent="1" vertex="1">
<mxGeometry x="1250" y="907.5" width="240" height="110" as="geometry"/>
<mxGeometry x="1210" y="900" width="240" height="110" as="geometry"/>
</mxCell>
<mxCell id="71" value="contributions&lt;br style=&quot;font-size: 24px&quot;&gt;&lt;font style=&quot;font-size: 20px&quot;&gt;confirmed_at = NOW, contribution_links_id=X, user_id=Y&lt;/font&gt;" style="shape=process;whiteSpace=wrap;html=1;backgroundOutline=1;fontSize=24;size=0.05263157894736842;" parent="1" vertex="1">
<mxGeometry x="1560" y="917.5" width="380" height="90" as="geometry"/>
<mxGeometry x="1560" y="910" width="380" height="90" as="geometry"/>
</mxCell>
<mxCell id="72" value="" style="edgeStyle=none;html=1;fontSize=24;" parent="1" edge="1">
<mxGeometry relative="1" as="geometry">
@ -317,15 +317,15 @@
</mxGeometry>
</mxCell>
<mxCell id="109" value="veröffentlichter &lt;br&gt;Link / QR-Code für&lt;br&gt;eine Contribution" style="ellipse;whiteSpace=wrap;html=1;fontSize=20;rounded=1;fillColor=#d0cee2;strokeColor=#56517e;" parent="1" vertex="1">
<mxGeometry x="2010" y="410" width="310" height="90" as="geometry"/>
<mxGeometry x="2020" y="310" width="310" height="90" as="geometry"/>
</mxCell>
<mxCell id="118" value="" style="edgeStyle=none;html=1;fontSize=16;" parent="1" target="113" edge="1">
<mxCell id="118" value="" style="edgeStyle=none;html=1;fontSize=16;exitX=0.5;exitY=1;exitDx=0;exitDy=0;" parent="1" target="113" edge="1" source="111">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1370" y="570" as="sourcePoint"/>
</mxGeometry>
</mxCell>
<mxCell id="111" value="User aktiviert &lt;br&gt;Link / QR-Code" style="rounded=1;whiteSpace=wrap;html=1;fontSize=20;fillColor=#dae8fc;strokeColor=#6c8ebf;" parent="1" vertex="1">
<mxGeometry x="1250" y="510" width="240" height="50" as="geometry"/>
<mxGeometry x="1250" y="450" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="115" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" parent="1" source="113" target="114" edge="1">
<mxGeometry relative="1" as="geometry"/>
@ -334,10 +334,10 @@
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="113" value="User führt &lt;br&gt;Login / Register aus" style="rounded=1;whiteSpace=wrap;html=1;fontSize=20;fillColor=#dae8fc;strokeColor=#6c8ebf;" parent="1" vertex="1">
<mxGeometry x="1250" y="597.5" width="240" height="50" as="geometry"/>
<mxGeometry x="1250" y="530" width="240" height="50" as="geometry"/>
</mxCell>
<mxCell id="114" value="users" style="shape=process;whiteSpace=wrap;html=1;backgroundOutline=1;fontSize=24;size=0.05263157894736842;" parent="1" vertex="1">
<mxGeometry x="1990" y="592.5" width="170" height="60" as="geometry"/>
<mxGeometry x="1990" y="525" width="170" height="60" as="geometry"/>
</mxCell>
<mxCell id="123" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" parent="1" source="124" target="125" edge="1">
<mxGeometry relative="1" as="geometry"/>
@ -351,7 +351,7 @@
<mxCell id="125" value="users" style="shape=process;whiteSpace=wrap;html=1;backgroundOutline=1;fontSize=24;size=0.05263157894736842;" parent="1" vertex="1">
<mxGeometry x="880" y="592.5" width="170" height="60" as="geometry"/>
</mxCell>
<mxCell id="129" value="" style="edgeStyle=none;html=1;fontSize=20;strokeWidth=1;" parent="1" source="127" target="69" edge="1">
<mxCell id="129" value="" style="edgeStyle=none;html=1;fontSize=20;strokeWidth=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="127" target="131" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="130" value="Ja" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=20;" parent="129" vertex="1" connectable="0">
@ -359,8 +359,76 @@
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="127" value="Contribution &lt;br&gt;und Regel &lt;br&gt;valide?" style="rhombus;whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;rounded=1;" parent="1" vertex="1">
<mxGeometry x="1250" y="770" width="240" height="100" as="geometry"/>
<mxCell id="127" value="Contribution &lt;br&gt;und Regel valide?&lt;br&gt;" style="rhombus;whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;rounded=1;" parent="1" vertex="1">
<mxGeometry x="1250" y="685" width="240" height="75" as="geometry"/>
</mxCell>
<mxCell id="132" style="edgeStyle=none;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" edge="1" parent="1" source="131" target="69">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="1660" y="780"/>
<mxPoint x="1330" y="780"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="134" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="131" target="133">
<mxGeometry relative="1" as="geometry">
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="135" value="Nein" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];fontSize=16;" vertex="1" connectable="0" parent="134">
<mxGeometry x="-0.4968" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="131" value="user Konto&lt;br&gt;active?" style="rhombus;whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;rounded=1;" vertex="1" parent="1">
<mxGeometry x="1540" y="685" width="240" height="75" as="geometry"/>
</mxCell>
<mxCell id="137" value="" style="edgeStyle=none;html=1;fontSize=16;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="133" target="138">
<mxGeometry relative="1" as="geometry">
<mxPoint x="2210" y="722.5" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="133" value="erzeuge aus ContributionLink zu angemeldetem User eine pending Contribution" style="rounded=1;whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="1830" y="685" width="300" height="75" as="geometry"/>
</mxCell>
<mxCell id="138" value="Ende Redeem-Aktivierung" style="ellipse;whiteSpace=wrap;html=1;fontSize=20;rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf;gradientColor=#7ea6e0;" vertex="1" parent="1">
<mxGeometry x="2170" y="677.5" width="110" height="90" as="geometry"/>
</mxCell>
<mxCell id="141" style="edgeStyle=none;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="139" target="140">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="139" value="Start Konto-&lt;br&gt;bestätigung" style="ellipse;whiteSpace=wrap;html=1;fontSize=20;rounded=1;fillColor=#f5f5f5;strokeColor=#666666;gradientColor=#b3b3b3;" vertex="1" parent="1">
<mxGeometry x="2170" y="805" width="110" height="90" as="geometry"/>
</mxCell>
<mxCell id="143" style="edgeStyle=none;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="140" target="142">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="140" value="User führt &lt;br&gt;Email-Bestätigung aus" style="rounded=1;whiteSpace=wrap;html=1;fontSize=20;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">
<mxGeometry x="1930" y="825" width="210" height="50" as="geometry"/>
</mxCell>
<mxCell id="145" value="" style="edgeStyle=none;html=1;fontSize=16;" edge="1" parent="1" source="142" target="144">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="142" value="Konto wird active" style="rounded=1;whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;" vertex="1" parent="1">
<mxGeometry x="1720" y="827.5" width="180" height="47.5" as="geometry"/>
</mxCell>
<mxCell id="147" value="" style="edgeStyle=none;html=1;fontSize=16;" edge="1" parent="1" source="144" target="146">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="144" value="für alle pending Contributions" style="whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;rounded=1;" vertex="1" parent="1">
<mxGeometry x="1540" y="825" width="150" height="50" as="geometry"/>
</mxCell>
<mxCell id="149" style="edgeStyle=none;html=1;entryX=0;entryY=0.25;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="146" target="71">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="150" style="edgeStyle=none;html=1;entryX=0.896;entryY=0.01;entryDx=0;entryDy=0;entryPerimeter=0;fontSize=16;exitX=0.642;exitY=0.988;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="146" target="79">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="146" value="erzeuge bestätigte Contribution" style="whiteSpace=wrap;html=1;fontSize=20;fillColor=#008a00;strokeColor=#005700;fontColor=#ffffff;rounded=1;" vertex="1" parent="1">
<mxGeometry x="1390" y="810" width="120" height="80" as="geometry"/>
</mxCell>
<mxCell id="151" value="" style="rounded=0;whiteSpace=wrap;html=1;fontSize=16;opacity=30;fillColor=#ffcd28;gradientColor=#ffa500;strokeColor=#d79b00;" vertex="1" parent="1">
<mxGeometry x="1180" y="680" width="1140" height="220" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -62,13 +62,13 @@ The business events will be stored in database in the new table `EventProtocol`.
The following table lists for each event type the mapping between old and new key, the mandatory attributes, which have to be initialized at event occurence and to be written in the database event protocol table:
| EventType - old key | EventType - new key | id | type | createdAt | userID | XuserID | XCommunityID | transactionID | contribID | amount |
| EventKey | EventType | id | type | createdAt | userID | XuserID | XCommunityID | transactionID | contribID | amount |
| :-------------------------------- | :------------------------------------- | :-: | :--: | :-------: | :----: | :-----: | :----------: | :-----------: | :-------: | :----: |
| BASIC | BasicEvent | x | x | x | | | | | | |
| VISIT_GRADIDO | VisitGradidoEvent | x | x | x | | | | | | |
| REGISTER | RegisterEvent | x | x | x | x | | | | | |
| LOGIN | LoginEvent | x | x | x | x | | | | | |
| | VerifyRedeemEvent | | | | | | | | | |
| VERIFY_REDEEM | VerifyRedeemEvent | x | x | x | x | | | (x) | (x) | |
| REDEEM_REGISTER | RedeemRegisterEvent | x | x | x | x | | | (x) | (x) | |
| REDEEM_LOGIN | RedeemLoginEvent | x | x | x | x | | | (x) | (x) | |
| ACTIVATE_ACCOUNT | ActivateAccountEvent | x | x | x | x | | | | | |
@ -82,20 +82,20 @@ The following table lists for each event type the mapping between old and new ke
| TRANSACTION_SEND_REDEEM | TransactionLinkRedeemEvent | x | x | x | x | x | x | x | | x |
| CONTRIBUTION_CREATE | ContributionCreateEvent | x | x | x | x | | | | x | x |
| CONTRIBUTION_CONFIRM | ContributionConfirmEvent | x | x | x | x | x | x | | x | x |
| | ContributionDenyEvent | x | x | x | x | x | x | | x | x |
| CONTRIBUTION_DENY | ContributionDenyEvent | x | x | x | x | x | x | | x | x |
| CONTRIBUTION_LINK_DEFINE | ContributionLinkDefineEvent | x | x | x | x | | | | | x |
| CONTRIBUTION_LINK_ACTIVATE_REDEEM | ContributionLinkRedeemEvent | x | x | x | x | | | | x | x |
| | UserCreateContributionMessageEvent | x | x | x | x | | | | x | x |
| | AdminCreateContributionMessageEvent | x | x | x | x | | | | x | x |
| | LogoutEvent | x | x | x | x | | | | x | x |
| USER_CREATES_CONTRIBUTION_MESSAGE | UserCreateContributionMessageEvent | x | x | x | x | | | | x | x |
| ADMIN_CREATES_CONTRIBUTION_MESSAGE | AdminCreateContributionMessageEvent | x | x | x | x | | | | x | x |
| LOGOUT | LogoutEvent | x | x | x | x | | | | | |
| SEND_CONFIRMATION_EMAIL | SendConfirmEmailEvent | x | x | x | x | | | | | |
| | SendAccountMultiRegistrationEmailEvent | x | x | x | x | | | | | |
| | SendForgotPasswordEmailEvent | x | x | x | x | | | | | |
| | SendTransactionSendEmailEvent | x | x | x | x | x | x | x | | x |
| | SendTransactionReceiveEmailEvent | x | x | x | x | x | x | x | | x |
| | SendAddedContributionEmailEvent | x | x | x | x | | | | x | x |
| | SendContributionConfirmEmailEvent | x | x | x | x | | | | x | x |
| | SendTransactionLinkRedeemEmailEvent | x | x | x | x | x | x | x | | x |
| SEND_ACCOUNT_MULTIREGISTRATION_EMAIL | SendAccountMultiRegistrationEmailEvent | x | x | x | x | | | | | |
| SEND_FORGOT_PASSWORD_EMAIL | SendForgotPasswordEmailEvent | x | x | x | x | | | | | |
| SEND_TRANSACTION_SEND_EMAIL | SendTransactionSendEmailEvent | x | x | x | x | x | x | x | | x |
| SEND_TRANSACTION_RECEIVE_EMAIL | SendTransactionReceiveEmailEvent | x | x | x | x | x | x | x | | x |
| SEND_ADDED_CONTRIBUTION_EMAIL | SendAddedContributionEmailEvent | x | x | x | x | | | | x | x |
| SEND_CONTRIBUTION_CONFIRM_EMAIL | SendContributionConfirmEmailEvent | x | x | x | x | | | | x | x |
| SEND_TRANSACTION_LINK_REDEEM_EMAIL | SendTransactionLinkRedeemEmailEvent | x | x | x | x | x | x | x | | x |
| TRANSACTION_REPEATE_REDEEM | - | | | | | | | | | |
| TRANSACTION_RECEIVE_REDEEM | - | | | | | | | | | |

View File

@ -1,6 +1,6 @@
{
"name": "bootstrap-vue-gradido-wallet",
"version": "1.12.1",
"version": "1.13.3",
"private": true,
"scripts": {
"start": "node run/server.js",

View File

@ -25,6 +25,7 @@ describe('App', () => {
meta: {
requiresAuth: false,
},
params: {},
},
}

View File

@ -33,7 +33,9 @@ $indigo: #5603ad !default;
$purple: #8965e0 !default;
$pink: #f3a4b5 !default;
$red: #f5365c !default;
$orange: #fb6340 !default;
// $orange: #fb6340 !default;
$orange: #8c0505 !default;
$yellow: #ffd600 !default;
$green: #2dce89 !default;
$teal: #11cdef !default;

View File

@ -18,9 +18,9 @@
<b-img class="sheet-img position-absolute d-block d-lg-none zindex1000" :src="sheet"></b-img>
<b-collapse id="nav-collapse" is-nav class="ml-5">
<b-navbar-nav class="ml-auto d-none d-lg-flex" right>
<b-nav-item to="/register" class="authNavbar ml-lg-5">{{ $t('signup') }}</b-nav-item>
<b-nav-item :to="register" class="authNavbar ml-lg-5">{{ $t('signup') }}</b-nav-item>
<span class="d-none d-lg-block mt-1">{{ $t('math.pipe') }}</span>
<b-nav-item to="/login" class="authNavbar">{{ $t('signin') }}</b-nav-item>
<b-nav-item :to="login" class="authNavbar">{{ $t('signin') }}</b-nav-item>
</b-navbar-nav>
</b-collapse>
</b-navbar>
@ -28,8 +28,11 @@
</template>
<script>
import { authLinks } from '@/mixins/authLinks'
export default {
name: 'AuthNavbar',
mixins: [authLinks],
data() {
return {
logo: '/img/brand/green.png',

View File

@ -2,17 +2,20 @@
<div class="navbar-small">
<b-navbar class="navi">
<b-navbar-nav>
<b-nav-item to="/register" class="authNavbar">{{ $t('signup') }}</b-nav-item>
<b-nav-item :to="register" class="authNavbar">{{ $t('signup') }}</b-nav-item>
<span class="mt-1">{{ $t('math.pipe') }}</span>
<b-nav-item to="/login" class="authNavbar">{{ $t('signin') }}</b-nav-item>
<b-nav-item :to="login" class="authNavbar">{{ $t('signin') }}</b-nav-item>
</b-navbar-nav>
</b-navbar>
</div>
</template>
<script>
import { authLinks } from '@/mixins/authLinks'
export default {
name: 'AuthNavbarSmall',
mixins: [authLinks],
}
</script>
<style scoped>

View File

@ -5,15 +5,23 @@
<contribution-messages-list-item :message="message" />
</div>
</b-container>
<contribution-messages-formular
v-if="['PENDING', 'IN_PROGRESS'].includes(state)"
:contributionId="contributionId"
@get-list-contribution-messages="getListContributionMessages"
@update-state="updateState"
/>
<div v-b-toggle="'collapse' + String(contributionId)" class="text-center pointer h2">
<b-icon icon="arrow-up-short"></b-icon>
{{ $t('form.close') }}
<b-container>
<contribution-messages-formular
v-if="['PENDING', 'IN_PROGRESS'].includes(state)"
:contributionId="contributionId"
@get-list-contribution-messages="getListContributionMessages"
@update-state="updateState"
/>
</b-container>
<div
v-b-toggle="'collapse' + String(contributionId)"
class="text-center pointer h2 clearboth pt-1"
>
<b-button variant="outline-primary" block class="mt-4">
<b-icon icon="arrow-up-short"></b-icon>
{{ $t('form.close') }}
</b-button>
</div>
</div>
</template>
@ -55,4 +63,7 @@ export default {
.temp-message {
margin-top: 50px;
}
.clearboth {
clear: both;
}
</style>

View File

@ -1,29 +1,29 @@
import { mount } from '@vue/test-utils'
import ContributionMessagesList from './ContributionMessagesList.vue'
import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
const localVue = global.localVue
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$store: {
state: {
firstName: 'Peter',
lastName: 'Lustig',
},
},
}
describe('ContributionMessagesList', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$store: {
state: {
firstName: 'Peter',
lastName: 'Lustig',
},
},
}
const propsData = {
contributionId: 42,
state: 'PENDING0',
state: 'PENDING',
messages: [
{
id: 111,
message: 'asd asda sda sda',
message: 'Lorem ipsum?',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
@ -32,10 +32,21 @@ describe('ContributionMessagesList', () => {
userId: 107,
__typename: 'ContributionMessage',
},
{
id: 113,
message: 'Asda sdad ad asdasd, das Ass das Das. ',
createdAt: '2022-08-29T12:25:34.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Bibi',
userLastName: 'Bloxberg',
userId: 108,
__typename: 'ContributionMessage',
},
],
}
const Wrapper = () => {
const ListWrapper = () => {
return mount(ContributionMessagesList, {
localVue,
mocks,
@ -45,11 +56,187 @@ describe('ContributionMessagesList', () => {
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper = ListWrapper()
})
it('has a DIV .contribution-messages-list-item', () => {
expect(wrapper.find('div.contribution-messages-list-item').exists()).toBe(true)
it('has two DIV .contribution-messages-list-item elements', () => {
expect(wrapper.findAll('div.contribution-messages-list-item').length).toBe(2)
})
})
})
describe('ContributionMessagesListItem', () => {
describe('if message author has moderator role', () => {
const propsData = {
message: {
id: 113,
message: 'Asda sdad ad asdasd, das Ass das Das. ',
createdAt: '2022-08-29T12:25:34.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Bibi',
userLastName: 'Bloxberg',
userId: 108,
__typename: 'ContributionMessage',
},
}
const ItemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeAll(() => {
wrapper = ItemWrapper()
})
it('has a DIV .is-moderator.text-left', () => {
expect(wrapper.find('div.is-moderator.text-left').exists()).toBe(true)
})
it('has the complete user name', () => {
expect(wrapper.find('div.is-moderator.text-left > span:nth-child(2)').text()).toBe(
'Bibi Bloxberg',
)
})
it('has the message creation date', () => {
expect(wrapper.find('div.is-moderator.text-left > span:nth-child(3)').text()).toMatch(
'Mon Aug 29 2022 12:25:34 GMT+0000',
)
})
it('has the moderator label', () => {
expect(wrapper.find('div.is-moderator.text-left > small:nth-child(4)').text()).toBe(
'community.moderator',
)
})
it('has the message', () => {
expect(wrapper.find('div.is-moderator.text-left > div:nth-child(5)').text()).toBe(
'Asda sdad ad asdasd, das Ass das Das.',
)
})
})
})
describe('if message author does not have moderator role', () => {
const propsData = {
message: {
id: 111,
message: 'Lorem ipsum?',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const ModeratorItemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeAll(() => {
wrapper = ModeratorItemWrapper()
})
it('has a DIV .is-not-moderator.text-right', () => {
expect(wrapper.find('div.is-not-moderator.text-right').exists()).toBe(true)
})
it('has the complete user name', () => {
expect(wrapper.find('div.is-not-moderator.text-right > span:nth-child(2)').text()).toBe(
'Peter Lustig',
)
})
it('has the message creation date', () => {
expect(wrapper.find('div.is-not-moderator.text-right > span:nth-child(3)').text()).toMatch(
'Mon Aug 29 2022 12:23:27 GMT+0000',
)
})
it('has the message', () => {
expect(wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)').text()).toBe(
'Lorem ipsum?',
)
})
})
})
describe('links in contribtion message', () => {
const propsData = {
message: {
id: 111,
message: 'Lorem ipsum?',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const ModeratorItemWrapper = () => {
return mount(ContributionMessagesListItem, {
localVue,
mocks,
propsData,
})
}
let messageField
describe('message of only one link', () => {
beforeEach(() => {
propsData.message.message = 'https://gradido.net/de/'
wrapper = ModeratorItemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)')
})
it('contains the link as text', () => {
expect(messageField.text()).toBe('https://gradido.net/de/')
})
it('contains a link to the given address', () => {
expect(messageField.find('a').attributes('href')).toBe('https://gradido.net/de/')
})
})
describe('message with text and two links', () => {
beforeEach(() => {
propsData.message.message = `Here you find all you need to know about Gradido: https://gradido.net/de/
and here is the link to the repository: https://github.com/gradido/gradido`
wrapper = ModeratorItemWrapper()
messageField = wrapper.find('div.is-not-moderator.text-right > div:nth-child(4)')
})
it('contains the whole text', () => {
expect(messageField.text())
.toBe(`Here you find all you need to know about Gradido: https://gradido.net/de/
and here is the link to the repository: https://github.com/gradido/gradido`)
})
it('contains the two links', () => {
expect(messageField.findAll('a').at(0).attributes('href')).toBe('https://gradido.net/de/')
expect(messageField.findAll('a').at(1).attributes('href')).toBe(
'https://github.com/gradido/gradido',
)
})
})
})
})

View File

@ -1,26 +1,33 @@
<template>
<div class="contribution-messages-list-item">
<is-not-moderator v-if="isNotModerator" :message="message"></is-not-moderator>
<is-moderator v-else :message="message"></is-moderator>
<div v-if="isNotModerator" class="is-not-moderator text-right">
<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>
</div>
<div v-else class="is-moderator text-left">
<b-avatar square variant="warning"></b-avatar>
<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('community.moderator') }}</small>
<linkify-message :message="message.message"></linkify-message>
</div>
</div>
</template>
<script>
import IsModerator from '@/components/ContributionMessages/slots/IsModerator.vue'
import IsNotModerator from '@/components/ContributionMessages/slots/IsNotModerator.vue'
import LinkifyMessage from '@/components/ContributionMessages/LinkifyMessage.vue'
export default {
name: 'ContributionMessagesListItem',
components: {
IsModerator,
IsNotModerator,
LinkifyMessage,
},
props: {
message: {
type: Object,
required: true,
default() {
return {}
},
},
},
data() {
@ -36,3 +43,19 @@ export default {
},
}
</script>
<style>
.is-not-moderator {
float: right;
/* background-color: rgb(261, 204, 221); */
width: 75%;
margin-top: 20px;
margin-bottom: 20px;
clear: both;
}
.is-moderator {
clear: both;
/* background-color: rgb(255, 255, 128); */
width: 75%;
margin-top: 20px;
}
</style>

View File

@ -0,0 +1,37 @@
<template>
<div class="mt-2">
<span v-for="({ type, text }, index) in linkifiedMessage" :key="index">
<b-link v-if="type === 'link'" :href="text" target="_blank">{{ text }}</b-link>
<span v-else>{{ text }}</span>
</span>
</div>
</template>
<script>
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',
props: {
message: {
type: String,
required: true,
},
},
computed: {
linkifiedMessage() {
const linkified = []
let string = this.message
let match
while ((match = string.match(LINK_REGEX_PATTERN))) {
if (match.index > 0)
linkified.push({ type: 'text', text: string.substring(0, match.index) })
linkified.push({ type: 'link', text: match[0] })
string = string.substring(match.index + match[0].length)
}
if (string.length > 0) linkified.push({ type: 'text', text: string })
return linkified
},
},
}
</script>

View File

@ -1,49 +0,0 @@
import { mount } from '@vue/test-utils'
import IsModerator from './IsModerator.vue'
const localVue = global.localVue
describe('IsModerator', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {
message: {
id: 111,
message: 'asd asda sda sda',
createdAt: '2022-08-29T12:23:27.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Peter',
userLastName: 'Lustig',
userId: 107,
__typename: 'ContributionMessage',
},
}
const Wrapper = () => {
return mount(IsModerator, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .slot-is-moderator', () => {
expect(wrapper.find('div.slot-is-moderator').exists()).toBe(true)
})
it('props.message.default', () => {
expect(wrapper.vm.$options.props.message.default.call()).toEqual({})
})
})
})

View File

@ -1,34 +0,0 @@
<template>
<div class="slot-is-moderator">
<b-avatar square :text="initialLetters" variant="warning"></b-avatar>
<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('community.moderator') }}</small>
<div class="mt-2">{{ message.message }}</div>
</div>
</template>
<script>
export default {
props: {
message: {
type: Object,
default() {
return {}
},
},
},
computed: {
initialLetters() {
return `${this.message.userFirstName[0]} ${this.message.userLastName[0]}`
},
},
}
</script>
<style>
.slot-is-moderator {
clear: both;
/* background-color: rgb(255, 242, 227); */
width: 75%;
margin-top: 20px;
}
</style>

View File

@ -1,49 +0,0 @@
import { mount } from '@vue/test-utils'
import IsNotModerator from './IsNotModerator.vue'
const localVue = global.localVue
describe('IsNotModerator', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {
message: {
id: 113,
message: 'asda sdad ad asdasd ',
createdAt: '2022-08-29T12:25:34.000Z',
updatedAt: null,
type: 'DIALOG',
userFirstName: 'Bibi',
userLastName: 'Bloxberg',
userId: 108,
__typename: 'ContributionMessage',
},
}
const Wrapper = () => {
return mount(IsNotModerator, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .slot-is-not-moderator', () => {
expect(wrapper.find('div.slot-is-not-moderator').exists()).toBe(true)
})
it('props.message.default', () => {
expect(wrapper.vm.$options.props.message.default.call()).toEqual({})
})
})
})

View File

@ -1,36 +0,0 @@
<template>
<div class="slot-is-not-moderator">
<div class="text-right">
<b-avatar :text="initialLetters" 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>
<div class="mt-2">{{ message.message }}</div>
</div>
</div>
</template>
<script>
export default {
props: {
message: {
type: Object,
default() {
return {}
},
},
},
computed: {
initialLetters() {
return `${this.message.userFirstName[0]} ${this.message.userLastName[0]}`
},
},
}
</script>
<style>
.slot-is-not-moderator {
float: right;
width: 75%;
margin-top: 20px;
margin-bottom: 20px;
clear: both;
}
</style>

View File

@ -198,6 +198,75 @@ describe('ContributionForm', () => {
})
})
})
describe('date with the 31st day of the month', () => {
describe('same month', () => {
beforeEach(async () => {
await wrapper.setData({
maximalDate: new Date('2022-10-31T00:00:00.000Z'),
form: { date: new Date('2022-10-31T00:00:00.000Z') },
})
})
describe('minimalDate', () => {
it('has "2022-09-01T00:00:00.000Z"', () => {
expect(wrapper.vm.minimalDate.toISOString()).toBe('2022-09-01T00:00:00.000Z')
})
})
describe('isThisMonth', () => {
it('has true', () => {
expect(wrapper.vm.isThisMonth).toBe(true)
})
})
})
})
describe('date with the 28th day of the month', () => {
describe('same month', () => {
beforeEach(async () => {
await wrapper.setData({
maximalDate: new Date('2023-02-28T00:00:00.000Z'),
form: { date: new Date('2023-02-28T00:00:00.000Z') },
})
})
describe('minimalDate', () => {
it('has "2023-01-01T00:00:00.000Z"', () => {
expect(wrapper.vm.minimalDate.toISOString()).toBe('2023-01-01T00:00:00.000Z')
})
})
describe('isThisMonth', () => {
it('has true', () => {
expect(wrapper.vm.isThisMonth).toBe(true)
})
})
})
})
describe('date with 29.02.2024 leap year', () => {
describe('same month', () => {
beforeEach(async () => {
await wrapper.setData({
maximalDate: new Date('2024-02-29T00:00:00.000Z'),
form: { date: new Date('2024-02-29T00:00:00.000Z') },
})
})
describe('minimalDate', () => {
it('has "2024-01-01T00:00:00.000Z"', () => {
expect(wrapper.vm.minimalDate.toISOString()).toBe('2024-01-01T00:00:00.000Z')
})
})
describe('isThisMonth', () => {
it('has true', () => {
expect(wrapper.vm.isThisMonth).toBe(true)
})
})
})
})
})
describe('set contrubtion', () => {
@ -329,7 +398,8 @@ describe('ContributionForm', () => {
describe('invalid form data', () => {
beforeEach(async () => {
await wrapper.findComponent({ name: 'BFormDatepicker' }).vm.$emit('input', now)
// skip this precondition as long as the datepicker is disabled in the component
// await wrapper.findComponent({ name: 'BFormDatepicker' }).vm.$emit('input', now)
await wrapper.find('#contribution-amount').find('input').setValue('200')
})

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