Merge branch 'master' into setup-hyperswarm

This commit is contained in:
Moriz Wahl 2022-07-19 14:43:19 +02:00
commit b78ad2fb00
290 changed files with 14318 additions and 2801 deletions

View File

@ -528,7 +528,7 @@ jobs:
report_name: Coverage Backend
type: lcov
result_path: ./backend/coverage/lcov.info
min_coverage: 65
min_coverage: 68
token: ${{ github.token }}
##########################################################################

10
.gitignore vendored
View File

@ -1,4 +1,7 @@
.dbeaver
.project
*.log
*.bak
/node_modules/*
messages.pot
nbproject
@ -11,4 +14,9 @@ package-lock.json
/deployment/bare_metal/nginx/update-page/updating.html
/deployment/bare_metal/log
/deployment/bare_metal/backup
/.nvmrc
# Node Version Manager configuration file
.nvmrc
# Apple macOS folder attribute file
.DS_Store

View File

@ -4,8 +4,103 @@ 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.10.1](https://github.com/gradido/gradido/compare/1.10.0...1.10.1)
- automatic session logout with info modal [`#2001`](https://github.com/gradido/gradido/pull/2001)
- 1910 separate text for the slideshow images. [`#1998`](https://github.com/gradido/gradido/pull/1998)
- Origin/1921 additional parameter checks for createContributionLinks [`#1996`](https://github.com/gradido/gradido/pull/1996)
- add missing locales [`#1999`](https://github.com/gradido/gradido/pull/1999)
- 1906 feature concept for gdd creation per linkqr code [`#1907`](https://github.com/gradido/gradido/pull/1907)
- refactor: 🍰 Not Throwing An Error When Register With Existing Email [`#1962`](https://github.com/gradido/gradido/pull/1962)
- feat: 🍰 Set Role In Admin Interface [`#1974`](https://github.com/gradido/gradido/pull/1974)
- refactor mobile style step 1 [`#1977`](https://github.com/gradido/gradido/pull/1977)
- changed mobil stage picture [`#1995`](https://github.com/gradido/gradido/pull/1995)
#### [1.10.0](https://github.com/gradido/gradido/compare/1.9.0...1.10.0)
> 17 June 2022
- release: v1.10.0 [`#1993`](https://github.com/gradido/gradido/pull/1993)
- frontend redeem contribution link [`#1988`](https://github.com/gradido/gradido/pull/1988)
- change new start picture [`#1990`](https://github.com/gradido/gradido/pull/1990)
- feat: Redeem Contribution Link [`#1987`](https://github.com/gradido/gradido/pull/1987)
- fix: Max Amount on Slider for Edit Contribution [`#1986`](https://github.com/gradido/gradido/pull/1986)
- CRUD contribution link admin interface [`#1981`](https://github.com/gradido/gradido/pull/1981)
- fix: `.env` log level for apollo and backend category [`#1967`](https://github.com/gradido/gradido/pull/1967)
- refactor: Admin Pending Creations Table to Contributions Table [`#1949`](https://github.com/gradido/gradido/pull/1949)
- devops: Update Browser List for Unit Tests as Recomended [`#1984`](https://github.com/gradido/gradido/pull/1984)
- feat: CRUD for Contribution Links in Admin Resolver [`#1979`](https://github.com/gradido/gradido/pull/1979)
- 1920 feature create contribution link table [`#1957`](https://github.com/gradido/gradido/pull/1957)
- refactor: 🍰 Delete `user_setting` Table From DB [`#1960`](https://github.com/gradido/gradido/pull/1960)
- locales link german, english navbar [`#1969`](https://github.com/gradido/gradido/pull/1969)
#### [1.9.0](https://github.com/gradido/gradido/compare/1.8.3...1.9.0)
> 2 June 2022
- devops: Release Version 1.9.0 [`#1968`](https://github.com/gradido/gradido/pull/1968)
- refactor: 🍰 Refactor To `filters` Object And Rename Filters Properties [`#1914`](https://github.com/gradido/gradido/pull/1914)
- refactor register button position [`#1964`](https://github.com/gradido/gradido/pull/1964)
- fixed redeem link is mobile start false [`#1958`](https://github.com/gradido/gradido/pull/1958)
- 1951 remove back link and remove gray box [`#1959`](https://github.com/gradido/gradido/pull/1959)
- 1952 change footer icons color an remove save login [`#1955`](https://github.com/gradido/gradido/pull/1955)
- fix: License should be a valid SPDX license expression [`#1954`](https://github.com/gradido/gradido/pull/1954)
- refactor: 🍰 Refactor THX Page 2. Step [`#1858`](https://github.com/gradido/gradido/pull/1858)
- fix: Add Timezone to Decay Start Block [`#1931`](https://github.com/gradido/gradido/pull/1931)
- devops: Update License in all package.json [`#1925`](https://github.com/gradido/gradido/pull/1925)
- docu: Creation Flowchart [`#1918`](https://github.com/gradido/gradido/pull/1918)
- refactor: Use Logger Categories [`#1912`](https://github.com/gradido/gradido/pull/1912)
- 1883 remove the animated coins in the profile settings [`#1946`](https://github.com/gradido/gradido/pull/1946)
- 1942 replace pictures for carousel [`#1943`](https://github.com/gradido/gradido/pull/1943)
- 1933 auth footer is not on one level [`#1941`](https://github.com/gradido/gradido/pull/1941)
- 1929 styling new template for password component [`#1935`](https://github.com/gradido/gradido/pull/1935)
- 1926 button concept for gradido template [`#1927`](https://github.com/gradido/gradido/pull/1927)
- 1916 remove select language from register form [`#1930`](https://github.com/gradido/gradido/pull/1930)
- rename files from auth folder, rule vue name = name files [`#1937`](https://github.com/gradido/gradido/pull/1937)
- Add files Bild_1_2400.jpg [`#1945`](https://github.com/gradido/gradido/pull/1945)
- Bilder für Slider [`#1940`](https://github.com/gradido/gradido/pull/1940)
- contribution analysis of elopage and concept proposal [`#1917`](https://github.com/gradido/gradido/pull/1917)
- 1676 feature federation technical concept [`#1711`](https://github.com/gradido/gradido/pull/1711)
- more details about Windows installation [`#1842`](https://github.com/gradido/gradido/pull/1842)
- Concept to Introduce Gradido ID [`#1797`](https://github.com/gradido/gradido/pull/1797)
- first draft of concept event protocol [`#1796`](https://github.com/gradido/gradido/pull/1796)
- 1682 new design for the login and registration area [`#1693`](https://github.com/gradido/gradido/pull/1693)
- fix: Database Connection Charset to utf8mb4_unicode_ci [`#1915`](https://github.com/gradido/gradido/pull/1915)
- refactor: 🍰 Create Filter Object in GQL And Rename Args [`#1860`](https://github.com/gradido/gradido/pull/1860)
- feat: 🍰 Improve Apollo Logging [`#1859`](https://github.com/gradido/gradido/pull/1859)
- Add files via upload [`#1903`](https://github.com/gradido/gradido/pull/1903)
- 🍰 Hide Pagenation On Short Transactionlist [`#1875`](https://github.com/gradido/gradido/pull/1875)
- 🍰 Ignore macOS .DS_Store Files [`#1902`](https://github.com/gradido/gradido/pull/1902)
- pre I from #1682, add images, svg for new styling [`#1900`](https://github.com/gradido/gradido/pull/1900)
- add browserstack logo image [`#1888`](https://github.com/gradido/gradido/pull/1888)
#### [1.8.3](https://github.com/gradido/gradido/compare/1.8.2...1.8.3)
> 13 May 2022
- Release 1.8.3 [`#1899`](https://github.com/gradido/gradido/pull/1899)
- Checkbox [`#1894`](https://github.com/gradido/gradido/pull/1894)
- fix: Count Deprecated Links as Well [`#1892`](https://github.com/gradido/gradido/pull/1892)
#### [1.8.2](https://github.com/gradido/gradido/compare/1.8.1...1.8.2)
> 12 May 2022
- Release 1.8.2 [`#1890`](https://github.com/gradido/gradido/pull/1890)
- Update README.md [`#1878`](https://github.com/gradido/gradido/pull/1878)
- fix: Unique Previous Column in Transactions Table [`#1879`](https://github.com/gradido/gradido/pull/1879)
- fix: Up and Down Migrations for Older SQL Versions [`#1861`](https://github.com/gradido/gradido/pull/1861)
- 🍰 Refactor THX Page 1. Step [`#1856`](https://github.com/gradido/gradido/pull/1856)
- Create LICENSE [`#1803`](https://github.com/gradido/gradido/pull/1803)
- docu: Update Deployment Documentation [`#1864`](https://github.com/gradido/gradido/pull/1864)
- fix: Loading Transaction Links after Reopening Link List [`#1863`](https://github.com/gradido/gradido/pull/1863)
- 🍰 Add NVM Config Files To '.gitignore' [`#1846`](https://github.com/gradido/gradido/pull/1846)
#### [1.8.1](https://github.com/gradido/gradido/compare/1.8.0...1.8.1)
> 28 April 2022
- v1.8.1 [`#1855`](https://github.com/gradido/gradido/pull/1855)
- 1851 integrate and test the behaviour of clipboard polyfill [`#1853`](https://github.com/gradido/gradido/pull/1853)
- fix: Deprecated Warning from Faker on Seeding [`#1854`](https://github.com/gradido/gradido/pull/1854)
- feat: Test Admin Resolver [`#1848`](https://github.com/gradido/gradido/pull/1848)

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -9,21 +9,26 @@ The Corona crisis has fundamentally changed our world within a very short time.
The dominant financial system threatens to fail around the globe, followed by mass insolvencies, record unemployment and abject poverty. Only with a sustainable new monetary system can humanity master these challenges of the 21st century. The Gradido Academy for Bionic Economy has developed such a system.
Find out more about the Project on its [Website](https://gradido.net/). It is offering vast resources about the idea. The remaining document will discuss the gradido software only.
## Software requirements
Currently we only support `docker` install instructions to run all services, since many different programming languages and frameworks are used.
- [docker](https://www.docker.com/)
- [docker](https://www.docker.com/)
- [docker-compose]
- [yarn](https://phoenixnap.com/kb/yarn-windows)
### For Arch Linux
Install the required packages:
```bash
sudo pacman -S docker
sudo pacman -S docker-compose
```
Add group `docker` and then your user to it in order to allow you to run docker without sudo
Add group `docker` and then your user to it in order to allow you to run docker without sudo
```bash
sudo groupadd docker # may already exist `groupadd: group 'docker' already exists`
sudo usermod -aG docker $USER
@ -31,26 +36,58 @@ groups # verify you have the group (requires relog)
```
Start the docker service:
```bash
sudo systemctrl start docker
```
### For Windows
#### docker
The installation of dockers depends on your selected product package from the [dockers page](https://www.docker.com/). For windows the product *docker desktop* will be the choice. Please follow the installation instruction of your selected product.
##### known problems
* In case the docker desktop will not start correctly because of previous docker installations, then please clean the used directories of previous docker installation - `C:\Users` - before you retry starting docker desktop. For further problems executing docker desktop please take a look in this description "[logs and trouble shooting](https://docs.docker.com/desktop/windows/troubleshoot/)"
* In case your docker desktop installation causes high memory consumption per vmmem process, then please take a look at this description "[vmmen process consuming too much memory (Docker Desktop)](https://dev.to/tallesl/vmmen-process-consuming-too-much-memory-docker-desktop-273p)"
#### yarn
For the Gradido build process the yarn package manager will be used. Please download and install [yarn for windows](https://phoenixnap.com/kb/yarn-windows) by following the instructions there.
## How to run?
As soon as the software requirements are fulfilled and a docker installation is up and running then open a powershell on Windows or an other commandline prompt on Linux.
Create and navigate to the directory, where you want to create the Gradido runtime environment.
```
mkdir \Gradido
cd \Gradido
```
### 1. Clone Sources
Clone the repo and pull all submodules
```bash
git clone git@github.com:gradido/gradido.git
git submodule update --recursive --init
```
### 2. Run docker-compose
Run docker-compose to bring up the development environment
Run docker-compose to bring up the development environment
```bash
docker-compose up
```
### Additional Build options
If you want to build for production you can do this aswell:
```bash
docker-compose -f docker-compose.yml up
```
@ -73,6 +110,7 @@ A release is tagged on Github by its version number and published as github rele
Each release is accompanied with release notes automatically generated from the git log which is available as [CHANGELOG.md](./CHANGELOG.md).
To generate the Changelog and set a new Version you should use the following commands in the main folder
```bash
git fetch --all
yarn release
@ -83,13 +121,38 @@ After generating a new version you should commit the changes. This will be the C
Note: The Changelog will be regenerated with all tags on release on the external builder tool, but will not be checked in there. The Changelog on the github release will therefore always be correct, on the repo it might be incorrect due to missing tags when executing the `yarn release` command.
## How the different .env work on deploy
Each component (frontend, admin, backend and database) has its own `.env` file. When running in development with docker and nginx you usually do not have to care about the `.env`. The defaults are set by the respective config file, found in the `src/config/` folder of each component. But if you have a local `.env`, the defaults set in the config are overwritten by the `.env`. If you do not use docker, you need the `.env` in the frontend and admin interface because nginx is not running in order to find the backend.
Each component has a `.env.dist` file. This file contains all environment variables used by the component and can be used as pattern. If you want to use a local `.env`, copy the `.env.dist` and adjust the variables accordingly.
Each component has a `.env.template` file. These files are very important on deploy.
There is one `.env.dist` in the `deployment/bare_metal/` folder. This `.env.dist` contains all variables used by the components, e.g. unites all `.env.dist` from the components. On deploy, we copy this `.env.dist` to `.env` and set all variables in this new file. The deploy script loads this variables and provides them by the `.env.templates` of each component, creating an `.env` for each component (see in `deployment/bare_metal/start.sh` the `envsubst`).
To avoid forgetting to update an existing `.env` in the `deployment/bare_metal/` folder when deploying, we have an environment version variable inside the codebase of each component. You should update this version, when environment variables must be changed or added on deploy. The code checks, that the environement version provided by the `.env` is the one expected by the codebase.
## Troubleshooting
| Problem | Issue | Solution | Description |
| ------- | ----- | -------- | ----------- |
| Problem | Issue | Solution | Description |
| ------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| docker-compose raises database connection errors | [#1062](https://github.com/gradido/gradido/issues/1062) | End `ctrl+c` and restart the `docker-compose up` after a successful build | Several Database connection related errors occur in the docker-compose log. |
| Wallet page is empty | [#1063](https://github.com/gradido/gradido/issues/1063) | Accept Cookies and Local Storage in your Browser | The page stays empty when navigating to [http://localhost/](http://localhost/) |
| Wallet page is empty | [#1063](https://github.com/gradido/gradido/issues/1063) | Accept Cookies and Local Storage in your Browser | The page stays empty when navigating to[http://localhost/](http://localhost/) |
## Useful Links
- [Gradido.net](https://gradido.net/)
## Attributions
![browserstack_logo-freelogovectors net_](https://user-images.githubusercontent.com/1324583/167782608-0e4db0d4-3d34-45fb-ab06-344aa5e5ef4b.png)
Browser compatibility testing with [BrowserStack](https://www.browserstack.com/).
## License
See the [LICENSE](LICENSE.md) file for license rights and limitations (Apache-2.0 license).

1
admin/.gitignore vendored
View File

@ -10,4 +10,3 @@ coverage/
# emacs
*~
/.nvmrc

View File

@ -4,5 +4,6 @@ module.exports = {
singleQuote: true,
trailingComma: "all",
tabWidth: 2,
bracketSpacing: true
bracketSpacing: true,
endOfLine: "auto",
};

View File

@ -22,7 +22,7 @@ module.exports = {
'^.+\\.(js|jsx)?$': 'babel-jest',
'<rootDir>/node_modules/vee-validate/dist/rules': 'babel-jest',
},
setupFiles: ['<rootDir>/test/testSetup.js'],
setupFiles: ['<rootDir>/test/testSetup.js', 'jest-canvas-mock'],
testMatch: ['**/?(*.)+(spec|test).js?(x)'],
// snapshotSerializers: ['jest-serializer-vue'],
transformIgnorePatterns: ['<rootDir>/node_modules/(?!vee-validate/dist/rules)'],

View File

@ -3,8 +3,8 @@
"description": "Administraion Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "1.8.1",
"license": "MIT",
"version": "1.10.1",
"license": "Apache-2.0",
"private": false,
"scripts": {
"start": "node run/server.js",
@ -38,7 +38,9 @@
"graphql": "^15.6.1",
"identity-obj-proxy": "^3.0.0",
"jest": "26.6.3",
"jest-canvas-mock": "^2.3.1",
"portal-vue": "^2.1.7",
"qrcanvas-vue": "2.1.1",
"regenerator-runtime": "^0.13.9",
"stats-webpack-plugin": "^0.7.0",
"vue": "^2.6.11",

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -0,0 +1,254 @@
import { mount } from '@vue/test-utils'
import ChangeUserRoleFormular from './ChangeUserRoleFormular.vue'
import { setUserRole } from '../graphql/setUserRole'
import { toastSuccessSpy, toastErrorSpy } from '../../test/testSetup'
const localVue = global.localVue
const apolloMutateMock = jest.fn().mockResolvedValue({
data: {
setUserRole: null,
},
})
const mocks = {
$t: jest.fn((t) => t),
$apollo: {
mutate: apolloMutateMock,
},
$store: {
state: {
moderator: {
id: 0,
name: 'test moderator',
},
},
},
}
let propsData
let wrapper
describe('ChangeUserRoleFormular', () => {
const Wrapper = () => {
return mount(ChangeUserRoleFormular, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('DOM has', () => {
beforeEach(() => {
propsData = {
item: {
userId: 1,
isAdmin: null,
},
}
wrapper = Wrapper()
})
it('has a DIV element with the class.delete-user-formular', () => {
expect(wrapper.find('.change-user-role-formular').exists()).toBe(true)
})
})
describe('change own role', () => {
beforeEach(() => {
propsData = {
item: {
userId: 0,
isAdmin: null,
},
}
wrapper = Wrapper()
})
it('has the text that you cannot change own role', () => {
expect(wrapper.text()).toContain('userRole.notChangeYourSelf')
})
it('has role select disabled', () => {
expect(wrapper.find('select[disabled="disabled"]').exists()).toBe(true)
})
})
describe('change others role', () => {
let rolesToSelect
describe('general', () => {
beforeEach(() => {
propsData = {
item: {
userId: 1,
isAdmin: null,
},
}
wrapper = Wrapper()
rolesToSelect = wrapper.find('select.role-select').findAll('option')
})
it('has no text that you cannot change own role', () => {
expect(wrapper.text()).not.toContain('userRole.notChangeYourSelf')
})
it('has the select label', () => {
expect(wrapper.text()).toContain('userRole.selectLabel')
})
it('has a select', () => {
expect(wrapper.find('select.role-select').exists()).toBe(true)
})
it('has role select enabled', () => {
expect(wrapper.find('select.role-select[disabled="disabled"]').exists()).toBe(false)
})
describe('on API error', () => {
beforeEach(() => {
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
rolesToSelect.at(1).setSelected()
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh no!')
})
})
})
describe('user is usual user', () => {
beforeEach(() => {
apolloMutateMock.mockResolvedValue({
data: {
setUserRole: new Date(),
},
})
propsData = {
item: {
userId: 1,
isAdmin: null,
},
}
wrapper = Wrapper()
rolesToSelect = wrapper.find('select.role-select').findAll('option')
})
it('has selected option set to "usual user"', () => {
expect(wrapper.find('select.role-select').element.value).toBe('user')
})
describe('change select to', () => {
describe('same role', () => {
it('does not call the API', () => {
rolesToSelect.at(0).setSelected()
expect(apolloMutateMock).not.toHaveBeenCalled()
})
})
describe('new role', () => {
beforeEach(() => {
rolesToSelect.at(1).setSelected()
})
it('calls the API', () => {
expect(apolloMutateMock).toBeCalledWith(
expect.objectContaining({
mutation: setUserRole,
variables: {
userId: 1,
isAdmin: true,
},
}),
)
})
it('emits "updateIsAdmin"', () => {
expect(wrapper.emitted('updateIsAdmin')).toEqual(
expect.arrayContaining([
expect.arrayContaining([
{
userId: 1,
isAdmin: expect.any(Date),
},
]),
]),
)
})
it('toasts success message', () => {
expect(toastSuccessSpy).toBeCalledWith('userRole.successfullyChangedTo')
})
})
})
})
describe('user is admin', () => {
beforeEach(() => {
apolloMutateMock.mockResolvedValue({
data: {
setUserRole: null,
},
})
propsData = {
item: {
userId: 1,
isAdmin: new Date(),
},
}
wrapper = Wrapper()
rolesToSelect = wrapper.find('select.role-select').findAll('option')
})
it('has selected option set to "admin"', () => {
expect(wrapper.find('select.role-select').element.value).toBe('admin')
})
describe('change select to', () => {
describe('same role', () => {
it('does not call the API', () => {
rolesToSelect.at(1).setSelected()
expect(apolloMutateMock).not.toHaveBeenCalled()
})
})
describe('new role', () => {
beforeEach(() => {
rolesToSelect.at(0).setSelected()
})
it('calls the API', () => {
expect(apolloMutateMock).toBeCalledWith(
expect.objectContaining({
mutation: setUserRole,
variables: {
userId: 1,
isAdmin: false,
},
}),
)
})
it('emits "updateIsAdmin"', () => {
expect(wrapper.emitted('updateIsAdmin')).toEqual(
expect.arrayContaining([
expect.arrayContaining([
{
userId: 1,
isAdmin: null,
},
]),
]),
)
})
it('toasts success message', () => {
expect(toastSuccessSpy).toBeCalledWith('userRole.successfullyChangedTo')
})
})
})
})
})
})
})

View File

@ -0,0 +1,89 @@
<template>
<div class="change-user-role-formular">
<div class="shadow p-3 mb-5 bg-white rounded">
<div v-if="item.userId === $store.state.moderator.id" class="m-3 mb-4">
{{ $t('userRole.notChangeYourSelf') }}
</div>
<div class="m-3">
<label for="role" class="mr-3">{{ $t('userRole.selectLabel') }}</label>
<b-form-select
class="role-select"
v-model="roleSelected"
:options="roles"
:disabled="item.userId === $store.state.moderator.id"
/>
</div>
</div>
</div>
</template>
<script>
import { setUserRole } from '../graphql/setUserRole'
const rolesValues = {
admin: 'admin',
user: 'user',
}
export default {
name: 'ChangeUserRoleFormular',
props: {
item: {
type: Object,
required: true,
},
},
data() {
return {
roleSelected: this.item.isAdmin ? rolesValues.admin : rolesValues.user,
roles: [
{ value: rolesValues.user, text: this.$t('userRole.selectRoles.user') },
{ value: rolesValues.admin, text: this.$t('userRole.selectRoles.admin') },
],
}
},
watch: {
roleSelected(newRole, oldRole) {
if (newRole !== oldRole) {
this.setUserRole(newRole, oldRole)
}
},
},
methods: {
setUserRole(newRole, oldRole) {
this.$apollo
.mutate({
mutation: setUserRole,
variables: {
userId: this.item.userId,
isAdmin: newRole === rolesValues.admin,
},
})
.then((result) => {
this.$emit('updateIsAdmin', {
userId: this.item.userId,
isAdmin: result.data.setUserRole,
})
this.toastSuccess(
this.$t('userRole.successfullyChangedTo', {
role:
result.data.setUserRole !== null
? this.$t('userRole.selectRoles.admin')
: this.$t('userRole.selectRoles.user'),
}),
)
})
.catch((error) => {
this.roleSelected = oldRole
this.toastError(error.message)
})
},
},
}
</script>
<style>
.role-select {
width: 300pt;
}
</style>

View File

@ -0,0 +1,49 @@
import { mount } from '@vue/test-utils'
import ContributionLink from './ContributionLink.vue'
const localVue = global.localVue
const mocks = {
$t: jest.fn((t) => t),
}
const propsData = {
items: [
{
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,
}
describe('ContributionLink', () => {
let wrapper
const Wrapper = () => {
return mount(ContributionLink, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".contribution-link"', () => {
expect(wrapper.find('div.contribution-link').exists()).toBe(true)
})
it('emits toggle::collapse new Contribution', async () => {
wrapper.vm.editContributionLinkData()
expect(wrapper.vm.$root.$emit('bv::toggle::collapse', 'newContribution')).toBeTruthy()
})
})
})

View File

@ -0,0 +1,66 @@
<template>
<div class="contribution-link">
<b-card
border-variant="success"
:header="$t('contributionLink.contributionLinks')"
header-bg-variant="success"
header-text-variant="white"
header-class="text-center"
class="mt-5"
>
<b-button v-b-toggle.newContribution class="my-3 d-flex justify-content-left">
{{ $t('math.plus') }} {{ $t('contributionLink.newContributionLink') }}
</b-button>
<b-collapse v-model="visible" id="newContribution" class="mt-2">
<b-card>
<p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p>
<contribution-link-form :contributionLinkData="contributionLinkData" />
</b-card>
</b-collapse>
<b-card-text>
<contribution-link-list
v-if="count > 0"
:items="items"
@editContributionLinkData="editContributionLinkData"
/>
<div v-else>{{ $t('contributionLink.noContributionLinks') }}</div>
</b-card-text>
</b-card>
</div>
</template>
<script>
import ContributionLinkForm from './ContributionLinkForm.vue'
import ContributionLinkList from './ContributionLinkList.vue'
export default {
name: 'ContributionLink',
components: {
ContributionLinkForm,
ContributionLinkList,
},
props: {
items: {
type: Array,
required: true,
},
count: {
type: Number,
required: true,
},
},
data: function () {
return {
visible: false,
contributionLinkData: {},
}
},
methods: {
editContributionLinkData(data) {
if (!this.visible) this.$root.$emit('bv::toggle::collapse', 'newContribution')
this.contributionLinkData = data
},
},
}
</script>

View File

@ -0,0 +1,102 @@
import { mount } from '@vue/test-utils'
import ContributionLinkForm from './ContributionLinkForm.vue'
const localVue = global.localVue
global.alert = jest.fn()
const propsData = {
contributionLinkData: {},
}
const mocks = {
$t: jest.fn((t) => t),
}
// 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')
// })
// })
})
})

View File

@ -0,0 +1,218 @@
<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>
<b-col>
<b-form-group :label="$t('contributionLink.validFrom')">
<b-form-datepicker
v-model="form.validFrom"
size="lg"
:min="min"
class="mb-4 test-validFrom"
reset-value=""
:label-no-date-selected="$t('contributionLink.noDateSelected')"
required
></b-form-datepicker>
</b-form-group>
</b-col>
<b-col>
<b-form-group :label="$t('contributionLink.validTo')">
<b-form-datepicker
v-model="form.validTo"
size="lg"
:min="form.validFrom ? form.validFrom : min"
class="mb-4 test-validTo"
reset-value=""
:label-no-date-selected="$t('contributionLink.noDateSelected')"
required
></b-form-datepicker>
</b-form-group>
</b-col>
</b-row>
<!-- Name -->
<b-form-group :label="$t('contributionLink.name')">
<b-form-input
v-model="form.name"
size="lg"
type="text"
placeholder="Name"
required
maxlength="100"
class="test-name"
></b-form-input>
</b-form-group>
<!-- Desc -->
<b-form-group :label="$t('contributionLink.memo')">
<b-form-textarea
v-model="form.memo"
size="lg"
:placeholder="$t('contributionLink.memo')"
required
maxlength="255"
class="test-memo"
></b-form-textarea>
</b-form-group>
<!-- Amount -->
<b-form-group :label="$t('contributionLink.amount')">
<b-form-input
v-model="form.amount"
size="lg"
type="number"
placeholder="0"
required
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>
<!-- Max amount -->
<b-form-group :label="$t('contributionLink.maximumAmount')">
<b-form-input
v-model="form.maxAmountPerMonth"
size="lg"
:disabled="disabled"
type="number"
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">
{{ $t('contributionLink.clear') }}
</b-button>
</div>
</b-form>
</div>
</template>
<script>
import { createContributionLink } from '@/graphql/createContributionLink.js'
export default {
name: 'ContributionLinkForm',
props: {
contributionLinkData: {
type: Object,
default() {
return {}
},
},
},
data() {
return {
form: {
name: null,
memo: null,
amount: null,
validFrom: null,
validTo: null,
cycle: 'ONCE',
maxPerCycle: 1,
maxAmountPerMonth: '0',
},
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') },
],
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' },
],
}
},
methods: {
onSubmit() {
if (this.form.validFrom === null)
return this.toastError(this.$t('contributionLink.noStartDate'))
if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate'))
// alert(JSON.stringify(this.form))
this.$apollo
.mutate({
mutation: createContributionLink,
variables: {
validFrom: this.form.validFrom,
validTo: this.form.validTo,
name: this.form.name,
amount: this.form.amount,
memo: this.form.memo,
cycle: this.form.cycle,
maxPerCycle: this.form.maxPerCycle,
maxAmountPerMonth: this.form.maxAmountPerMonth,
},
})
.then((result) => {
this.link = result.data.createContributionLink.link
this.toastSuccess(this.link)
this.onReset()
})
.catch((error) => {
this.toastError(error.message)
})
},
onReset() {
this.$refs.contributionLinkForm.reset()
this.form.validFrom = null
this.form.validTo = null
},
},
computed: {
updateData() {
return this.contributionLinkData
},
disabled() {
if (this.form.cycle === 'ONCE') return true
return false
},
},
watch: {
contributionLinkData() {
this.form.name = this.contributionLinkData.name
this.form.memo = this.contributionLinkData.memo
this.form.amount = this.contributionLinkData.amount
this.form.validFrom = this.contributionLinkData.validFrom
this.form.validTo = this.contributionLinkData.validTo
this.form.cycle = this.contributionLinkData.cycle
this.form.maxPerCycle = this.contributionLinkData.maxPerCycle
this.form.maxAmountPerMonth = this.contributionLinkData.maxAmountPerMonth
},
},
}
</script>

View File

@ -0,0 +1,147 @@
import { mount } from '@vue/test-utils'
import ContributionLinkList from './ContributionLinkList.vue'
import { toastSuccessSpy, toastErrorSpy } from '../../test/testSetup'
// import { deleteContributionLink } from '../graphql/deleteContributionLink'
const localVue = global.localVue
const mockAPIcall = jest.fn()
const mocks = {
$t: jest.fn((t) => t),
$apollo: {
mutate: mockAPIcall,
},
}
const propsData = {
items: [
{
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',
},
],
}
describe('ContributionLinkList', () => {
let wrapper
const Wrapper = () => {
return mount(ContributionLinkList, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".contribution-link-list"', () => {
expect(wrapper.find('div.contribution-link-list').exists()).toBe(true)
})
it('renders table with contribution link', () => {
expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(0).text()).toContain(
'Meditation',
)
})
describe('edit contribution link', () => {
beforeEach(() => {
wrapper.vm.editContributionLink()
})
it('emits editContributionLinkData', async () => {
expect(wrapper.vm.$emit('editContributionLinkData')).toBeTruthy()
})
})
describe('delete contribution link', () => {
let spy
beforeEach(async () => {
jest.clearAllMocks()
wrapper.vm.deleteContributionLink()
})
describe('with success', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve('some value'))
mockAPIcall.mockResolvedValue()
await wrapper.find('.test-delete-link').trigger('click')
})
it('opens the modal ', () => {
expect(spy).toBeCalled()
})
it.skip('calls the API', () => {
// expect(mockAPIcall).toBeCalledWith(
// expect.objectContaining({
// mutation: deleteContributionLink,
// variables: {
// id: 1,
// },
// }),
// )
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('TODO: request message deleted ')
})
})
describe('with error', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve('some value'))
mockAPIcall.mockRejectedValue({ message: 'Something went wrong :(' })
await wrapper.find('.test-delete-link').trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Something went wrong :(')
})
})
describe('cancel delete', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve(false))
mockAPIcall.mockResolvedValue()
await wrapper.find('.test-delete-link').trigger('click')
})
it('does not call the API', () => {
expect(mockAPIcall).not.toBeCalled()
})
})
})
describe('onClick showButton', () => {
it('modelData contains contribution link', () => {
wrapper.find('button.test-show').trigger('click')
expect(wrapper.vm.modalData).toEqual({
amount: '200',
cycle: 'täglich',
id: 1,
link: 'https://localhost/redeem/CL-1a2345678',
maxAmountPerMonth: 0,
maxPerCycle: '3',
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
name: 'Meditation',
validFrom: '2022-04-01',
validTo: '2022-08-01',
})
})
})
})
})

View File

@ -0,0 +1,106 @@
<template>
<div class="contribution-link-list">
<b-table striped hover :items="items" :fields="fields">
<template #cell(delete)>
<b-button
variant="danger"
size="md"
class="mr-2 test-delete-link"
@click="deleteContributionLink"
>
<b-icon icon="trash" variant="light"></b-icon>
</b-button>
</template>
<template #cell(edit)="data">
<b-button variant="success" size="md" class="mr-2" @click="editContributionLink(data.item)">
<b-icon icon="pencil" variant="light"></b-icon>
</b-button>
</template>
<template #cell(show)="data">
<b-button
variant="info"
size="md"
class="mr-2 test-show"
@click="showContributionLink(data.item)"
>
<b-icon icon="eye" variant="light"></b-icon>
</b-button>
</template>
</b-table>
<b-modal ref="my-modal" ok-only hide-header-close>
<b-card header-tag="header" footer-tag="footer">
<template #header>
<h6 class="mb-0">{{ modalData ? modalData.name : '' }}</h6>
</template>
<b-card-text>
{{ modalData }}
<figure-qr-code :link="modalData ? modalData.link : ''" />
</b-card-text>
<template #footer>
<em>{{ modalData ? modalData.link : '' }}</em>
</template>
</b-card>
</b-modal>
</div>
</template>
<script>
import { deleteContributionLink } from '@/graphql/deleteContributionLink.js'
import FigureQrCode from './FigureQrCode.vue'
export default {
name: 'ContributionLinkList',
components: {
FigureQrCode,
},
props: {
items: { type: Array, required: true },
},
data() {
return {
fields: [
'name',
'memo',
'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') },
'delete',
'edit',
'show',
],
modalData: null,
modalDataLink: null,
}
},
methods: {
deleteContributionLink() {
this.$bvModal.msgBoxConfirm(this.$t('contributionLink.deleteNow')).then(async (value) => {
if (value)
await this.$apollo
.mutate({
mutation: deleteContributionLink,
variables: {
id: this.id,
},
})
.then(() => {
this.toastSuccess('TODO: request message deleted ')
})
.catch((err) => {
this.toastError(err.message)
})
})
},
editContributionLink(row) {
this.$emit('editContributionLinkData', row)
},
showContributionLink(row) {
this.modalData = row
this.$refs['my-modal'].show()
},
},
}
</script>

View File

@ -1,14 +1,14 @@
import { mount } from '@vue/test-utils'
import CreationFormular from './CreationFormular.vue'
import { createPendingCreation } from '../graphql/createPendingCreation'
import { createPendingCreations } from '../graphql/createPendingCreations'
import { adminCreateContribution } from '../graphql/adminCreateContribution'
import { adminCreateContributions } from '../graphql/adminCreateContributions'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
const localVue = global.localVue
const apolloMutateMock = jest.fn().mockResolvedValue({
data: {
createPendingCreation: [0, 0, 0],
adminCreateContribution: [0, 0, 0],
},
})
const stateCommitMock = jest.fn()
@ -110,7 +110,7 @@ describe('CreationFormular', () => {
it('sends ... to apollo', () => {
expect(apolloMutateMock).toBeCalledWith(
expect.objectContaining({
mutation: createPendingCreation,
mutation: adminCreateContribution,
variables: {
email: 'benjamin@bluemchen.de',
creationDate: getCreationDate(2),
@ -334,10 +334,10 @@ describe('CreationFormular', () => {
jest.clearAllMocks()
apolloMutateMock.mockResolvedValue({
data: {
createPendingCreations: {
adminCreateContributions: {
success: true,
successfulCreation: ['bob@baumeister.de', 'bibi@bloxberg.de'],
failedCreation: [],
successfulContribution: ['bob@baumeister.de', 'bibi@bloxberg.de'],
failedContribution: [],
},
},
})
@ -355,7 +355,7 @@ describe('CreationFormular', () => {
it('calls the API', () => {
expect(apolloMutateMock).toBeCalledWith(
expect.objectContaining({
mutation: createPendingCreations,
mutation: adminCreateContributions,
variables: {
pendingCreations: [
{
@ -390,10 +390,10 @@ describe('CreationFormular', () => {
jest.clearAllMocks()
apolloMutateMock.mockResolvedValue({
data: {
createPendingCreations: {
adminCreateContributions: {
success: true,
successfulCreation: [],
failedCreation: ['bob@baumeister.de', 'bibi@bloxberg.de'],
successfulContribution: [],
failedContribution: ['bob@baumeister.de', 'bibi@bloxberg.de'],
},
},
})

View File

@ -85,8 +85,8 @@
</div>
</template>
<script>
import { createPendingCreation } from '../graphql/createPendingCreation'
import { createPendingCreations } from '../graphql/createPendingCreations'
import { adminCreateContribution } from '../graphql/adminCreateContribution'
import { adminCreateContributions } from '../graphql/adminCreateContributions'
import { creationMonths } from '../mixins/creationMonths'
export default {
name: 'CreationFormular',
@ -158,25 +158,25 @@ export default {
})
this.$apollo
.mutate({
mutation: createPendingCreations,
mutation: adminCreateContributions,
variables: {
pendingCreations: submitObj,
},
fetchPolicy: 'no-cache',
})
.then((result) => {
const failedCreations = []
const failedContributions = []
this.$store.commit(
'openCreationsPlus',
result.data.createPendingCreations.successfulCreation.length,
result.data.adminCreateContributions.successfulContribution.length,
)
if (result.data.createPendingCreations.failedCreation.length > 0) {
result.data.createPendingCreations.failedCreation.forEach((email) => {
failedCreations.push(email)
if (result.data.adminCreateContributions.failedContribution.length > 0) {
result.data.adminCreateContributions.failedContribution.forEach((email) => {
failedContributions.push(email)
})
}
this.$emit('remove-all-bookmark')
this.$emit('toast-failed-creations', failedCreations)
this.$emit('toast-failed-creations', failedContributions)
})
.catch((error) => {
this.toastError(error.message)
@ -190,11 +190,11 @@ export default {
}
this.$apollo
.mutate({
mutation: createPendingCreation,
mutation: adminCreateContribution,
variables: submitObj,
})
.then((result) => {
this.$emit('update-user-data', this.item, result.data.createPendingCreation)
this.$emit('update-user-data', this.item, result.data.adminCreateContribution)
this.$store.commit('openCreationsPlus', 1)
this.toastSuccess(
this.$t('creation_form.toasted', {

View File

@ -47,200 +47,200 @@ describe('DeletedUserFormular', () => {
})
it('has a DIV element with the class.delete-user-formular', () => {
expect(wrapper.find('.deleted-user-formular').exists()).toBeTruthy()
})
})
describe('delete self', () => {
beforeEach(() => {
wrapper.setProps({
item: {
userId: 0,
},
})
expect(wrapper.find('.deleted-user-formular').exists()).toBe(true)
})
it('shows a text that you cannot delete yourself', () => {
expect(wrapper.text()).toBe('removeNotSelf')
})
})
describe('delete other user', () => {
beforeEach(() => {
wrapper.setProps({
item: {
userId: 1,
deletedAt: null,
},
})
})
it('has a checkbox', () => {
expect(wrapper.find('input[type="checkbox"]').exists()).toBeTruthy()
})
it('shows the text "delete_user"', () => {
expect(wrapper.text()).toBe('delete_user')
})
describe('click on checkbox', () => {
beforeEach(async () => {
await wrapper.find('input[type="checkbox"]').setChecked()
})
it('has a confirmation button', () => {
expect(wrapper.find('button').exists()).toBeTruthy()
})
it('has the button text "delete_user"', () => {
expect(wrapper.find('button').text()).toBe('delete_user')
})
describe('confirm delete with success', () => {
beforeEach(async () => {
await wrapper.find('button').trigger('click')
})
it('calls the API', () => {
expect(apolloMutateMock).toBeCalledWith(
expect.objectContaining({
mutation: deleteUser,
variables: {
userId: 1,
},
}),
)
})
it('emits update deleted At', () => {
expect(wrapper.emitted('updateDeletedAt')).toEqual(
expect.arrayContaining([
expect.arrayContaining([
{
userId: 1,
deletedAt: date,
},
]),
]),
)
})
it('unchecks the checkbox', () => {
expect(wrapper.find('input').attributes('checked')).toBe(undefined)
})
})
describe('confirm delete with error', () => {
beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
await wrapper.find('button').trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh no!')
})
})
describe('click on checkbox again', () => {
beforeEach(async () => {
await wrapper.find('input[type="checkbox"]').setChecked(false)
})
it('has no confirmation button anymore', () => {
expect(wrapper.find('button').exists()).toBeFalsy()
})
})
})
})
describe('recover user', () => {
beforeEach(() => {
wrapper.setProps({
item: {
userId: 1,
deletedAt: date,
},
})
})
it('has a checkbox', () => {
expect(wrapper.find('input[type="checkbox"]').exists()).toBeTruthy()
})
it('shows the text "undelete_user"', () => {
expect(wrapper.text()).toBe('undelete_user')
})
describe('click on checkbox', () => {
beforeEach(async () => {
apolloMutateMock.mockResolvedValue({
data: {
unDeleteUser: null,
describe('delete self', () => {
beforeEach(() => {
wrapper.setProps({
item: {
userId: 0,
},
})
await wrapper.find('input[type="checkbox"]').setChecked()
})
it('has a confirmation button', () => {
expect(wrapper.find('button').exists()).toBeTruthy()
it('shows a text that you cannot delete yourself', () => {
expect(wrapper.text()).toBe('removeNotSelf')
})
})
describe('delete other user', () => {
beforeEach(() => {
wrapper.setProps({
item: {
userId: 1,
deletedAt: null,
},
})
})
it('has the button text "undelete_user"', () => {
expect(wrapper.find('button').text()).toBe('undelete_user')
it('has a checkbox', () => {
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
})
describe('confirm recover with success', () => {
it('shows the text "delete_user"', () => {
expect(wrapper.text()).toBe('delete_user')
})
describe('click on checkbox', () => {
beforeEach(async () => {
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="checkbox"]').setChecked()
})
it('calls the API', () => {
expect(apolloMutateMock).toBeCalledWith(
expect.objectContaining({
mutation: unDeleteUser,
variables: {
userId: 1,
},
}),
)
it('has a confirmation button', () => {
expect(wrapper.find('button').exists()).toBe(true)
})
it('emits update deleted At', () => {
expect(wrapper.emitted('updateDeletedAt')).toEqual(
expect.arrayContaining([
expect.arrayContaining([
{
it('has the button text "delete_user"', () => {
expect(wrapper.find('button').text()).toBe('delete_user')
})
describe('confirm delete with success', () => {
beforeEach(async () => {
await wrapper.find('button').trigger('click')
})
it('calls the API', () => {
expect(apolloMutateMock).toBeCalledWith(
expect.objectContaining({
mutation: deleteUser,
variables: {
userId: 1,
deletedAt: null,
},
}),
)
})
it('emits update deleted At', () => {
expect(wrapper.emitted('updateDeletedAt')).toEqual(
expect.arrayContaining([
expect.arrayContaining([
{
userId: 1,
deletedAt: date,
},
]),
]),
]),
)
)
})
it('unchecks the checkbox', () => {
expect(wrapper.find('input').attributes('checked')).toBe(undefined)
})
})
it('unchecks the checkbox', () => {
expect(wrapper.find('input').attributes('checked')).toBe(undefined)
describe('confirm delete with error', () => {
beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
await wrapper.find('button').trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh no!')
})
})
describe('click on checkbox again', () => {
beforeEach(async () => {
await wrapper.find('input[type="checkbox"]').setChecked(false)
})
it('has no confirmation button anymore', () => {
expect(wrapper.find('button').exists()).toBe(false)
})
})
})
})
describe('recover user', () => {
beforeEach(() => {
wrapper.setProps({
item: {
userId: 1,
deletedAt: date,
},
})
})
describe('confirm recover with error', () => {
beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
await wrapper.find('button').trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh no!')
})
it('has a checkbox', () => {
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
})
describe('click on checkbox again', () => {
it('shows the text "undelete_user"', () => {
expect(wrapper.text()).toBe('undelete_user')
})
describe('click on checkbox', () => {
beforeEach(async () => {
await wrapper.find('input[type="checkbox"]').setChecked(false)
apolloMutateMock.mockResolvedValue({
data: {
unDeleteUser: null,
},
})
await wrapper.find('input[type="checkbox"]').setChecked()
})
it('has no confirmation button anymore', () => {
expect(wrapper.find('button').exists()).toBeFalsy()
it('has a confirmation button', () => {
expect(wrapper.find('button').exists()).toBe(true)
})
it('has the button text "undelete_user"', () => {
expect(wrapper.find('button').text()).toBe('undelete_user')
})
describe('confirm recover with success', () => {
beforeEach(async () => {
await wrapper.find('button').trigger('click')
})
it('calls the API', () => {
expect(apolloMutateMock).toBeCalledWith(
expect.objectContaining({
mutation: unDeleteUser,
variables: {
userId: 1,
},
}),
)
})
it('emits update deleted At', () => {
expect(wrapper.emitted('updateDeletedAt')).toEqual(
expect.arrayContaining([
expect.arrayContaining([
{
userId: 1,
deletedAt: null,
},
]),
]),
)
})
it('unchecks the checkbox', () => {
expect(wrapper.find('input').attributes('checked')).toBe(undefined)
})
})
describe('confirm recover with error', () => {
beforeEach(async () => {
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
await wrapper.find('button').trigger('click')
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh no!')
})
})
describe('click on checkbox again', () => {
beforeEach(async () => {
await wrapper.find('input[type="checkbox"]').setChecked(false)
})
it('has no confirmation button anymore', () => {
expect(wrapper.find('button').exists()).toBe(false)
})
})
})
})

View File

@ -28,6 +28,7 @@ export default {
props: {
item: {
type: Object,
required: true,
},
},
data() {

View File

@ -6,7 +6,7 @@ const localVue = global.localVue
const apolloMutateMock = jest.fn().mockResolvedValue({
data: {
updatePendingCreation: {
adminUpdateContribution: {
creation: [0, 0, 0],
amount: 500,
date: new Date(),

View File

@ -73,7 +73,7 @@
</div>
</template>
<script>
import { updatePendingCreation } from '../graphql/updatePendingCreation'
import { adminUpdateContribution } from '../graphql/adminUpdateContribution'
import { creationMonths } from '../mixins/creationMonths'
export default {
@ -103,7 +103,7 @@ export default {
data() {
return {
text: !this.creationUserData.memo ? '' : this.creationUserData.memo,
value: !this.creationUserData.amount ? 0 : this.creationUserData.amount,
value: !this.creationUserData.amount ? 0 : Number(this.creationUserData.amount),
rangeMin: 0,
rangeMax: 1000,
selected: '',
@ -113,7 +113,7 @@ export default {
submitCreation() {
this.$apollo
.mutate({
mutation: updatePendingCreation,
mutation: adminUpdateContribution,
variables: {
id: this.item.id,
email: this.item.email,
@ -123,11 +123,11 @@ export default {
},
})
.then((result) => {
this.$emit('update-user-data', this.item, result.data.updatePendingCreation.creation)
this.$emit('update-user-data', this.item, result.data.adminUpdateContribution.creation)
this.$emit('update-creation-data', {
amount: Number(result.data.updatePendingCreation.amount),
date: result.data.updatePendingCreation.date,
memo: result.data.updatePendingCreation.memo,
amount: Number(result.data.adminUpdateContribution.amount),
date: result.data.adminUpdateContribution.date,
memo: result.data.adminUpdateContribution.memo,
row: this.row,
})
this.toastSuccess(
@ -155,7 +155,7 @@ export default {
const month = this.$d(new Date(this.creationUserData.date), 'month')
const index = this.radioOptions.findIndex((obj) => obj.item.short === month)
this.selected = this.radioOptions[index].item
this.rangeMax = this.creation[index] + this.creationUserData.amount
this.rangeMax = Number(this.creation[index]) + Number(this.creationUserData.amount)
}
},
}

View File

@ -0,0 +1,30 @@
import { mount } from '@vue/test-utils'
import FigureQrCode from './FigureQrCode.vue'
const localVue = global.localVue
const propsData = {
link: '',
}
describe('FigureQrCode', () => {
let wrapper
const Wrapper = () => {
return mount(FigureQrCode, { localVue, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".figure-qr-code"', () => {
expect(wrapper.find('div.figure-qr-code').exists()).toBe(true)
})
it('renders the QRCanvas Element ".canvas"', () => {
expect(wrapper.find('.canvas').exists()).toBe(true)
})
})
})

View File

@ -0,0 +1,56 @@
<template>
<div class="figure-qr-code">
<div class="qrbox">
<q-r-canvas :options="options" class="canvas" />
</div>
</div>
</template>
<script>
import { QRCanvas } from 'qrcanvas-vue'
export default {
name: 'FigureQrCode',
components: {
QRCanvas,
},
props: {
link: { type: String, required: true },
},
data() {
return {
options: {
cellSize: 8,
correctLevel: 'H',
data: this.link,
logo: {
image: null,
},
},
}
},
created() {
const image = new Image()
image.src = 'img/gdd-coin.png'
image.onload = () => {
this.options = {
...this.options,
logo: {
image,
},
}
}
},
}
</script>
<style scoped>
.qrbox {
padding: 20px;
background-color: rgb(255, 255, 255);
}
.canvas {
width: 90%;
max-width: 300px;
padding: 5px;
background-color: rgb(255, 255, 255);
}
</style>

View File

@ -69,6 +69,7 @@ const propsData = {
{ key: 'edit_creation', label: 'edit' },
{ key: 'confirm', label: 'save' },
],
toggleDetails: false,
}
const mocks = {
@ -101,7 +102,7 @@ describe('OpenCreationsTable', () => {
})
it('has a DIV element with the class .open-creations-table', () => {
expect(wrapper.find('div.open-creations-table').exists()).toBeTruthy()
expect(wrapper.find('div.open-creations-table').exists()).toBe(true)
})
it('has a table with three rows', () => {
@ -109,7 +110,7 @@ describe('OpenCreationsTable', () => {
})
it('find first button.bi-pencil-square for open EditCreationFormular ', () => {
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBeTruthy()
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBe(true)
})
describe('show edit details', () => {
@ -122,7 +123,15 @@ describe('OpenCreationsTable', () => {
})
it.skip('renders the component component-edit-creation-formular', () => {
expect(wrapper.find('div.component-edit-creation-formular').exists()).toBeTruthy()
expect(wrapper.find('div.component-edit-creation-formular').exists()).toBe(true)
})
})
describe('call updateUserData', () => {
it('user creations has updated data', async () => {
wrapper.vm.updateUserData(propsData.items[0], [444, 555, 666])
await wrapper.vm.$nextTick()
expect(wrapper.vm.items[0].creation).toEqual([444, 555, 666])
})
})
})

View File

@ -70,12 +70,23 @@ export default {
required: true,
},
},
data() {
return {
creationUserData: {
amount: null,
date: null,
memo: null,
moderator: null,
},
}
},
methods: {
updateCreationData(data) {
this.creationUserData.amount = data.amount
this.creationUserData.date = data.date
this.creationUserData.memo = data.memo
this.creationUserData.moderator = data.moderator
this.creationUserData = data
// this.creationUserData.amount = data.amount
// this.creationUserData.date = data.date
// this.creationUserData.memo = data.memo
// this.creationUserData.moderator = data.moderator
data.row.toggleDetails()
},
updateUserData(rowItem, newCreation) {

View File

@ -1,8 +1,6 @@
import { mount } from '@vue/test-utils'
import SearchUserTable from './SearchUserTable.vue'
const date = new Date()
const localVue = global.localVue
const apolloMutateMock = jest.fn().mockResolvedValue({})
@ -96,16 +94,29 @@ describe('SearchUserTable', () => {
await wrapper.findAll('tbody > tr').at(1).trigger('click')
})
describe('isAdmin', () => {
beforeEach(async () => {
await wrapper.find('div.change-user-role-formular').vm.$emit('updateIsAdmin', {
userId: 1,
isAdmin: new Date(),
})
})
it('emits updateIsAdmin', () => {
expect(wrapper.emitted('updateIsAdmin')).toEqual([[1, expect.any(Date)]])
})
})
describe('deleted at', () => {
beforeEach(async () => {
await wrapper.find('div.deleted-user-formular').vm.$emit('updateDeletedAt', {
userId: 1,
deletedAt: date,
deletedAt: new Date(),
})
})
it('emits updateDeletedAt', () => {
expect(wrapper.emitted('updateDeletedAt')).toEqual([[1, date]])
expect(wrapper.emitted('updateDeletedAt')).toEqual([[1, expect.any(Date)]])
})
})

View File

@ -18,7 +18,7 @@
<template #cell(status)="row">
<div class="text-right">
<b-avatar v-if="row.item.deletedAt" class="mr-3" variant="light">
<b-avatar v-if="row.item.deletedAt" class="mr-3 test-deleted-icon" variant="light">
<b-iconstack font-scale="2">
<b-icon stacked icon="person" variant="info" scale="0.75"></b-icon>
<b-icon stacked icon="slash-circle" variant="danger"></b-icon>
@ -79,6 +79,9 @@
<b-tab :title="$t('transactionlink.name')" :disabled="row.item.deletedAt !== null">
<transaction-link-list v-if="!row.item.deletedAt" :userId="row.item.userId" />
</b-tab>
<b-tab :title="$t('userRole.tabTitle')">
<change-user-role-formular :item="row.item" @updateIsAdmin="updateIsAdmin" />
</b-tab>
<b-tab :title="$t('delete_user')">
<deleted-user-formular :item="row.item" @updateDeletedAt="updateDeletedAt" />
</b-tab>
@ -93,6 +96,7 @@ import CreationFormular from '../CreationFormular.vue'
import ConfirmRegisterMailFormular from '../ConfirmRegisterMailFormular.vue'
import CreationTransactionList from '../CreationTransactionList.vue'
import TransactionLinkList from '../TransactionLinkList.vue'
import ChangeUserRoleFormular from '../ChangeUserRoleFormular.vue'
import DeletedUserFormular from '../DeletedUserFormular.vue'
export default {
@ -102,6 +106,7 @@ export default {
ConfirmRegisterMailFormular,
CreationTransactionList,
TransactionLinkList,
ChangeUserRoleFormular,
DeletedUserFormular,
},
props: {
@ -123,6 +128,9 @@ export default {
updateUserData(rowItem, newCreation) {
rowItem.creation = newCreation
},
updateIsAdmin({ userId, isAdmin }) {
this.$emit('updateIsAdmin', userId, isAdmin)
},
updateDeletedAt({ userId, deletedAt }) {
this.$emit('updateDeletedAt', userId, deletedAt)
},

View File

@ -0,0 +1,12 @@
import gql from 'graphql-tag'
export const adminCreateContribution = gql`
mutation ($email: String!, $amount: Decimal!, $memo: String!, $creationDate: String!) {
adminCreateContribution(
email: $email
amount: $amount
memo: $memo
creationDate: $creationDate
)
}
`

View File

@ -0,0 +1,11 @@
import gql from 'graphql-tag'
export const adminCreateContributions = gql`
mutation ($pendingCreations: [AdminCreateContributionArgs!]!) {
adminCreateContributions(pendingCreations: $pendingCreations) {
success
successfulContribution
failedContribution
}
}
`

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const adminDeleteContribution = gql`
mutation ($id: Int!) {
adminDeleteContribution(id: $id)
}
`

View File

@ -1,8 +1,8 @@
import gql from 'graphql-tag'
export const updatePendingCreation = gql`
export const adminUpdateContribution = gql`
mutation ($id: Int!, $email: String!, $amount: Decimal!, $memo: String!, $creationDate: String!) {
updatePendingCreation(
adminUpdateContribution(
id: $id
email: $email
amount: $amount

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const confirmContribution = gql`
mutation ($id: Int!) {
confirmContribution(id: $id)
}
`

View File

@ -1,7 +0,0 @@
import gql from 'graphql-tag'
export const confirmPendingCreation = gql`
mutation ($id: Int!) {
confirmPendingCreation(id: $id)
}
`

View File

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

View File

@ -1,7 +0,0 @@
import gql from 'graphql-tag'
export const createPendingCreation = gql`
mutation ($email: String!, $amount: Decimal!, $memo: String!, $creationDate: String!) {
createPendingCreation(email: $email, amount: $amount, memo: $memo, creationDate: $creationDate)
}
`

View File

@ -1,11 +0,0 @@
import gql from 'graphql-tag'
export const createPendingCreations = gql`
mutation ($pendingCreations: [CreatePendingCreationArgs!]!) {
createPendingCreations(pendingCreations: $pendingCreations) {
success
successfulCreation
failedCreation
}
}
`

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const deleteContributionLink = gql`
mutation ($id: Int!) {
deleteContributionLink(id: $id)
}
`

View File

@ -1,7 +0,0 @@
import gql from 'graphql-tag'
export const deletePendingCreation = gql`
mutation ($id: Int!) {
deletePendingCreation(id: $id)
}
`

View File

@ -0,0 +1,23 @@
import gql from 'graphql-tag'
export const listContributionLinks = gql`
query ($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {
listContributionLinks(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
links {
id
amount
name
memo
code
link
createdAt
validFrom
validTo
maxAmountPerMonth
cycle
maxPerCycle
}
count
}
}
`

View File

@ -2,7 +2,12 @@ import gql from 'graphql-tag'
export const listTransactionLinksAdmin = gql`
query ($currentPage: Int = 1, $pageSize: Int = 5, $userId: Int!) {
listTransactionLinksAdmin(currentPage: $currentPage, pageSize: $pageSize, userId: $userId) {
listTransactionLinksAdmin(
currentPage: $currentPage
pageSize: $pageSize
userId: $userId
filters: { withRedeemed: true, withExpired: true, withDeleted: true }
) {
linkCount
linkList {
id

View File

@ -1,8 +1,8 @@
import gql from 'graphql-tag'
export const getPendingCreations = gql`
export const listUnconfirmedContributions = gql`
query {
getPendingCreations {
listUnconfirmedContributions {
id
firstName
lastName

View File

@ -1,19 +1,12 @@
import gql from 'graphql-tag'
export const searchUsers = gql`
query (
$searchText: String!
$currentPage: Int
$pageSize: Int
$filterByActivated: Boolean
$filterByDeleted: Boolean
) {
query ($searchText: String!, $currentPage: Int, $pageSize: Int, $filters: SearchUsersFilters) {
searchUsers(
searchText: $searchText
currentPage: $currentPage
pageSize: $pageSize
filterByActivated: $filterByActivated
filterByDeleted: $filterByDeleted
filters: $filters
) {
userCount
userList {
@ -26,6 +19,7 @@ export const searchUsers = gql`
hasElopage
emailConfirmationSend
deletedAt
isAdmin
}
}
}

View File

@ -0,0 +1,7 @@
import gql from 'graphql-tag'
export const setUserRole = gql`
mutation ($userId: Int!, $isAdmin: Boolean!) {
setUserRole(userId: $userId, isAdmin: $isAdmin)
}
`

View File

@ -0,0 +1,18 @@
import gql from 'graphql-tag'
export const showContributionLink = gql`
query ($id: Int!) {
showContributionLink {
id
validFrom
validTo
name
memo
amount
cycle
maxPerCycle
maxAmountPerMonth
code
}
}
`

View File

@ -1,6 +1,35 @@
{
"all_emails": "Alle Nutzer",
"back": "zurück",
"contributionLink": {
"amount": "Betrag",
"clear": "Löschen",
"contributionLinks": "Beitragslinks",
"create": "Anlegen",
"cycle": "Zyklus",
"deleteNow": "Automatische Creations wirklich löschen?",
"maximumAmount": "maximaler Betrag",
"maxPerCycle": "Wiederholungen",
"memo": "Nachricht",
"name": "Name",
"newContributionLink": "Neuer Beitragslink",
"noContributionLinks": "Es sind keine Beitragslinks angelegt.",
"noDateSelected": "Kein Datum ausgewählt",
"noEndDate": "Kein Enddatum gewählt.",
"noStartDate": "Kein Startdatum gewählt.",
"options": {
"cycle": {
"daily": "täglich",
"hourly": "stündlich",
"monthly": "monatlich",
"once": "einmalig",
"weekly": "wöchentlich",
"yearly": "jährlich"
}
},
"validFrom": "Startdatum",
"validTo": "Enddatum"
},
"creation": "Schöpfung",
"creationList": "Schöpfungsliste",
"creation_form": {
@ -44,7 +73,8 @@
"lastname": "Nachname",
"math": {
"exclaim": "!",
"pipe": "|"
"pipe": "|",
"plus": "+"
},
"moderator": "Moderator",
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
@ -71,7 +101,7 @@
},
"redeemed": "eingelöst",
"remove": "Entfernen",
"removeNotSelf": "Als Admin / Moderator kannst du dich nicht selber löschen.",
"removeNotSelf": "Als Admin/Moderator kannst du dich nicht selber löschen.",
"remove_all": "alle Nutzer entfernen",
"save": "Speichern",
"status": "Status",
@ -101,6 +131,16 @@
"text_false": " Die letzte Email wurde am {date} Uhr an das Mitglied ({email}) gesendet.",
"text_true": " Die Email wurde bestätigt."
},
"userRole": {
"notChangeYourSelf": "Als Admin/Moderator kannst du nicht selber deine Rolle ändern.",
"selectLabel": "Rolle:",
"selectRoles": {
"admin": "Administrator",
"user": "einfacher Nutzer"
},
"successfullyChangedTo": "Nutzer ist jetzt „{role}“.",
"tabTitle": "Nutzer-Rolle"
},
"user_deleted": "Nutzer ist gelöscht.",
"user_recovered": "Nutzer ist wiederhergestellt.",
"user_search": "Nutzer-Suche"

View File

@ -1,6 +1,35 @@
{
"all_emails": "All users",
"back": "back",
"contributionLink": {
"amount": "Amount",
"clear": "Clear",
"contributionLinks": "Contribution Links",
"create": "Create",
"cycle": "Cycle",
"deleteNow": "Do you really delete automatic creations?",
"maximumAmount": "Maximum amount",
"maxPerCycle": "Repetition",
"memo": "Memo",
"name": "Name",
"newContributionLink": "New contribution link",
"noContributionLinks": "No contribution link has been created.",
"noDateSelected": "No date selected",
"noEndDate": "No end-date",
"noStartDate": "No start-date",
"options": {
"cycle": {
"daily": "daily",
"hourly": "hourly",
"monthly": "monthly",
"once": "once",
"weekly": "weekly",
"yearly": "yearly"
}
},
"validFrom": "Start-date",
"validTo": "End-Date"
},
"creation": "Creation",
"creationList": "Creation list",
"creation_form": {
@ -44,7 +73,8 @@
"lastname": "Lastname",
"math": {
"exclaim": "!",
"pipe": "|"
"pipe": "|",
"plus": "+"
},
"moderator": "Moderator",
"multiple_creation_text": "Please select one or more members for which you would like to perform creations.",
@ -71,7 +101,7 @@
},
"redeemed": "redeemed",
"remove": "Remove",
"removeNotSelf": "As admin / moderator you cannot delete yourself.",
"removeNotSelf": "As an admin/moderator, you cannot delete yourself.",
"remove_all": "Remove all users",
"save": "Speichern",
"status": "Status",
@ -101,6 +131,16 @@
"text_false": "The last email was sent to the member ({email}) on {date}.",
"text_true": "The email was confirmed."
},
"userRole": {
"notChangeYourSelf": "As an admin/moderator, you cannot change your own role.",
"selectLabel": "Role:",
"selectRoles": {
"admin": "administrator",
"user": "usual user"
},
"successfullyChangedTo": "User is now \"{role}\".",
"tabTitle": "User Role"
},
"user_deleted": "User is deleted.",
"user_recovered": "User is recovered.",
"user_search": "User search"

View File

@ -71,8 +71,10 @@ describe('Creation', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
filterByActivated: true,
filterByDeleted: false,
filters: {
byActivated: true,
byDeleted: false,
},
},
}),
)
@ -271,8 +273,10 @@ describe('Creation', () => {
searchText: 'XX',
currentPage: 1,
pageSize: 25,
filterByActivated: true,
filterByDeleted: false,
filters: {
byActivated: true,
byDeleted: false,
},
},
}),
)
@ -288,8 +292,10 @@ describe('Creation', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
filterByActivated: true,
filterByDeleted: false,
filters: {
byActivated: true,
byDeleted: false,
},
},
}),
)
@ -305,8 +311,10 @@ describe('Creation', () => {
searchText: '',
currentPage: 2,
pageSize: 25,
filterByActivated: true,
filterByDeleted: false,
filters: {
byActivated: true,
byDeleted: false,
},
},
}),
)

View File

@ -102,8 +102,10 @@ export default {
searchText: this.criteria,
currentPage: this.currentPage,
pageSize: this.perPage,
filterByActivated: true,
filterByDeleted: false,
filters: {
byActivated: true,
byDeleted: false,
},
},
fetchPolicy: 'network-only',
})

View File

@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import CreationConfirm from './CreationConfirm.vue'
import { deletePendingCreation } from '../graphql/deletePendingCreation'
import { confirmPendingCreation } from '../graphql/confirmPendingCreation'
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { confirmContribution } from '../graphql/confirmContribution'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
const localVue = global.localVue
@ -9,7 +9,7 @@ const localVue = global.localVue
const storeCommitMock = jest.fn()
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
getPendingCreations: [
listUnconfirmedContributions: [
{
id: 1,
firstName: 'Bibi',
@ -84,9 +84,9 @@ describe('CreationConfirm', () => {
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
it('calls the deletePendingCreation mutation', () => {
it('calls the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: deletePendingCreation,
mutation: adminDeleteContribution,
variables: { id: 1 },
})
})
@ -141,9 +141,9 @@ describe('CreationConfirm', () => {
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
it('calls the confirmPendingCreation mutation', () => {
it('calls the confirmContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: confirmPendingCreation,
mutation: confirmContribution,
variables: { id: 2 },
})
})

View File

@ -15,9 +15,9 @@
<script>
import Overlay from '../components/Overlay.vue'
import OpenCreationsTable from '../components/Tables/OpenCreationsTable.vue'
import { getPendingCreations } from '../graphql/getPendingCreations'
import { deletePendingCreation } from '../graphql/deletePendingCreation'
import { confirmPendingCreation } from '../graphql/confirmPendingCreation'
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { confirmContribution } from '../graphql/confirmContribution'
export default {
name: 'CreationConfirm',
@ -36,7 +36,7 @@ export default {
removeCreation(item) {
this.$apollo
.mutate({
mutation: deletePendingCreation,
mutation: adminDeleteContribution,
variables: {
id: item.id,
},
@ -52,7 +52,7 @@ export default {
confirmCreation() {
this.$apollo
.mutate({
mutation: confirmPendingCreation,
mutation: confirmContribution,
variables: {
id: this.item.id,
},
@ -70,13 +70,13 @@ export default {
getPendingCreations() {
this.$apollo
.query({
query: getPendingCreations,
query: listUnconfirmedContributions,
fetchPolicy: 'network-only',
})
.then((result) => {
this.$store.commit('resetOpenCreations')
this.pendingCreations = result.data.getPendingCreations
this.$store.commit('setOpenCreations', result.data.getPendingCreations.length)
this.pendingCreations = result.data.listUnconfirmedContributions
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
})
.catch((error) => {
this.toastError(error.message)

View File

@ -5,7 +5,7 @@ const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
getPendingCreations: [
listUnconfirmedContributions: [
{
pending: true,
},
@ -46,7 +46,7 @@ describe('Overview', () => {
wrapper = Wrapper()
})
it('calls getPendingCreations', () => {
it('calls listUnconfirmedContributions', () => {
expect(apolloQueryMock).toBeCalled()
})

View File

@ -28,27 +28,54 @@
</b-link>
</b-card-text>
</b-card>
<contribution-link :items="items" :count="count" />
</div>
</template>
<script>
import { getPendingCreations } from '../graphql/getPendingCreations'
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import ContributionLink from '../components/ContributionLink.vue'
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
export default {
name: 'overview',
components: {
ContributionLink,
},
data() {
return {
items: [],
count: 0,
}
},
methods: {
async getPendingCreations() {
this.$apollo
.query({
query: getPendingCreations,
query: listUnconfirmedContributions,
fetchPolicy: 'network-only',
})
.then((result) => {
this.$store.commit('setOpenCreations', result.data.getPendingCreations.length)
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
})
},
async 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.getPendingCreations()
this.getContributionLinks()
},
}
</script>

View File

@ -7,7 +7,7 @@ const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
searchUsers: {
userCount: 1,
userCount: 4,
userList: [
{
userId: 1,
@ -82,8 +82,10 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
filterByActivated: null,
filterByDeleted: null,
filters: {
byActivated: null,
byDeleted: null,
},
},
}),
)
@ -101,8 +103,10 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
filterByActivated: false,
filterByDeleted: null,
filters: {
byActivated: false,
byDeleted: null,
},
},
}),
)
@ -121,8 +125,10 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
filterByActivated: null,
filterByDeleted: true,
filters: {
byActivated: null,
byDeleted: true,
},
},
}),
)
@ -141,8 +147,10 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 2,
pageSize: 25,
filterByActivated: null,
filterByDeleted: null,
filters: {
byActivated: null,
byDeleted: null,
},
},
}),
)
@ -161,8 +169,10 @@ describe('UserSearch', () => {
searchText: 'search string',
currentPage: 1,
pageSize: 25,
filterByActivated: null,
filterByDeleted: null,
filters: {
byActivated: null,
byDeleted: null,
},
},
}),
)
@ -178,8 +188,10 @@ describe('UserSearch', () => {
searchText: '',
currentPage: 1,
pageSize: 25,
filterByActivated: null,
filterByDeleted: null,
filters: {
byActivated: null,
byDeleted: null,
},
},
}),
)
@ -187,14 +199,43 @@ describe('UserSearch', () => {
})
})
describe('change user role', () => {
const userId = 4
describe('to admin', () => {
it('updates user role to admin', async () => {
await wrapper
.findComponent({ name: 'SearchUserTable' })
.vm.$emit('updateIsAdmin', userId, new Date())
expect(wrapper.vm.searchResult.find((obj) => obj.userId === userId).isAdmin).toEqual(
expect.any(Date),
)
})
})
describe('to usual user', () => {
it('updates user role to usual user', async () => {
await wrapper
.findComponent({ name: 'SearchUserTable' })
.vm.$emit('updateIsAdmin', userId, null)
expect(wrapper.vm.searchResult.find((obj) => obj.userId === userId).isAdmin).toEqual(null)
})
})
})
describe('delete user', () => {
const now = new Date()
beforeEach(async () => {
wrapper.findComponent({ name: 'SearchUserTable' }).vm.$emit('updateDeletedAt', 4, now)
const userId = 4
beforeEach(() => {
wrapper
.findComponent({ name: 'SearchUserTable' })
.vm.$emit('updateDeletedAt', userId, new Date())
})
it('marks the user as deleted', () => {
expect(wrapper.vm.searchResult.find((obj) => obj.userId === 4).deletedAt).toEqual(now)
expect(wrapper.vm.searchResult.find((obj) => obj.userId === userId).deletedAt).toEqual(
expect.any(Date),
)
expect(wrapper.find('.test-deleted-icon').exists()).toBe(true)
})
it('toasts a success message', () => {

View File

@ -4,9 +4,9 @@
<b-button class="unconfirmedRegisterMails" variant="light" @click="unconfirmedRegisterMails">
<b-icon icon="envelope" variant="danger"></b-icon>
{{
filterByActivated === null
filters.byActivated === null
? $t('all_emails')
: filterByActivated === false
: filters.byActivated === false
? $t('unregistered_emails')
: ''
}}
@ -14,9 +14,9 @@
<b-button class="deletedUserSearch" variant="light" @click="deletedUserSearch">
<b-icon icon="x-circle" variant="danger"></b-icon>
{{
filterByDeleted === null
filters.byDeleted === null
? $t('all_emails')
: filterByDeleted === true
: filters.byDeleted === true
? $t('deleted_user')
: ''
}}
@ -42,6 +42,7 @@
type="PageUserSearch"
:items="searchResult"
:fields="fields"
@updateIsAdmin="updateIsAdmin"
@updateDeletedAt="updateDeletedAt"
/>
<b-pagination
@ -72,8 +73,10 @@ export default {
searchResult: [],
massCreation: [],
criteria: '',
filterByActivated: null,
filterByDeleted: null,
filters: {
byActivated: null,
byDeleted: null,
},
rows: 0,
currentPage: 1,
perPage: 25,
@ -82,11 +85,11 @@ export default {
},
methods: {
unconfirmedRegisterMails() {
this.filterByActivated = this.filterByActivated === null ? false : null
this.filters.byActivated = this.filters.byActivated === null ? false : null
this.getUsers()
},
deletedUserSearch() {
this.filterByDeleted = this.filterByDeleted === null ? true : null
this.filters.byDeleted = this.filters.byDeleted === null ? true : null
this.getUsers()
},
getUsers() {
@ -97,8 +100,7 @@ export default {
searchText: this.criteria,
currentPage: this.currentPage,
pageSize: this.perPage,
filterByActivated: this.filterByActivated,
filterByDeleted: this.filterByDeleted,
filters: this.filters,
},
fetchPolicy: 'no-cache',
})
@ -110,6 +112,9 @@ export default {
this.toastError(error.message)
})
},
updateIsAdmin(userId, isAdmin) {
this.searchResult.find((obj) => obj.userId === userId).isAdmin = isAdmin
},
updateDeletedAt(userId, deletedAt) {
this.searchResult.find((obj) => obj.userId === userId).deletedAt = deletedAt
this.toastSuccess(deletedAt ? this.$t('user_deleted') : this.$t('user_recovered'))

View File

@ -932,6 +932,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.11.2", "@babel/runtime@^7.16.0":
version "7.18.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.3.tgz#c7b654b57f6f63cf7f8b418ac9ca04408c4579f4"
integrity sha512-38Y8f7YUhce/K7RMwTp7m0uCumpv9hZkitCbBClqQIow1qSbCvGkcegKOXpEWCQLfWmevgRiWokZ1GkpfhbZug==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.14.0":
version "7.17.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825"
@ -4082,9 +4089,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001271:
version "1.0.30001271"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001271.tgz#0dda0c9bcae2cf5407cd34cac304186616cc83e8"
integrity sha512-BBruZFWmt3HFdVPS8kceTBIguKxu4f99n5JNp06OlPD/luoAMIaIK5ieV5YjnBLH3Nysai9sxj9rpJj4ZisXOA==
version "1.0.30001354"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001354.tgz"
integrity sha512-mImKeCkyGDAHNywYFA4bqnLAzTUvVkqPvhY4DV47X+Gl2c5Z8c3KNETnXp14GQt11LvxE8AwjzGxJ+rsikiOzg==
capture-exit@^2.0.0:
version "2.0.0"
@ -4397,7 +4404,7 @@ color-name@1.1.3:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
color-name@^1.0.0, color-name@~1.1.4:
color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
@ -4845,6 +4852,11 @@ cssesc@^3.0.0:
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
cssfontparser@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/cssfontparser/-/cssfontparser-1.2.1.tgz#f4022fc8f9700c68029d542084afbaf425a3f3e3"
integrity sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==
cssnano-preset-default@^4.0.0, cssnano-preset-default@^4.0.8:
version "4.0.8"
resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz#920622b1fc1e95a34e8838203f1397a504f2d3ff"
@ -7821,6 +7833,14 @@ javascript-stringify@^2.0.1:
resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
integrity sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==
jest-canvas-mock@^2.3.1:
version "2.4.0"
resolved "https://registry.yarnpkg.com/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz#947b71442d7719f8e055decaecdb334809465341"
integrity sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==
dependencies:
cssfontparser "^1.2.1"
moo-color "^1.0.2"
jest-changed-files@^24.9.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039"
@ -9478,6 +9498,13 @@ mkdirp@0.x, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1:
dependencies:
minimist "^1.2.5"
moo-color@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/moo-color/-/moo-color-1.0.3.tgz#d56435f8359c8284d83ac58016df7427febece74"
integrity sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==
dependencies:
color-name "^1.1.4"
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@ -10959,6 +10986,27 @@ q@^1.1.2:
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
qrcanvas-vue@2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/qrcanvas-vue/-/qrcanvas-vue-2.1.1.tgz#27b449f99eaf46f324b300215469bfdf8ef77d88"
integrity sha512-86NMjOJ5XJGrrqrD2t+zmZxZKNuW1Is7o88UOiM8qFxDBjuTyfq9VJE9/2rN5XxThsjBuY4bRrQqL9blVwnI9w==
dependencies:
"@babel/runtime" "^7.16.0"
qrcanvas "^3.1.2"
qrcanvas@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/qrcanvas/-/qrcanvas-3.1.2.tgz#81a25e91b2c27e9ace91da95591cbfb100d68702"
integrity sha512-lNcAyCHN0Eno/mJ5eBc7lHV/5ejAJxII0UELthG3bNnlLR+u8hCc7CR+hXBawbYUf96kNIosXfG2cJzx92ZWKg==
dependencies:
"@babel/runtime" "^7.11.2"
qrcode-generator "^1.4.4"
qrcode-generator@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/qrcode-generator/-/qrcode-generator-1.4.4.tgz#63f771224854759329a99048806a53ed278740e7"
integrity sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==
qs@6.7.0:
version "6.7.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v6.2022-04-21
CONFIG_VERSION=v10.2022-19-07
# Server
PORT=4000
@ -51,7 +51,14 @@ EMAIL_CODE_REQUEST_TIME=10
# Webhook
WEBHOOK_ELOPAGE_SECRET=secret
# EventProtocol
EVENT_PROTOCOL_DISABLED=false
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
# LOG_LEVEL=info
# DHT
# if you set this value, the DHT hyperswarm will start to announce and listen
# on an hash created from this tpoic
# DHT_TOPIC=GRADIDO_HUB
# DHT_TOPIC=GRADIDO_HUB

View File

@ -27,6 +27,7 @@ COMMUNITY_NAME=$COMMUNITY_NAME
COMMUNITY_URL=$COMMUNITY_URL
COMMUNITY_REGISTER_URL=$COMMUNITY_REGISTER_URL
COMMUNITY_REDEEM_URL=$COMMUNITY_REDEEM_URL
COMMUNITY_REDEEM_CONTRIBUTION_URL=$COMMUNITY_REDEEM_CONTRIBUTION_URL
COMMUNITY_DESCRIPTION=$COMMUNITY_DESCRIPTION
# Login Server
@ -42,6 +43,7 @@ EMAIL_SMTP_URL=$EMAIL_SMTP_URL
EMAIL_SMTP_PORT=587
EMAIL_LINK_VERIFICATION=$EMAIL_LINK_VERIFICATION
EMAIL_LINK_SETPASSWORD=$EMAIL_LINK_SETPASSWORD
EMAIL_LINK_FORGOTPASSWORD=$EMAIL_LINK_FORGOTPASSWORD
EMAIL_LINK_OVERVIEW=$EMAIL_LINK_OVERVIEW
EMAIL_CODE_VALID_TIME=$EMAIL_CODE_VALID_TIME
EMAIL_CODE_REQUEST_TIME=$EMAIL_CODE_REQUEST_TIME
@ -49,5 +51,8 @@ EMAIL_CODE_REQUEST_TIME=$EMAIL_CODE_REQUEST_TIME
# Webhook
WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET
# EventProtocol
EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED
# DHT
DHT_TOPIC=$DHT_TOPIC
DHT_TOPIC=$DHT_TOPIC

1
backend/.gitignore vendored
View File

@ -6,4 +6,3 @@ package-json.lock
coverage
# emacs
*~
/.nvmrc

View File

@ -5,4 +5,5 @@ module.exports = {
trailingComma: "all",
tabWidth: 2,
bracketSpacing: true,
endOfLine: "auto",
};

View File

@ -1,16 +1,18 @@
# backend
## Project setup
```
```bash
yarn install
```
## Seed DB
```
```bash
yarn seed
```
Deletes all data in database. Then seeds data in database.
Deletes all data in database. Then seeds data in database.
## Seeded Users
@ -22,3 +24,47 @@ Deletes all data in database. Then seeds data in database.
| bob@baumeister.de | `Aa12345_` | `false` | `true` | `false` |
| garrick@ollivander.com | | `false` | `false` | `false` |
| stephen@hawking.uk | `Aa12345_` | `false` | `true` | `true` |
## Setup GraphQL Playground
### Setup In The Code
Setting up the GraphQL Playground in our code requires the following steps:
- Create an empty `.env` file in the `backend` folder and set "GRAPHIQL=true" there.
- Start or restart Docker Compose.
- For verification, Docker should display `GraphQL available at http://localhost:4000` in the terminal.
- If you open "http://localhost:4000/" in your browser, you should see the GraphQL Playground.
### Authentication
You need to authenticate yourself in GraphQL Playground to be able to send queries and mutations, to do so follow the steps below:
- in Firefox go to "Network Analysis" and delete all entries
- enter and send the login query:
```gql
{
login(email: "bibi@bloxberg.de", password:"Aa12345_") {
id
publisherId
email
firstName
lastName
emailChecked
language
hasElopage
}
}
```
- search in Firefox under „Network Analysis" for the smallest size of a header and copy the value of the token
- open the header section in GraphQL Playground and set your current token by filling in and replacing `XXX`:
```qgl
{
"Authorization": "XXX"
}
```
Now you can open a new tap in the Playground and enter your query or mutation there.

102
backend/log4js-config.json Normal file
View File

@ -0,0 +1,102 @@
{
"appenders":
{
"access":
{
"type": "dateFile",
"filename": "../logs/backend/access.log",
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
"keepFileExt" : true,
"fileNameSep" : "_"
},
"apollo":
{
"type": "dateFile",
"filename": "../logs/backend/apollo.log",
"pattern": "%d{ISO8601} %p %c %m",
"keepFileExt" : true,
"fileNameSep" : "_"
},
"backend":
{
"type": "dateFile",
"filename": "../logs/backend/backend.log",
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
"keepFileExt" : true,
"fileNameSep" : "_"
},
"errorFile":
{
"type": "dateFile",
"filename": "../logs/backend/errors.log",
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
"keepFileExt" : true,
"fileNameSep" : "_"
},
"errors":
{
"type": "logLevelFilter",
"level": "error",
"appender": "errorFile"
},
"out":
{
"type": "stdout",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m"
}
},
"apolloOut":
{
"type": "stdout",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c %m"
}
}
},
"categories":
{
"default":
{
"appenders":
[
"out",
"errors"
],
"level": "debug",
"enableCallStack": true
},
"apollo":
{
"appenders":
[
"apollo",
"apolloOut",
"errors"
],
"level": "debug",
"enableCallStack": true
},
"backend":
{
"appenders":
[
"backend",
"out",
"errors"
],
"level": "debug",
"enableCallStack": true
},
"http":
{
"appenders":
[
"access"
],
"level": "info"
}
}
}

View File

@ -1,11 +1,11 @@
{
"name": "gradido-backend",
"version": "1.8.1",
"version": "1.10.1",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",
"author": "Ulf Gebhardt",
"license": "MIT",
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --build",
@ -20,7 +20,6 @@
"@hyperswarm/dht": "^5.0.25",
"@types/jest": "^27.0.2",
"@types/lodash.clonedeep": "^4.5.6",
"apollo-log": "^1.1.0",
"apollo-server-express": "^2.25.2",
"apollo-server-testing": "^2.25.2",
"axios": "^0.21.1",
@ -34,6 +33,7 @@
"jest": "^27.2.4",
"jsonwebtoken": "^8.5.1",
"lodash.clonedeep": "^4.5.0",
"log4js": "^6.4.6",
"mysql2": "^2.3.0",
"nodemailer": "^6.6.5",
"random-bigint": "^0.0.1",

View File

@ -1,10 +1,14 @@
import axios from 'axios'
import { backendLogger as logger } from '@/server/logger'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const apiPost = async (url: string, payload: unknown): Promise<any> => {
logger.trace('POST: url=' + url + ' payload=' + payload)
return axios
.post(url, payload)
.then((result) => {
logger.trace('POST-Response: result=' + result)
if (result.status !== 200) {
throw new Error('HTTP Status Error ' + result.status)
}
@ -20,9 +24,11 @@ export const apiPost = async (url: string, payload: unknown): Promise<any> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const apiGet = async (url: string): Promise<any> => {
logger.trace('GET: url=' + url)
return axios
.get(url)
.then((result) => {
logger.trace('GET-Response: result=' + result)
if (result.status !== 200) {
throw new Error('HTTP Status Error ' + result.status)
}

View File

@ -25,16 +25,27 @@ export enum RIGHTS {
REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK',
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
GDT_BALANCE = 'GDT_BALANCE',
CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION',
DELETE_CONTRIBUTION = 'DELETE_CONTRIBUTION',
LIST_CONTRIBUTIONS = 'LIST_CONTRIBUTIONS',
LIST_ALL_CONTRIBUTIONS = 'LIST_ALL_CONTRIBUTIONS',
UPDATE_CONTRIBUTION = 'UPDATE_CONTRIBUTION',
// Admin
SEARCH_USERS = 'SEARCH_USERS',
CREATE_PENDING_CREATION = 'CREATE_PENDING_CREATION',
UPDATE_PENDING_CREATION = 'UPDATE_PENDING_CREATION',
SEARCH_PENDING_CREATION = 'SEARCH_PENDING_CREATION',
DELETE_PENDING_CREATION = 'DELETE_PENDING_CREATION',
CONFIRM_PENDING_CREATION = 'CONFIRM_PENDING_CREATION',
SEND_ACTIVATION_EMAIL = 'SEND_ACTIVATION_EMAIL',
SET_USER_ROLE = 'SET_USER_ROLE',
DELETE_USER = 'DELETE_USER',
UNDELETE_USER = 'UNDELETE_USER',
ADMIN_CREATE_CONTRIBUTION = 'ADMIN_CREATE_CONTRIBUTION',
ADMIN_CREATE_CONTRIBUTIONS = 'ADMIN_CREATE_CONTRIBUTIONS',
ADMIN_UPDATE_CONTRIBUTION = 'ADMIN_UPDATE_CONTRIBUTION',
ADMIN_DELETE_CONTRIBUTION = 'ADMIN_DELETE_CONTRIBUTION',
LIST_UNCONFIRMED_CONTRIBUTIONS = 'LIST_UNCONFIRMED_CONTRIBUTIONS',
CONFIRM_CONTRIBUTION = 'CONFIRM_CONTRIBUTION',
SEND_ACTIVATION_EMAIL = 'SEND_ACTIVATION_EMAIL',
CREATION_TRANSACTION_LIST = 'CREATION_TRANSACTION_LIST',
LIST_TRANSACTION_LINKS_ADMIN = 'LIST_TRANSACTION_LINKS_ADMIN',
CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK',
LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS',
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
}

View File

@ -23,6 +23,11 @@ export const ROLE_USER = new Role('user', [
RIGHTS.REDEEM_TRANSACTION_LINK,
RIGHTS.LIST_TRANSACTION_LINKS,
RIGHTS.GDT_BALANCE,
RIGHTS.CREATE_CONTRIBUTION,
RIGHTS.DELETE_CONTRIBUTION,
RIGHTS.LIST_CONTRIBUTIONS,
RIGHTS.LIST_ALL_CONTRIBUTIONS,
RIGHTS.UPDATE_CONTRIBUTION,
])
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights

View File

@ -3,7 +3,7 @@ import CONFIG from './index'
describe('config/index', () => {
describe('decay start block', () => {
it('has the correct date set', () => {
expect(CONFIG.DECAY_START_TIME).toEqual(new Date('2021-05-13 17:46:31'))
expect(CONFIG.DECAY_START_TIME).toEqual(new Date('2021-05-13 17:46:31-0000'))
})
})
})

View File

@ -10,11 +10,12 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0035-admin_pending_creations_decimal',
DB_VERSION: '0043-add_event_protocol_table',
DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v7.2022-05-04',
EXPECTED: 'v10.2022-19-07',
CURRENT: '',
},
}
@ -104,6 +105,11 @@ if (
)
}
const eventProtocol = {
// global switch to enable writing of EventProtocol-Entries
EVENT_PROTOCOL_DISABLED: process.env.EVENT_PROTOCOL_DISABLED === 'true' || false,
}
const federation = {
DHT_TOPIC: process.env.DHT_TOPIC || null,
}
@ -117,6 +123,7 @@ const CONFIG = {
...email,
...loginServer,
...webhook,
...eventProtocol,
...federation,
}

301
backend/src/event/Event.ts Normal file
View File

@ -0,0 +1,301 @@
import { EventProtocol } from '@entity/EventProtocol'
import decimal from 'decimal.js-light'
import { EventProtocolType } from './EventProtocolType'
export class EventBasic {
type: string
createdAt: Date
}
export class EventBasicUserId extends EventBasic {
userId: number
}
export class EventBasicTx extends EventBasicUserId {
xUserId: number
xCommunityId: number
transactionId: number
amount: decimal
}
export class EventBasicCt extends EventBasicUserId {
contributionId: number
amount: decimal
}
export class EventBasicRedeem extends EventBasicUserId {
transactionId?: number
contributionId?: number
}
export class EventVisitGradido extends EventBasic {}
export class EventRegister extends EventBasicUserId {}
export class EventRedeemRegister extends EventBasicRedeem {}
export class EventInactiveAccount extends EventBasicUserId {}
export class EventSendConfirmationEmail extends EventBasicUserId {}
export class EventConfirmationEmail extends EventBasicUserId {}
export class EventRegisterEmailKlicktipp extends EventBasicUserId {}
export class EventLogin 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 EventContributionCreate extends EventBasicCt {}
export class EventContributionConfirm extends EventBasicCt {
xUserId: number
xCommunityId: number
}
export class EventContributionLinkDefine extends EventBasicCt {}
export class EventContributionLinkActivateRedeem extends EventBasicCt {}
export class Event {
constructor()
constructor(event?: EventProtocol) {
if (event) {
this.id = event.id
this.type = event.type
this.createdAt = event.createdAt
this.userId = event.userId
this.xUserId = event.xUserId
this.xCommunityId = event.xCommunityId
this.transactionId = event.transactionId
this.contributionId = event.contributionId
this.amount = event.amount
}
}
public setEventBasic(): Event {
this.type = EventProtocolType.BASIC
this.createdAt = new Date()
return this
}
public setEventVisitGradido(): Event {
this.setEventBasic()
this.type = EventProtocolType.VISIT_GRADIDO
return this
}
public setEventRegister(ev: EventRegister): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.REGISTER
return this
}
public setEventRedeemRegister(ev: EventRedeemRegister): Event {
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
this.type = EventProtocolType.REDEEM_REGISTER
return this
}
public setEventInactiveAccount(ev: EventInactiveAccount): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.INACTIVE_ACCOUNT
return this
}
public setEventSendConfirmationEmail(ev: EventSendConfirmationEmail): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.SEND_CONFIRMATION_EMAIL
return this
}
public setEventConfirmationEmail(ev: EventConfirmationEmail): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.CONFIRM_EMAIL
return this
}
public setEventRegisterEmailKlicktipp(ev: EventRegisterEmailKlicktipp): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.REGISTER_EMAIL_KLICKTIPP
return this
}
public setEventLogin(ev: EventLogin): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.LOGIN
return this
}
public setEventRedeemLogin(ev: EventRedeemLogin): Event {
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
this.type = EventProtocolType.REDEEM_LOGIN
return this
}
public setEventActivateAccount(ev: EventActivateAccount): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.ACTIVATE_ACCOUNT
return this
}
public setEventPasswordChange(ev: EventPasswordChange): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.PASSWORD_CHANGE
return this
}
public setEventTransactionSend(ev: EventTransactionSend): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
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.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.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.type = EventProtocolType.TRANSACTION_CREATION
return this
}
public setEventTransactionReceive(ev: EventTransactionReceive): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
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.type = EventProtocolType.TRANSACTION_RECEIVE_REDEEM
return this
}
public setEventContributionCreate(ev: EventContributionCreate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_CREATE
return this
}
public setEventContributionConfirm(ev: EventContributionConfirm): 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.CONTRIBUTION_CONFIRM
return this
}
public setEventContributionLinkDefine(ev: EventContributionLinkDefine): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_LINK_DEFINE
return this
}
public setEventContributionLinkActivateRedeem(ev: EventContributionLinkActivateRedeem): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_LINK_ACTIVATE_REDEEM
return this
}
setByBasicUser(userId: number): Event {
this.setEventBasic()
this.userId = userId
return this
}
setByBasicTx(
userId: number,
xUserId?: number,
xCommunityId?: 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
return this
}
setByBasicCt(userId: number, contributionId: number, amount?: decimal): Event {
this.setByBasicUser(userId)
if (contributionId) this.contributionId = contributionId
if (amount) this.amount = amount
return this
}
setByBasicRedeem(userId: number, transactionId?: number, contributionId?: number): Event {
this.setByBasicUser(userId)
if (transactionId) this.transactionId = transactionId
if (contributionId) this.contributionId = contributionId
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
userId: number
xUserId?: number
xCommunityId?: number
transactionId?: number
contributionId?: number
amount?: decimal
}

View File

@ -0,0 +1,39 @@
import { Event } from '@/event/Event'
import { backendLogger as logger } from '@/server/logger'
import { EventProtocol } from '@entity/EventProtocol'
import CONFIG from '@/config'
class EventProtocolEmitter {
/* }extends EventEmitter { */
private events: Event[]
public addEvent(event: Event) {
this.events.push(event)
}
public getEvents(): Event[] {
return this.events
}
public isDisabled() {
logger.info(`EventProtocol - isDisabled=${CONFIG.EVENT_PROTOCOL_DISABLED}`)
return CONFIG.EVENT_PROTOCOL_DISABLED === true
}
public async writeEvent(event: Event): Promise<void> {
if (!eventProtocol.isDisabled()) {
logger.info(`writeEvent(${JSON.stringify(event)})`)
const dbEvent = new EventProtocol()
dbEvent.type = event.type
dbEvent.createdAt = event.createdAt
dbEvent.userId = event.userId
if (event.xUserId) dbEvent.xUserId = event.xUserId
if (event.xCommunityId) dbEvent.xCommunityId = event.xCommunityId
if (event.contributionId) dbEvent.contributionId = event.contributionId
if (event.transactionId) dbEvent.transactionId = event.transactionId
if (event.amount) dbEvent.amount = event.amount
await dbEvent.save()
}
}
}
export const eventProtocol = new EventProtocolEmitter()

View File

@ -0,0 +1,24 @@
export enum EventProtocolType {
BASIC = 'BASIC',
VISIT_GRADIDO = 'VISIT_GRADIDO',
REGISTER = 'REGISTER',
REDEEM_REGISTER = 'REDEEM_REGISTER',
INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT',
SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL',
CONFIRM_EMAIL = 'CONFIRM_EMAIL',
REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP',
LOGIN = 'LOGIN',
REDEEM_LOGIN = 'REDEEM_LOGIN',
ACTIVATE_ACCOUNT = 'ACTIVATE_ACCOUNT',
PASSWORD_CHANGE = 'PASSWORD_CHANGE',
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',
CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE',
CONTRIBUTION_CONFIRM = 'CONTRIBUTION_CONFIRM',
CONTRIBUTION_LINK_DEFINE = 'CONTRIBUTION_LINK_DEFINE',
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
}

View File

@ -3,7 +3,7 @@ import Decimal from 'decimal.js-light'
@InputType()
@ArgsType()
export default class CreatePendingCreationArgs {
export default class AdminCreateContributionArgs {
@Field(() => String)
email: string

View File

@ -2,7 +2,7 @@ import { ArgsType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
@ArgsType()
export default class UpdatePendingCreationArgs {
export default class AdminUpdateContributionArgs {
@Field(() => Int)
id: number

View File

@ -0,0 +1,15 @@
import { ArgsType, Field, InputType } from 'type-graphql'
import Decimal from 'decimal.js-light'
@InputType()
@ArgsType()
export default class ContributionArgs {
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
memo: string
@Field(() => String)
creationDate: string
}

View File

@ -0,0 +1,29 @@
import { ArgsType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
@ArgsType()
export default class ContributionLinkArgs {
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
name: string
@Field(() => String)
memo: string
@Field(() => String)
cycle: string
@Field(() => String, { nullable: true })
validFrom?: string | null
@Field(() => String, { nullable: true })
validTo?: string | null
@Field(() => Decimal, { nullable: true })
maxAmountPerMonth: Decimal | null
@Field(() => Int)
maxPerCycle: number
}

View File

@ -1,4 +1,5 @@
import { ArgsType, Field, Int } from 'type-graphql'
import SearchUsersFilters from '@arg/SearchUsersFilters'
@ArgsType()
export default class SearchUsersArgs {
@ -11,9 +12,6 @@ export default class SearchUsersArgs {
@Field(() => Int, { nullable: true })
pageSize?: number
@Field(() => Boolean, { nullable: true })
filterByActivated?: boolean | null
@Field(() => Boolean, { nullable: true })
filterByDeleted?: boolean | null
@Field(() => SearchUsersFilters, { nullable: true, defaultValue: null })
filters: SearchUsersFilters
}

View File

@ -0,0 +1,10 @@
import { Field, InputType } from 'type-graphql'
@InputType()
export default class SearchUsersFilters {
@Field(() => Boolean, { nullable: true, defaultValue: null })
byActivated: boolean
@Field(() => Boolean, { nullable: true, defaultValue: null })
byDeleted: boolean
}

View File

@ -1,13 +1,13 @@
import { ArgsType, Field } from 'type-graphql'
import { Field, InputType } from 'type-graphql'
@ArgsType()
@InputType()
export default class TransactionLinkFilters {
@Field(() => Boolean, { nullable: true, defaultValue: true })
withDeleted?: boolean
@Field(() => Boolean, { nullable: true })
withDeleted: boolean
@Field(() => Boolean, { nullable: true, defaultValue: true })
withExpired?: boolean
@Field(() => Boolean, { nullable: true })
withExpired: boolean
@Field(() => Boolean, { nullable: true, defaultValue: true })
withRedeemed?: boolean
@Field(() => Boolean, { nullable: true })
withRedeemed: boolean
}

View File

@ -19,7 +19,4 @@ export default class UpdateUserInfosArgs {
@Field({ nullable: true })
passwordNew?: string
@Field({ nullable: true })
coinanimation?: boolean
}

View File

@ -0,0 +1,28 @@
import { registerEnumType } from 'type-graphql'
export enum ContributionCycleType {
ONCE = 'once',
HOUR = 'hour',
TWO_HOURS = 'two_hours',
FOUR_HOURS = 'four_hours',
EIGHT_HOURS = 'eight_hours',
HALF_DAY = 'half_day',
DAY = 'day',
TWO_DAYS = 'two_days',
THREE_DAYS = 'three_days',
FOUR_DAYS = 'four_days',
FIVE_DAYS = 'five_days',
SIX_DAYS = 'six_days',
WEEK = 'week',
TWO_WEEKS = 'two_weeks',
MONTH = 'month',
TWO_MONTH = 'two_month',
QUARTER = 'quarter',
HALF_YEAR = 'half_year',
YEAR = 'year',
}
registerEnumType(ContributionCycleType, {
name: 'ContributionCycleType', // this one is mandatory
description: 'Name of the Type of the ContributionCycle', // this one is optional
})

View File

@ -1,5 +0,0 @@
enum Setting {
COIN_ANIMATION = 'coinanimation',
}
export { Setting }

View File

@ -1,19 +1,19 @@
import { ObjectType, Field } from 'type-graphql'
@ObjectType()
export class CreatePendingCreations {
export class AdminCreateContributions {
constructor() {
this.success = false
this.successfulCreation = []
this.failedCreation = []
this.successfulContribution = []
this.failedContribution = []
}
@Field(() => Boolean)
success: boolean
@Field(() => [String])
successfulCreation: string[]
successfulContribution: string[]
@Field(() => [String])
failedCreation: string[]
failedContribution: string[]
}

View File

@ -2,7 +2,7 @@ import { ObjectType, Field } from 'type-graphql'
import Decimal from 'decimal.js-light'
@ObjectType()
export class UpdatePendingCreation {
export class AdminUpdateContribution {
@Field(() => Date)
date: Date

View File

@ -0,0 +1,60 @@
import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
import { Contribution as dbContribution } from '@entity/Contribution'
import { User } from './User'
@ObjectType()
export class Contribution {
constructor(contribution: dbContribution, user: User) {
this.id = contribution.id
this.firstName = user ? user.firstName : null
this.lastName = user ? user.lastName : null
this.amount = contribution.amount
this.memo = contribution.memo
this.createdAt = contribution.createdAt
this.deletedAt = contribution.deletedAt
this.confirmedAt = contribution.confirmedAt
this.confirmedBy = contribution.confirmedBy
}
@Field(() => Number)
id: number
@Field(() => String, { nullable: true })
firstName: string | null
@Field(() => String, { nullable: true })
lastName: string | null
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
memo: string
@Field(() => Date)
createdAt: Date
@Field(() => Date, { nullable: true })
deletedAt: Date | null
@Field(() => Date, { nullable: true })
confirmedAt: Date | null
@Field(() => Number, { nullable: true })
confirmedBy: number | null
}
@ObjectType()
export class ContributionListResult {
constructor(count: number, list: Contribution[]) {
this.contributionCount = count
this.contributionList = list
}
@Field(() => Int)
contributionCount: number
@Field(() => [Contribution])
contributionList: Contribution[]
}

View File

@ -0,0 +1,62 @@
import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
import CONFIG from '@/config'
@ObjectType()
export class ContributionLink {
constructor(contributionLink: dbContributionLink) {
this.id = contributionLink.id
this.amount = contributionLink.amount
this.name = contributionLink.name
this.memo = contributionLink.memo
this.createdAt = contributionLink.createdAt
this.deletedAt = contributionLink.deletedAt
this.validFrom = contributionLink.validFrom
this.validTo = contributionLink.validTo
this.maxAmountPerMonth = contributionLink.maxAmountPerMonth
this.cycle = contributionLink.cycle
this.maxPerCycle = contributionLink.maxPerCycle
this.code = contributionLink.code
this.link = CONFIG.COMMUNITY_REDEEM_CONTRIBUTION_URL.replace(/{code}/g, this.code)
}
@Field(() => Number)
id: number
@Field(() => Decimal)
amount: Decimal
@Field(() => String)
name: string
@Field(() => String)
memo: string
@Field(() => String)
code: string
@Field(() => String)
link: string
@Field(() => Date)
createdAt: Date
@Field(() => Date, { nullable: true })
deletedAt: Date | null
@Field(() => Date, { nullable: true })
validFrom: Date | null
@Field(() => Date, { nullable: true })
validTo: Date | null
@Field(() => Decimal, { nullable: true })
maxAmountPerMonth: Decimal | null
@Field(() => String)
cycle: string
@Field(() => Int)
maxPerCycle: number
}

View File

@ -0,0 +1,11 @@
import { ObjectType, Field } from 'type-graphql'
import { ContributionLink } from '@model/ContributionLink'
@ObjectType()
export class ContributionLinkList {
@Field(() => [ContributionLink])
links: ContributionLink[]
@Field(() => Number)
count: number
}

View File

@ -1,35 +0,0 @@
import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
@ObjectType()
export class PendingCreation {
@Field(() => String)
firstName: string
@Field(() => Int)
id?: number
@Field(() => String)
lastName: string
@Field(() => Number)
userId: number
@Field(() => String)
email: string
@Field(() => Date)
date: Date
@Field(() => String)
memo: string
@Field(() => Decimal)
amount: Decimal
@Field(() => Number)
moderator: number
@Field(() => [Decimal])
creation: Decimal[]
}

View File

@ -0,0 +1,49 @@
import { ObjectType, Field, Int } from 'type-graphql'
import Decimal from 'decimal.js-light'
import { Contribution } from '@entity/Contribution'
import { User } from '@entity/User'
@ObjectType()
export class UnconfirmedContribution {
constructor(contribution: Contribution, user: User, creations: Decimal[]) {
this.id = contribution.id
this.userId = contribution.userId
this.amount = contribution.amount
this.memo = contribution.memo
this.date = contribution.contributionDate
this.firstName = user ? user.firstName : ''
this.lastName = user ? user.lastName : ''
this.email = user ? user.email : ''
this.creation = creations
}
@Field(() => String)
firstName: string
@Field(() => Int)
id?: number
@Field(() => String)
lastName: string
@Field(() => Number)
userId: number
@Field(() => String)
email: string
@Field(() => Date)
date: Date
@Field(() => String)
memo: string
@Field(() => Decimal)
amount: Decimal
@Field(() => Number, { nullable: true })
moderator: number | null
@Field(() => [Decimal])
creation: Decimal[]
}

View File

@ -1,10 +1,12 @@
import { ObjectType, Field } from 'type-graphql'
import { KlickTipp } from './KlickTipp'
import { User as dbUser } from '@entity/User'
import Decimal from 'decimal.js-light'
import { FULL_CREATION_AVAILABLE } from '../resolver/const/const'
@ObjectType()
export class User {
constructor(user: dbUser) {
constructor(user: dbUser, creation: Decimal[] = FULL_CREATION_AVAILABLE) {
this.id = user.id
this.email = user.email
this.firstName = user.firstName
@ -15,10 +17,9 @@ export class User {
this.language = user.language
this.publisherId = user.publisherId
this.isAdmin = user.isAdmin
// TODO
this.coinanimation = null
this.klickTipp = null
this.hasElopage = null
this.creation = creation
}
@Field(() => Number)
@ -61,14 +62,12 @@ export class User {
@Field(() => Date, { nullable: true })
isAdmin: Date | null
// TODO this is a bit inconsistent with what we query from the database
// therefore all those fields are now nullable with default value null
@Field(() => Boolean, { nullable: true })
coinanimation: boolean | null
@Field(() => KlickTipp, { nullable: true })
klickTipp: KlickTipp | null
@Field(() => Boolean, { nullable: true })
hasElopage: boolean | null
@Field(() => [Decimal])
creation: Decimal[]
}

View File

@ -14,6 +14,7 @@ export class UserAdmin {
this.hasElopage = hasElopage
this.deletedAt = user.deletedAt
this.emailConfirmationSend = emailConfirmationSend
this.isAdmin = user.isAdmin
}
@Field(() => Number)
@ -42,6 +43,9 @@ export class UserAdmin {
@Field(() => String, { nullable: true })
emailConfirmationSend?: string
@Field(() => Date, { nullable: true })
isAdmin: Date | null
}
@ObjectType()

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import { Context, getUser } from '@/server/context'
import { backendLogger as logger } from '@/server/logger'
import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type-graphql'
import {
getCustomRepository,
@ -11,21 +12,25 @@ import {
FindOperator,
} from '@dbTools/typeorm'
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
import { PendingCreation } from '@model/PendingCreation'
import { CreatePendingCreations } from '@model/CreatePendingCreations'
import { UpdatePendingCreation } from '@model/UpdatePendingCreation'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { AdminCreateContributions } from '@model/AdminCreateContributions'
import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
import { ContributionLink } from '@model/ContributionLink'
import { ContributionLinkList } from '@model/ContributionLinkList'
import { RIGHTS } from '@/auth/RIGHTS'
import { UserRepository } from '@repository/User'
import CreatePendingCreationArgs from '@arg/CreatePendingCreationArgs'
import UpdatePendingCreationArgs from '@arg/UpdatePendingCreationArgs'
import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs'
import AdminUpdateContributionArgs from '@arg/AdminUpdateContributionArgs'
import SearchUsersArgs from '@arg/SearchUsersArgs'
import ContributionLinkArgs from '@arg/ContributionLinkArgs'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { Transaction } from '@model/Transaction'
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { TransactionRepository } from '@repository/Transaction'
import { calculateDecay } from '@/util/decay'
import { AdminPendingCreation } from '@entity/AdminPendingCreation'
import { Contribution } from '@entity/Contribution'
import { hasElopageBuys } from '@/util/hasElopageBuys'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User as dbUser } from '@entity/User'
@ -39,12 +44,25 @@ import { Order } from '@enum/Order'
import { communityUser } from '@/util/communityUser'
import { checkOptInCode, activationLink, printTimeDuration } from './UserResolver'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
import CONFIG from '@/config'
import {
getUserCreation,
getUserCreations,
validateContribution,
isStartEndDateValid,
updateCreations,
} from './util/creations'
import {
CONTRIBUTIONLINK_MEMO_MAX_CHARS,
CONTRIBUTIONLINK_MEMO_MIN_CHARS,
CONTRIBUTIONLINK_NAME_MAX_CHARS,
CONTRIBUTIONLINK_NAME_MIN_CHARS,
FULL_CREATION_AVAILABLE,
} from './const/const'
// const EMAIL_OPT_IN_REGISTER = 1
// const EMAIL_OPT_UNKNOWN = 3 // elopage?
const MAX_CREATION_AMOUNT = new Decimal(1000)
const FULL_CREATION_AVAILABLE = [MAX_CREATION_AMOUNT, MAX_CREATION_AMOUNT, MAX_CREATION_AMOUNT]
@Resolver()
export class AdminResolver {
@ -52,26 +70,30 @@ export class AdminResolver {
@Query(() => SearchUsersResult)
async searchUsers(
@Args()
{
searchText,
currentPage = 1,
pageSize = 25,
filterByActivated = null,
filterByDeleted = null,
}: SearchUsersArgs,
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
): Promise<SearchUsersResult> {
const userRepository = getCustomRepository(UserRepository)
const filterCriteria: ObjectLiteral[] = []
if (filterByActivated !== null) {
filterCriteria.push({ emailChecked: filterByActivated })
if (filters) {
if (filters.byActivated !== null) {
filterCriteria.push({ emailChecked: filters.byActivated })
}
if (filters.byDeleted !== null) {
filterCriteria.push({ deletedAt: filters.byDeleted ? Not(IsNull()) : IsNull() })
}
}
if (filterByDeleted !== null) {
filterCriteria.push({ deletedAt: filterByDeleted ? Not(IsNull()) : IsNull() })
}
const userFields = ['id', 'firstName', 'lastName', 'email', 'emailChecked', 'deletedAt']
const userFields = [
'id',
'firstName',
'lastName',
'email',
'emailChecked',
'deletedAt',
'isAdmin',
]
const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered(
userFields.map((fieldName) => {
return 'user.' + fieldName
@ -131,6 +153,48 @@ export class AdminResolver {
}
}
@Authorized([RIGHTS.SET_USER_ROLE])
@Mutation(() => Date, { nullable: true })
async setUserRole(
@Arg('userId', () => Int)
userId: number,
@Arg('isAdmin', () => Boolean)
isAdmin: boolean,
@Ctx()
context: Context,
): Promise<Date | null> {
const user = await dbUser.findOne({ id: userId })
// user exists ?
if (!user) {
throw new Error(`Could not find user with userId: ${userId}`)
}
// administrator user changes own role?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
throw new Error('Administrator can not change his own role!')
}
// change isAdmin
switch (user.isAdmin) {
case null:
if (isAdmin === true) {
user.isAdmin = new Date()
} else {
throw new Error('User is already a usual user!')
}
break
default:
if (isAdmin === false) {
user.isAdmin = null
} else {
throw new Error('User is already admin!')
}
break
}
await user.save()
const newUser = await dbUser.findOne({ id: userId })
return newUser ? newUser.isAdmin : null
}
@Authorized([RIGHTS.DELETE_USER])
@Mutation(() => Date, { nullable: true })
async deleteUser(
@ -167,10 +231,10 @@ export class AdminResolver {
return null
}
@Authorized([RIGHTS.CREATE_PENDING_CREATION])
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION])
@Mutation(() => [Number])
async createPendingCreation(
@Args() { email, amount, memo, creationDate }: CreatePendingCreationArgs,
async adminCreateContribution(
@Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs,
@Ctx() context: Context,
): Promise<Decimal[]> {
const user = await dbUser.findOne({ email }, { withDeleted: true })
@ -178,61 +242,63 @@ export class AdminResolver {
throw new Error(`Could not find user with email: ${email}`)
}
if (user.deletedAt) {
throw new Error('This user was deleted. Cannot make a creation.')
throw new Error('This user was deleted. Cannot create a contribution.')
}
if (!user.emailChecked) {
throw new Error('Creation could not be saved, Email is not activated')
throw new Error('Contribution could not be saved, Email is not activated')
}
const moderator = getUser(context)
logger.trace('moderator: ', moderator.id)
const creations = await getUserCreation(user.id)
logger.trace('creations', creations)
const creationDateObj = new Date(creationDate)
if (isCreationValid(creations, amount, creationDateObj)) {
const adminPendingCreation = AdminPendingCreation.create()
adminPendingCreation.userId = user.id
adminPendingCreation.amount = amount
adminPendingCreation.created = new Date()
adminPendingCreation.date = creationDateObj
adminPendingCreation.memo = memo
adminPendingCreation.moderator = moderator.id
validateContribution(creations, amount, creationDateObj)
const contribution = Contribution.create()
contribution.userId = user.id
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.memo = memo
contribution.moderatorId = moderator.id
await AdminPendingCreation.save(adminPendingCreation)
}
logger.trace('contribution to save', contribution)
await Contribution.save(contribution)
return getUserCreation(user.id)
}
@Authorized([RIGHTS.CREATE_PENDING_CREATION])
@Mutation(() => CreatePendingCreations)
async createPendingCreations(
@Arg('pendingCreations', () => [CreatePendingCreationArgs])
pendingCreations: CreatePendingCreationArgs[],
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
@Mutation(() => AdminCreateContributions)
async adminCreateContributions(
@Arg('pendingCreations', () => [AdminCreateContributionArgs])
contributions: AdminCreateContributionArgs[],
@Ctx() context: Context,
): Promise<CreatePendingCreations> {
): Promise<AdminCreateContributions> {
let success = false
const successfulCreation: string[] = []
const failedCreation: string[] = []
for (const pendingCreation of pendingCreations) {
await this.createPendingCreation(pendingCreation, context)
const successfulContribution: string[] = []
const failedContribution: string[] = []
for (const contribution of contributions) {
await this.adminCreateContribution(contribution, context)
.then(() => {
successfulCreation.push(pendingCreation.email)
successfulContribution.push(contribution.email)
success = true
})
.catch(() => {
failedCreation.push(pendingCreation.email)
failedContribution.push(contribution.email)
})
}
return {
success,
successfulCreation,
failedCreation,
successfulContribution,
failedContribution,
}
}
@Authorized([RIGHTS.UPDATE_PENDING_CREATION])
@Mutation(() => UpdatePendingCreation)
async updatePendingCreation(
@Args() { id, email, amount, memo, creationDate }: UpdatePendingCreationArgs,
@Authorized([RIGHTS.ADMIN_UPDATE_CONTRIBUTION])
@Mutation(() => AdminUpdateContribution)
async adminUpdateContribution(
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
@Ctx() context: Context,
): Promise<UpdatePendingCreation> {
): Promise<AdminUpdateContribution> {
const user = await dbUser.findOne({ email }, { withDeleted: true })
if (!user) {
throw new Error(`Could not find user with email: ${email}`)
@ -243,59 +309,69 @@ export class AdminResolver {
const moderator = getUser(context)
const pendingCreationToUpdate = await AdminPendingCreation.findOne({ id })
const contributionToUpdate = await Contribution.findOne({
where: { id, confirmedAt: IsNull() },
})
if (!pendingCreationToUpdate) {
throw new Error('No creation found to given id.')
if (!contributionToUpdate) {
throw new Error('No contribution found to given id.')
}
if (pendingCreationToUpdate.userId !== user.id) {
throw new Error('user of the pending creation and send user does not correspond')
if (contributionToUpdate.userId !== user.id) {
throw new Error('user of the pending contribution and send user does not correspond')
}
if (contributionToUpdate.moderatorId === null) {
throw new Error('An admin is not allowed to update a user contribution.')
}
const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id)
if (pendingCreationToUpdate.date.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, pendingCreationToUpdate)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate)
}
// all possible cases not to be true are thrown in this function
isCreationValid(creations, amount, creationDateObj)
pendingCreationToUpdate.amount = amount
pendingCreationToUpdate.memo = memo
pendingCreationToUpdate.date = new Date(creationDate)
pendingCreationToUpdate.moderator = moderator.id
validateContribution(creations, amount, creationDateObj)
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.moderatorId = moderator.id
await AdminPendingCreation.save(pendingCreationToUpdate)
const result = new UpdatePendingCreation()
await Contribution.save(contributionToUpdate)
const result = new AdminUpdateContribution()
result.amount = amount
result.memo = pendingCreationToUpdate.memo
result.date = pendingCreationToUpdate.date
result.memo = contributionToUpdate.memo
result.date = contributionToUpdate.contributionDate
result.creation = await getUserCreation(user.id)
return result
}
@Authorized([RIGHTS.SEARCH_PENDING_CREATION])
@Query(() => [PendingCreation])
async getPendingCreations(): Promise<PendingCreation[]> {
const pendingCreations = await AdminPendingCreation.find()
if (pendingCreations.length === 0) {
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
@Query(() => [UnconfirmedContribution])
async listUnconfirmedContributions(): Promise<UnconfirmedContribution[]> {
const contributions = await Contribution.find({ where: { confirmedAt: IsNull() } })
if (contributions.length === 0) {
return []
}
const userIds = pendingCreations.map((p) => p.userId)
const userIds = contributions.map((p) => p.userId)
const userCreations = await getUserCreations(userIds)
const users = await dbUser.find({ where: { id: In(userIds) }, withDeleted: true })
return pendingCreations.map((pendingCreation) => {
const user = users.find((u) => u.id === pendingCreation.userId)
const creation = userCreations.find((c) => c.id === pendingCreation.userId)
return contributions.map((contribution) => {
const user = users.find((u) => u.id === contribution.userId)
const creation = userCreations.find((c) => c.id === contribution.userId)
return {
...pendingCreation,
amount: pendingCreation.amount,
id: contribution.id,
userId: contribution.userId,
date: contribution.contributionDate,
memo: contribution.memo,
amount: contribution.amount,
moderator: contribution.moderatorId,
firstName: user ? user.firstName : '',
lastName: user ? user.lastName : '',
email: user ? user.email : '',
@ -304,67 +380,91 @@ export class AdminResolver {
})
}
@Authorized([RIGHTS.DELETE_PENDING_CREATION])
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
@Mutation(() => Boolean)
async deletePendingCreation(@Arg('id', () => Int) id: number): Promise<boolean> {
const pendingCreation = await AdminPendingCreation.findOne(id)
if (!pendingCreation) {
throw new Error('Creation not found for given id.')
async adminDeleteContribution(@Arg('id', () => Int) id: number): Promise<boolean> {
const contribution = await Contribution.findOne(id)
if (!contribution) {
throw new Error('Contribution not found for given id.')
}
const res = await AdminPendingCreation.delete(pendingCreation)
const res = await contribution.softRemove()
return !!res
}
@Authorized([RIGHTS.CONFIRM_PENDING_CREATION])
@Authorized([RIGHTS.CONFIRM_CONTRIBUTION])
@Mutation(() => Boolean)
async confirmPendingCreation(
async confirmContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const pendingCreation = await AdminPendingCreation.findOne(id)
if (!pendingCreation) {
throw new Error('Creation not found to given id.')
const contribution = await Contribution.findOne(id)
if (!contribution) {
throw new Error('Contribution not found to given id.')
}
const moderatorUser = getUser(context)
if (moderatorUser.id === pendingCreation.userId)
throw new Error('Moderator can not confirm own pending creation')
if (moderatorUser.id === contribution.userId)
throw new Error('Moderator can not confirm own contribution')
const user = await dbUser.findOneOrFail({ id: pendingCreation.userId }, { withDeleted: true })
if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a creation.')
const user = await dbUser.findOneOrFail({ id: contribution.userId }, { withDeleted: true })
if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a contribution.')
const creations = await getUserCreation(pendingCreation.userId, false)
if (!isCreationValid(creations, pendingCreation.amount, pendingCreation.date)) {
throw new Error('Creation is not valid!!')
}
const creations = await getUserCreation(contribution.userId, false)
validateContribution(creations, contribution.amount, contribution.contributionDate)
const receivedCallDate = new Date()
const transactionRepository = getCustomRepository(TransactionRepository)
const lastTransaction = await transactionRepository.findLastForUser(pendingCreation.userId)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
try {
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
.select('transaction')
.from(DbTransaction, 'transaction')
.where('transaction.userId = :id', { id: contribution.userId })
.orderBy('transaction.balanceDate', 'DESC')
.getOne()
logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined')
let newBalance = new Decimal(0)
let decay: Decay | null = null
if (lastTransaction) {
decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, receivedCallDate)
newBalance = decay.balance
let newBalance = new Decimal(0)
let decay: Decay | null = null
if (lastTransaction) {
decay = calculateDecay(
lastTransaction.balance,
lastTransaction.balanceDate,
receivedCallDate,
)
newBalance = decay.balance
}
newBalance = newBalance.add(contribution.amount.toString())
const transaction = new DbTransaction()
transaction.typeId = TransactionTypeId.CREATION
transaction.memo = contribution.memo
transaction.userId = contribution.userId
transaction.previous = lastTransaction ? lastTransaction.id : null
transaction.amount = contribution.amount
transaction.creationDate = contribution.contributionDate
transaction.balance = newBalance
transaction.balanceDate = receivedCallDate
transaction.decay = decay ? decay.decay : new Decimal(0)
transaction.decayStart = decay ? decay.start : null
await queryRunner.manager.insert(DbTransaction, transaction)
contribution.confirmedAt = receivedCallDate
contribution.confirmedBy = moderatorUser.id
contribution.transactionId = transaction.id
await queryRunner.manager.update(Contribution, { id: contribution.id }, contribution)
await queryRunner.commitTransaction()
logger.info('creation commited successfuly.')
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Creation was not successful: ${e}`)
throw new Error(`Creation was not successful.`)
} finally {
await queryRunner.release()
}
newBalance = newBalance.add(pendingCreation.amount.toString())
const transaction = new DbTransaction()
transaction.typeId = TransactionTypeId.CREATION
transaction.memo = pendingCreation.memo
transaction.userId = pendingCreation.userId
transaction.previous = lastTransaction ? lastTransaction.id : null
transaction.amount = pendingCreation.amount
transaction.creationDate = pendingCreation.date
transaction.balance = newBalance
transaction.balanceDate = receivedCallDate
transaction.decay = decay ? decay.decay : new Decimal(0)
transaction.decayStart = decay ? decay.start : null
await transaction.save()
await AdminPendingCreation.delete(pendingCreation)
return true
}
@ -428,9 +528,10 @@ export class AdminResolver {
async listTransactionLinksAdmin(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Args()
@Arg('filters', () => TransactionLinkFilters, { nullable: true })
filters: TransactionLinkFilters,
@Arg('userId', () => Int) userId: number,
@Arg('userId', () => Int)
userId: number,
): Promise<TransactionLinkResult> {
const user = await dbUser.findOneOrFail({ id: userId })
const where: {
@ -439,12 +540,16 @@ export class AdminResolver {
validUntil?: FindOperator<Date> | null
} = {
userId,
redeemedBy: null,
validUntil: MoreThan(new Date()),
}
if (filters) {
if (filters.withRedeemed) delete where.redeemedBy
if (filters.withExpired) delete where.validUntil
}
if (!filters.withRedeemed) where.redeemedBy = null
if (!filters.withExpired) where.validUntil = MoreThan(new Date())
const [transactionLinks, count] = await dbTransactionLink.findAndCount({
where,
withDeleted: filters.withDeleted,
withDeleted: filters ? filters.withDeleted : false,
order: {
createdAt: order,
},
@ -457,96 +562,133 @@ export class AdminResolver {
linkList: transactionLinks.map((tl) => new TransactionLink(tl, new User(user))),
}
}
}
interface CreationMap {
id: number
creations: Decimal[]
}
async function getUserCreation(id: number, includePending = true): Promise<Decimal[]> {
const creations = await getUserCreations([id], includePending)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
}
async function getUserCreations(ids: number[], includePending = true): Promise<CreationMap[]> {
const months = getCreationMonths()
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
const unionString = includePending
? `
UNION
SELECT date AS date, amount AS amount, userId AS userId FROM admin_pending_creations
WHERE userId IN (${ids.toString()})
AND date >= ${dateFilter}`
: ''
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
`)
await queryRunner.release()
return ids.map((id) => {
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,
)
return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0)
}),
@Authorized([RIGHTS.CREATE_CONTRIBUTION_LINK])
@Mutation(() => ContributionLink)
async createContributionLink(
@Args()
{
amount,
name,
memo,
cycle,
validFrom,
validTo,
maxAmountPerMonth,
maxPerCycle,
}: ContributionLinkArgs,
): Promise<ContributionLink> {
isStartEndDateValid(validFrom, validTo)
if (!name) {
logger.error(`The name must be initialized!`)
throw new Error(`The name must be initialized!`)
}
})
}
function updateCreations(creations: Decimal[], pendingCreation: AdminPendingCreation): Decimal[] {
const index = getCreationIndex(pendingCreation.date.getMonth())
if (index < 0) {
throw new Error('You cannot create GDD for a month older than the last three months.')
}
creations[index] = creations[index].plus(pendingCreation.amount.toString())
return creations
}
function isCreationValid(creations: Decimal[], amount: Decimal, creationDate: Date) {
const index = getCreationIndex(creationDate.getMonth())
if (index < 0) {
throw new Error('No information for available creations for the given date')
if (
name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS ||
name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS
) {
const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}`
logger.error(`${msg}`)
throw new Error(`${msg}`)
}
if (!memo) {
logger.error(`The memo must be initialized!`)
throw new Error(`The memo must be initialized!`)
}
if (
memo.length < CONTRIBUTIONLINK_MEMO_MIN_CHARS ||
memo.length > CONTRIBUTIONLINK_MEMO_MAX_CHARS
) {
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_MEMO_MIN_CHARS} and max=${CONTRIBUTIONLINK_MEMO_MAX_CHARS}`
logger.error(`${msg}`)
throw new Error(`${msg}`)
}
if (!amount) {
logger.error(`The amount must be initialized!`)
throw new Error('The amount must be initialized!')
}
if (!new Decimal(amount).isPositive()) {
logger.error(`The amount=${amount} must be initialized with a positiv value!`)
throw new Error(`The amount=${amount} must be initialized with a positiv value!`)
}
const dbContributionLink = new DbContributionLink()
dbContributionLink.amount = amount
dbContributionLink.name = name
dbContributionLink.memo = memo
dbContributionLink.createdAt = new Date()
dbContributionLink.code = contributionLinkCode(dbContributionLink.createdAt)
dbContributionLink.cycle = cycle
if (validFrom) dbContributionLink.validFrom = new Date(validFrom)
if (validTo) dbContributionLink.validTo = new Date(validTo)
dbContributionLink.maxAmountPerMonth = maxAmountPerMonth
dbContributionLink.maxPerCycle = maxPerCycle
await dbContributionLink.save()
logger.debug(`createContributionLink successful!`)
return new ContributionLink(dbContributionLink)
}
if (amount.greaterThan(creations[index].toString())) {
throw new Error(
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
)
@Authorized([RIGHTS.LIST_CONTRIBUTION_LINKS])
@Query(() => ContributionLinkList)
async listContributionLinks(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
): Promise<ContributionLinkList> {
const [links, count] = await DbContributionLink.findAndCount({
order: { createdAt: order },
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return {
links: links.map((link: DbContributionLink) => new ContributionLink(link)),
count,
}
}
return true
}
@Authorized([RIGHTS.DELETE_CONTRIBUTION_LINK])
@Mutation(() => Date, { nullable: true })
async deleteContributionLink(@Arg('id', () => Int) id: number): Promise<Date | null> {
const contributionLink = await DbContributionLink.findOne(id)
if (!contributionLink) {
logger.error(`Contribution Link not found to given id: ${id}`)
throw new Error('Contribution Link not found to given id.')
}
await contributionLink.softRemove()
logger.debug(`deleteContributionLink successful!`)
const newContributionLink = await DbContributionLink.findOne({ id }, { withDeleted: true })
return newContributionLink ? newContributionLink.deletedAt : null
}
const getCreationMonths = (): number[] => {
const now = new Date(Date.now())
return [
now.getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1,
].reverse()
}
const getCreationIndex = (month: number): number => {
return getCreationMonths().findIndex((el) => el === month + 1)
@Authorized([RIGHTS.UPDATE_CONTRIBUTION_LINK])
@Mutation(() => ContributionLink)
async updateContributionLink(
@Args()
{
amount,
name,
memo,
cycle,
validFrom,
validTo,
maxAmountPerMonth,
maxPerCycle,
}: ContributionLinkArgs,
@Arg('id', () => Int) id: number,
): Promise<ContributionLink> {
const dbContributionLink = await DbContributionLink.findOne(id)
if (!dbContributionLink) {
logger.error(`Contribution Link not found to given id: ${id}`)
throw new Error('Contribution Link not found to given id.')
}
dbContributionLink.amount = amount
dbContributionLink.name = name
dbContributionLink.memo = memo
dbContributionLink.cycle = cycle
if (validFrom) dbContributionLink.validFrom = new Date(validFrom)
if (validTo) dbContributionLink.validTo = new Date(validTo)
dbContributionLink.maxAmountPerMonth = maxAmountPerMonth
dbContributionLink.maxPerCycle = maxPerCycle
await dbContributionLink.save()
logger.debug(`updateContributionLink successful!`)
return new ContributionLink(dbContributionLink)
}
}

View File

@ -1,3 +1,5 @@
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context'
import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
import { Balance } from '@model/Balance'
@ -7,7 +9,7 @@ import { Transaction as dbTransaction } from '@entity/Transaction'
import Decimal from 'decimal.js-light'
import { GdtResolver } from './GdtResolver'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { MoreThan, getCustomRepository } from '@dbTools/typeorm'
import { getCustomRepository } from '@dbTools/typeorm'
import { TransactionLinkRepository } from '@repository/TransactionLink'
@Resolver()
@ -18,15 +20,22 @@ export class BalanceResolver {
const user = getUser(context)
const now = new Date()
logger.addContext('user', user.id)
logger.info(`balance(userId=${user.id})...`)
const gdtResolver = new GdtResolver()
const balanceGDT = await gdtResolver.gdtBalance(context)
logger.debug(`balanceGDT=${balanceGDT}`)
const lastTransaction = context.lastTransaction
? context.lastTransaction
: await dbTransaction.findOne({ userId: user.id }, { order: { balanceDate: 'DESC' } })
logger.debug(`lastTransaction=${lastTransaction}`)
// No balance found
if (!lastTransaction) {
logger.info(`no balance found, return Default-Balance!`)
return new Balance({
balance: new Decimal(0),
balanceGDT,
@ -39,16 +48,16 @@ export class BalanceResolver {
context.transactionCount || context.transactionCount === 0
? context.transactionCount
: await dbTransaction.count({ where: { userId: user.id } })
const linkCount =
context.linkCount || context.linkCount === 0
? context.linkCount
: await dbTransactionLink.count({
where: {
userId: user.id,
redeemedAt: null,
validUntil: MoreThan(new Date()),
},
})
logger.debug(`transactionCount=${count}`)
const linkCount = await dbTransactionLink.count({
where: {
userId: user.id,
redeemedAt: null,
// validUntil: MoreThan(new Date()),
},
})
logger.debug(`linkCount=${linkCount}`)
// The decay is always calculated on the last booked transaction
const calculatedDecay = calculateDecay(
@ -56,6 +65,9 @@ export class BalanceResolver {
lastTransaction.balanceDate,
now,
)
logger.info(
`calculatedDecay(balance=${lastTransaction.balance}, balanceDate=${lastTransaction.balanceDate})=${calculatedDecay}`,
)
// The final balance is reduced by the link amount withheld
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
@ -63,13 +75,27 @@ export class BalanceResolver {
? { sumHoldAvailableAmount: context.sumHoldAvailableAmount }
: await transactionLinkRepository.summary(user.id, now)
return new Balance({
balance: calculatedDecay.balance
.minus(sumHoldAvailableAmount.toString())
.toDecimalPlaces(2, Decimal.ROUND_DOWN), // round towards zero
logger.debug(`context.sumHoldAvailableAmount=${context.sumHoldAvailableAmount}`)
logger.debug(`sumHoldAvailableAmount=${sumHoldAvailableAmount}`)
const balance = calculatedDecay.balance
.minus(sumHoldAvailableAmount.toString())
.toDecimalPlaces(2, Decimal.ROUND_DOWN) // round towards zero
// const newBalance = new Balance({
// balance: calculatedDecay.balance
// .minus(sumHoldAvailableAmount.toString())
// .toDecimalPlaces(2, Decimal.ROUND_DOWN),
const newBalance = new Balance({
balance,
balanceGDT,
count,
linkCount,
})
logger.info(
`new Balance(balance=${balance}, balanceGDT=${balanceGDT}, count=${count}, linkCount=${linkCount}) = ${newBalance}`,
)
return newBalance
}
}

View File

@ -0,0 +1,657 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import {
adminUpdateContribution,
confirmContribution,
createContribution,
deleteContribution,
updateContribution,
} from '@/seeds/graphql/mutations'
import { listAllContributions, listContributions, login } 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'
let mutate: any, query: 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()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('ContributionResolver', () => {
describe('createContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: createContribution,
variables: { amount: 100.0, memo: 'Test Contribution', creationDate: 'not-valid' },
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated with valid user', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('input not valid', () => {
it('throws error when creationDate not-valid', async () => {
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: 'not-valid',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError('No information for available creations for the given date'),
],
}),
)
})
it('throws error when creationDate 3 month behind', async () => {
const date = new Date()
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: date.setMonth(date.getMonth() - 3).toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError('No information for available creations for the given date'),
],
}),
)
})
})
describe('valid input', () => {
it('creates contribution', async () => {
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
createContribution: {
id: expect.any(Number),
amount: '100',
memo: 'Test env contribution',
},
},
}),
)
})
})
})
})
describe('listContributions', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
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,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('filter confirmed is false', () => {
it('returns creations', async () => {
await expect(
query({
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
})
describe('filter confirmed is true', () => {
it('returns only unconfirmed creations', async () => {
await expect(
query({
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: true,
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listContributions: {
contributionCount: 1,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
})
})
})
describe('updateContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: updateContribution,
variables: {
contributionId: 1,
amount: 100.0,
memo: 'Test Contribution',
creationDate: 'not-valid',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, peterLustig)
await userFactory(testEnv, bibiBloxberg)
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
result = await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('wrong contribution id', () => {
it('throws an error', async () => {
await expect(
mutate({
mutation: updateContribution,
variables: {
contributionId: -1,
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('No contribution found to given id.')],
}),
)
})
})
describe('wrong user tries to update the contribution', () => {
beforeAll(async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
})
it('throws an error', async () => {
await expect(
mutate({
mutation: updateContribution,
variables: {
contributionId: result.data.createContribution.id,
amount: 10.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'user of the pending contribution and send user does not correspond',
),
],
}),
)
})
})
describe('admin tries to update a user contribution', () => {
it('throws an error', async () => {
await expect(
mutate({
mutation: adminUpdateContribution,
variables: {
id: result.data.createContribution.id,
email: 'bibi@bloxberg.de',
amount: 10.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('An admin is not allowed to update a user contribution.')],
}),
)
})
})
describe('update too much so that the limit is exceeded', () => {
beforeAll(async () => {
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
})
it('throws an error', async () => {
await expect(
mutate({
mutation: updateContribution,
variables: {
contributionId: result.data.createContribution.id,
amount: 1019.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
'The amount (1019 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
),
],
}),
)
})
})
describe('update creation to a date that is older than 3 months', () => {
it('throws an error', async () => {
const date = new Date()
await expect(
mutate({
mutation: updateContribution,
variables: {
contributionId: result.data.createContribution.id,
amount: 10.0,
memo: 'Test env contribution',
creationDate: date.setMonth(date.getMonth() - 3).toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError('No information for available creations for the given date'),
],
}),
)
})
})
describe('valid input', () => {
it('updates contribution', async () => {
await expect(
mutate({
mutation: updateContribution,
variables: {
contributionId: result.data.createContribution.id,
amount: 10.0,
memo: 'Test contribution',
creationDate: new Date().toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
updateContribution: {
id: result.data.createContribution.id,
amount: '10',
memo: 'Test contribution',
},
},
}),
)
})
})
})
})
describe('listAllContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
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,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
it('returns allCreation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
})
})
describe('deleteContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: deleteContribution,
variables: {
id: -1,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
result = await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('wrong contribution id', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: deleteContribution,
variables: {
id: -1,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Contribution not found for given id.')],
}),
)
})
})
describe('other user sends a deleteContribtuion', () => {
it('returns an error', async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await expect(
mutate({
mutation: deleteContribution,
variables: {
id: result.data.createContribution.id,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Can not delete contribution of another user')],
}),
)
})
})
describe('User deletes own contribution', () => {
it('deletes successfully', async () => {
await expect(
mutate({
mutation: deleteContribution,
variables: {
id: result.data.createContribution.id,
},
}),
).resolves.toBeTruthy()
})
})
describe('User deletes already confirmed contribution', () => {
it('throws an error', async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: confirmContribution,
variables: {
id: result.data.createContribution.id,
},
})
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await expect(
mutate({
mutation: deleteContribution,
variables: {
id: result.data.createContribution.id,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('A confirmed contribution can not be deleted')],
}),
)
})
})
})
})
})

View File

@ -0,0 +1,149 @@
import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser } from '@/server/context'
import { backendLogger as logger } from '@/server/logger'
import { Contribution as dbContribution } from '@entity/Contribution'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { FindOperator, IsNull } from '@dbTools/typeorm'
import ContributionArgs from '@arg/ContributionArgs'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
import { Contribution, ContributionListResult } from '@model/Contribution'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { User } from '@model/User'
import { validateContribution, getUserCreation, updateCreations } from './util/creations'
@Resolver()
export class ContributionResolver {
@Authorized([RIGHTS.CREATE_CONTRIBUTION])
@Mutation(() => UnconfirmedContribution)
async createContribution(
@Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context,
): Promise<UnconfirmedContribution> {
const user = getUser(context)
const creations = await getUserCreation(user.id)
logger.trace('creations', creations)
const creationDateObj = new Date(creationDate)
validateContribution(creations, amount, creationDateObj)
const contribution = dbContribution.create()
contribution.userId = user.id
contribution.amount = amount
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.memo = memo
logger.trace('contribution to save', contribution)
await dbContribution.save(contribution)
return new UnconfirmedContribution(contribution, user, creations)
}
@Authorized([RIGHTS.DELETE_CONTRIBUTION])
@Mutation(() => Boolean)
async deleteContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const user = getUser(context)
const contribution = await dbContribution.findOne(id)
if (!contribution) {
throw new Error('Contribution not found for given id.')
}
if (contribution.userId !== user.id) {
throw new Error('Can not delete contribution of another user')
}
if (contribution.confirmedAt) {
throw new Error('A confirmed contribution can not be deleted')
}
const res = await contribution.softRemove()
return !!res
}
@Authorized([RIGHTS.LIST_CONTRIBUTIONS])
@Query(() => ContributionListResult)
async listContributions(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Arg('filterConfirmed', () => Boolean)
filterConfirmed: boolean | null,
@Ctx() context: Context,
): Promise<ContributionListResult> {
const user = getUser(context)
const where: {
userId: number
confirmedBy?: FindOperator<number> | null
} = { userId: user.id }
if (filterConfirmed) where.confirmedBy = IsNull()
const [contributions, count] = await dbContribution.findAndCount({
where,
order: {
createdAt: order,
},
withDeleted: true,
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return new ContributionListResult(
count,
contributions.map((contribution) => new Contribution(contribution, new User(user))),
)
}
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTIONS])
@Query(() => ContributionListResult)
async listAllContributions(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
): Promise<ContributionListResult> {
const [dbContributions, count] = await dbContribution.findAndCount({
relations: ['user'],
order: {
createdAt: order,
},
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return new ContributionListResult(
count,
dbContributions.map(
(contribution) => new Contribution(contribution, new User(contribution.user)),
),
)
}
@Authorized([RIGHTS.UPDATE_CONTRIBUTION])
@Mutation(() => UnconfirmedContribution)
async updateContribution(
@Arg('contributionId', () => Int)
contributionId: number,
@Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context,
): Promise<UnconfirmedContribution> {
const user = getUser(context)
const contributionToUpdate = await dbContribution.findOne({
where: { id: contributionId, confirmedAt: IsNull() },
})
if (!contributionToUpdate) {
throw new Error('No contribution found to given id.')
}
if (contributionToUpdate.userId !== user.id) {
throw new Error('user of the pending contribution and send user does not correspond')
}
const creationDateObj = new Date(creationDate)
let creations = await getUserCreation(user.id)
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
creations = updateCreations(creations, contributionToUpdate)
}
// all possible cases not to be true are thrown in this function
validateContribution(creations, amount, creationDateObj)
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
dbContribution.save(contributionToUpdate)
return new UnconfirmedContribution(contributionToUpdate, user, creations)
}
}

View File

@ -1,7 +1,21 @@
import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context'
import { Resolver, Args, Arg, Authorized, Ctx, Mutation, Query, Int } from 'type-graphql'
import { getConnection } from '@dbTools/typeorm'
import {
Resolver,
Args,
Arg,
Authorized,
Ctx,
Mutation,
Query,
Int,
createUnionType,
} from 'type-graphql'
import { TransactionLink } from '@model/TransactionLink'
import { ContributionLink } from '@model/ContributionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { User as dbUser } from '@entity/User'
import TransactionLinkArgs from '@arg/TransactionLinkArgs'
import Paginated from '@arg/Paginated'
@ -12,6 +26,17 @@ import { User } from '@model/User'
import { calculateDecay } from '@/util/decay'
import { executeTransaction } from './TransactionResolver'
import { Order } from '@enum/Order'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { getUserCreation, validateContribution } from './util/creations'
import { Decay } from '@model/Decay'
import Decimal from 'decimal.js-light'
import { TransactionTypeId } from '@enum/TransactionTypeId'
const QueryLinkResult = createUnionType({
name: 'QueryLinkResult', // the name of the GraphQL union
types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes
})
// TODO: do not export, test it inside the resolver
export const transactionLinkCode = (date: Date): string => {
@ -95,15 +120,23 @@ export class TransactionLinkResolver {
}
@Authorized([RIGHTS.QUERY_TRANSACTION_LINK])
@Query(() => TransactionLink)
async queryTransactionLink(@Arg('code') code: string): Promise<TransactionLink> {
const transactionLink = await dbTransactionLink.findOneOrFail({ code }, { withDeleted: true })
const user = await dbUser.findOneOrFail({ id: transactionLink.userId })
let redeemedBy: User | null = null
if (transactionLink && transactionLink.redeemedBy) {
redeemedBy = new User(await dbUser.findOneOrFail({ id: transactionLink.redeemedBy }))
@Query(() => QueryLinkResult)
async queryTransactionLink(@Arg('code') code: string): Promise<typeof QueryLinkResult> {
if (code.match(/^CL-/)) {
const contributionLink = await DbContributionLink.findOneOrFail(
{ code: code.replace('CL-', '') },
{ withDeleted: true },
)
return new ContributionLink(contributionLink)
} else {
const transactionLink = await dbTransactionLink.findOneOrFail({ code }, { withDeleted: true })
const user = await dbUser.findOneOrFail({ id: transactionLink.userId })
let redeemedBy: User | null = null
if (transactionLink && transactionLink.redeemedBy) {
redeemedBy = new User(await dbUser.findOneOrFail({ id: transactionLink.redeemedBy }))
}
return new TransactionLink(transactionLink, new User(user), redeemedBy)
}
return new TransactionLink(transactionLink, new User(user), redeemedBy)
}
@Authorized([RIGHTS.LIST_TRANSACTION_LINKS])
@ -137,31 +170,137 @@ export class TransactionLinkResolver {
@Ctx() context: Context,
): Promise<boolean> {
const user = getUser(context)
const transactionLink = await dbTransactionLink.findOneOrFail({ code })
const linkedUser = await dbUser.findOneOrFail({ id: transactionLink.userId })
const now = new Date()
if (user.id === linkedUser.id) {
throw new Error('Cannot redeem own transaction link.')
if (code.match(/^CL-/)) {
logger.info('redeem contribution link...')
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('SERIALIZABLE')
try {
const contributionLink = await queryRunner.manager
.createQueryBuilder()
.select('contributionLink')
.from(DbContributionLink, 'contributionLink')
.where('contributionLink.code = :code', { code: code.replace('CL-', '') })
.getOne()
if (!contributionLink) {
logger.error('no contribution link found to given code:', code)
throw new Error('No contribution link found')
}
logger.info('...contribution link found with id', contributionLink.id)
if (new Date(contributionLink.validFrom).getTime() > now.getTime()) {
logger.error(
'contribution link is not valid yet. Valid from: ',
contributionLink.validFrom,
)
throw new Error('Contribution link not valid yet')
}
if (contributionLink.validTo) {
if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) {
logger.error('contribution link is depricated. Valid to: ', contributionLink.validTo)
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')
}
const creations = await getUserCreation(user.id, false)
logger.info('open creations', creations)
validateContribution(creations, contributionLink.amount, now)
const contribution = new DbContribution()
contribution.userId = user.id
contribution.createdAt = now
contribution.contributionDate = now
contribution.memo = contributionLink.memo
contribution.amount = contributionLink.amount
contribution.contributionLinkId = contributionLink.id
await queryRunner.manager.insert(DbContribution, contribution)
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
.select('transaction')
.from(DbTransaction, 'transaction')
.where('transaction.userId = :id', { id: user.id })
.orderBy('transaction.balanceDate', 'DESC')
.getOne()
let newBalance = new Decimal(0)
let decay: Decay | null = null
if (lastTransaction) {
decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now)
newBalance = decay.balance
}
newBalance = newBalance.add(contributionLink.amount.toString())
const transaction = new DbTransaction()
transaction.typeId = TransactionTypeId.CREATION
transaction.memo = contribution.memo
transaction.userId = contribution.userId
transaction.previous = lastTransaction ? lastTransaction.id : null
transaction.amount = contribution.amount
transaction.creationDate = contribution.contributionDate
transaction.balance = newBalance
transaction.balanceDate = now
transaction.decay = decay ? decay.decay : new Decimal(0)
transaction.decayStart = decay ? decay.start : null
await queryRunner.manager.insert(DbTransaction, transaction)
contribution.confirmedAt = now
contribution.transactionId = transaction.id
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
await queryRunner.commitTransaction()
logger.info('creation from contribution link commited successfuly.')
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Creation from contribution link was not successful: ${e}`)
throw new Error(`Creation from contribution link was not successful. ${e}`)
} finally {
await queryRunner.release()
}
return true
} else {
const transactionLink = await dbTransactionLink.findOneOrFail({ code })
const linkedUser = await dbUser.findOneOrFail({ id: transactionLink.userId })
if (user.id === linkedUser.id) {
throw new Error('Cannot redeem own transaction link.')
}
if (transactionLink.validUntil.getTime() < now.getTime()) {
throw new Error('Transaction Link is not valid anymore.')
}
if (transactionLink.redeemedBy) {
throw new Error('Transaction Link already redeemed.')
}
await executeTransaction(
transactionLink.amount,
transactionLink.memo,
linkedUser,
user,
transactionLink,
)
return true
}
if (transactionLink.validUntil.getTime() < now.getTime()) {
throw new Error('Transaction Link is not valid anymore.')
}
if (transactionLink.redeemedBy) {
throw new Error('Transaction Link already redeemed.')
}
await executeTransaction(
transactionLink.amount,
transactionLink.memo,
linkedUser,
user,
transactionLink,
)
return true
}
}

View File

@ -1,6 +1,7 @@
/* eslint-disable new-cap */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { backendLogger as logger } from '@/server/logger'
import CONFIG from '@/config'
import { Context, getUser } from '@/server/context'
@ -44,15 +45,22 @@ export const executeTransaction = async (
recipient: dbUser,
transactionLink?: dbTransactionLink | null,
): Promise<boolean> => {
logger.info(
`executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`,
)
if (sender.id === recipient.id) {
logger.error(`Sender and Recipient are the same.`)
throw new Error('Sender and Recipient are the same.')
}
if (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}`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
@ -64,13 +72,16 @@ export const executeTransaction = async (
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 queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
logger.debug(`open Transaction to write...`)
try {
// transaction
const transactionSend = new dbTransaction()
@ -87,6 +98,8 @@ export const executeTransaction = async (
transactionSend.transactionLinkId = transactionLink ? transactionLink.id : null
await queryRunner.manager.insert(dbTransaction, transactionSend)
logger.debug(`sendTransaction inserted: ${dbTransaction}`)
const transactionReceive = new dbTransaction()
transactionReceive.typeId = TransactionTypeId.RECEIVE
transactionReceive.memo = memo
@ -102,12 +115,15 @@ export const executeTransaction = async (
transactionReceive.linkedTransactionId = transactionSend.id
transactionReceive.transactionLinkId = transactionLink ? transactionLink.id : null
await queryRunner.manager.insert(dbTransaction, transactionReceive)
logger.debug(`receive Transaction inserted: ${dbTransaction}`)
// Save linked transaction id for send
transactionSend.linkedTransactionId = transactionReceive.id
await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend)
logger.debug(`send Transaction updated: ${transactionSend}`)
if (transactionLink) {
logger.info(`transactionLink: ${transactionLink}`)
transactionLink.redeemedAt = receivedCallDate
transactionLink.redeemedBy = recipient.id
await queryRunner.manager.update(
@ -118,13 +134,15 @@ export const executeTransaction = async (
}
await queryRunner.commitTransaction()
logger.info(`commit Transaction successful...`)
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Transaction was not successful: ${e}`)
throw new Error(`Transaction was not successful: ${e}`)
} finally {
await queryRunner.release()
}
logger.debug(`prepare Email for transaction received...`)
// send notification email
// TODO: translate
await sendTransactionReceivedEmail({
@ -138,7 +156,7 @@ export const executeTransaction = async (
memo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
})
logger.info(`finished executeTransaction successfully`)
return true
}
@ -154,16 +172,21 @@ export class TransactionResolver {
const now = new Date()
const user = getUser(context)
logger.addContext('user', user.id)
logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.email})`)
// find current balance
const lastTransaction = await dbTransaction.findOne(
{ userId: user.id },
{ order: { balanceDate: 'DESC' } },
)
logger.debug(`lastTransaction=${lastTransaction}`)
const balanceResolver = new BalanceResolver()
context.lastTransaction = lastTransaction
if (!lastTransaction) {
logger.info('no lastTransaction')
return new TransactionList(await balanceResolver.balance(context), [])
}
@ -186,6 +209,8 @@ export class TransactionResolver {
involvedUserIds.push(transaction.linkedUserId)
}
})
logger.debug(`involvedUserIds=${involvedUserIds}`)
// We need to show the name for deleted users for old transactions
const involvedDbUsers = await dbUser
.createQueryBuilder()
@ -193,6 +218,7 @@ export class TransactionResolver {
.where('id IN (:...userIds)', { userIds: involvedUserIds })
.getMany()
const involvedUsers = involvedDbUsers.map((u) => new User(u))
logger.debug(`involvedUsers=${involvedUsers}`)
const self = new User(user)
const transactions: Transaction[] = []
@ -201,10 +227,13 @@ export class TransactionResolver {
const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate, transactionLinkcount } =
await transactionLinkRepository.summary(user.id, now)
context.linkCount = transactionLinkcount
logger.debug(`transactionLinkcount=${transactionLinkcount}`)
context.sumHoldAvailableAmount = sumHoldAvailableAmount
logger.debug(`sumHoldAvailableAmount=${sumHoldAvailableAmount}`)
// decay & link transactions
if (currentPage === 1 && order === Order.DESC) {
logger.debug(`currentPage == 1: transactions=${transactions}`)
// The virtual decay is always on the booked amount, not including the generated, not yet booked links,
// since the decay is substantially different when the amount is less
transactions.push(
@ -216,8 +245,11 @@ export class TransactionResolver {
sumHoldAvailableAmount,
),
)
logger.debug(`transactions=${transactions}`)
// virtual transaction for pending transaction-links sum
if (sumHoldAvailableAmount.greaterThan(0)) {
logger.debug(`sumHoldAvailableAmount > 0: transactions=${transactions}`)
transactions.push(
virtualLinkTransaction(
lastTransaction.balance.minus(sumHoldAvailableAmount.toString()),
@ -229,6 +261,7 @@ export class TransactionResolver {
self,
),
)
logger.debug(`transactions=${transactions}`)
}
}
@ -240,6 +273,7 @@ export class TransactionResolver {
: involvedUsers.find((u) => u.id === userTransaction.linkedUserId)
transactions.push(new Transaction(userTransaction, self, linkedUser))
})
logger.debug(`TransactionTypeId.CREATION: transactions=${transactions}`)
// Construct Result
return new TransactionList(await balanceResolver.balance(context), transactions)
@ -251,29 +285,38 @@ export class TransactionResolver {
@Args() { email, amount, memo }: TransactionSendArgs,
@Ctx() context: Context,
): Promise<boolean> {
logger.info(`sendCoins(email=${email}, amount=${amount}, memo=${memo})`)
// TODO this is subject to replay attacks
const senderUser = getUser(context)
if (senderUser.pubKey.length !== 32) {
logger.error(`invalid sender public key:${senderUser.pubKey}`)
throw new Error('invalid sender public key')
}
// validate recipient user
const recipientUser = await dbUser.findOne({ email: email }, { withDeleted: true })
if (!recipientUser) {
logger.error(`recipient not known: email=${email}`)
throw new Error('recipient not known')
}
if (recipientUser.deletedAt) {
logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`)
throw new Error('The recipient account was deleted')
}
if (!recipientUser.emailChecked) {
logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`)
throw new Error('The recipient account is not activated')
}
if (!isHexPublicKey(recipientUser.pubKey.toString('hex'))) {
logger.error(`invalid recipient public key: recipientUser=${recipientUser}`)
throw new Error('invalid recipient public key')
}
await executeTransaction(amount, memo, senderUser, recipientUser)
logger.info(
`successful executeTransaction(amount=${amount}, memo=${memo}, senderUser=${senderUser}, recipientUser=${recipientUser})`,
)
return true
}
}

View File

@ -11,8 +11,15 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User'
import CONFIG from '@/config'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail'
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
import { printTimeDuration, activationLink } from './UserResolver'
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
// import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
import { ContributionLink } from '@model/ContributionLink'
// import { TransactionLink } from '@entity/TransactionLink'
import { logger } from '@test/testSetup'
// import { klicktippSignIn } from '@/apis/KlicktippController'
@ -23,6 +30,13 @@ jest.mock('@/mailer/sendAccountActivationEmail', () => {
}
})
jest.mock('@/mailer/sendAccountMultiRegistrationEmail', () => {
return {
__esModule: true,
sendAccountMultiRegistrationEmail: jest.fn(),
}
})
jest.mock('@/mailer/sendResetPasswordEmail', () => {
return {
__esModule: true,
@ -43,7 +57,7 @@ let mutate: any, query: any, con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
testEnv = await testEnvironment(logger)
mutate = testEnv.mutate
query = testEnv.query
con = testEnv.con
@ -67,6 +81,7 @@ describe('UserResolver', () => {
let result: any
let emailOptIn: string
let user: User[]
beforeAll(async () => {
jest.clearAllMocks()
@ -84,7 +99,6 @@ describe('UserResolver', () => {
})
describe('valid input data', () => {
let user: User[]
let loginEmailOptIn: LoginEmailOptIn[]
beforeAll(async () => {
user = await User.find()
@ -112,6 +126,7 @@ describe('UserResolver', () => {
deletedAt: null,
publisherId: 1234,
referrerId: null,
contributionLinkId: null,
},
])
})
@ -149,10 +164,31 @@ describe('UserResolver', () => {
})
describe('email already exists', () => {
it('throws an error', async () => {
await expect(mutate({ mutation: createUser, variables })).resolves.toEqual(
let mutation: User
beforeAll(async () => {
mutation = await mutate({ mutation: createUser, variables })
})
it('logs an info', async () => {
expect(logger.info).toBeCalledWith('User already exists with this email=peter@lustig.de')
})
it('sends an account multi registration email', () => {
expect(sendAccountMultiRegistrationEmail).toBeCalledWith({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
})
})
it('results with partly faked user with random "id"', async () => {
expect(mutation).toEqual(
expect.objectContaining({
errors: [new GraphQLError('User already exists.')],
data: {
createUser: {
id: expect.any(Number),
},
},
}),
)
})
@ -191,6 +227,72 @@ describe('UserResolver', () => {
)
})
})
describe('redeem codes', () => {
describe('contribution link', () => {
let link: ContributionLink
beforeAll(async () => {
// activate account of admin Peter Lustig
await mutate({
mutation: setPassword,
variables: { code: emailOptIn, password: 'Aa12345_' },
})
// make Peter Lustig Admin
const peter = await User.findOneOrFail({ id: user[0].id })
peter.isAdmin = new Date()
await peter.save()
// factory logs in as Peter Lustig
link = await contributionLinkFactory(testEnv, {
name: 'Dokumenta 2022',
memo: 'Vielen Dank für deinen Besuch bei der Dokumenta 2022',
amount: 200,
validFrom: new Date(2022, 5, 18),
validTo: new Date(2022, 8, 25),
})
resetToken()
await mutate({
mutation: createUser,
variables: { ...variables, email: 'ein@besucher.de', redeemCode: 'CL-' + link.code },
})
})
it('sets the contribution link id', async () => {
await expect(User.findOne({ email: 'ein@besucher.de' })).resolves.toEqual(
expect.objectContaining({
contributionLinkId: link.id,
}),
)
})
})
/* A transaction link requires GDD on account
describe('transaction link', () => {
let code: string
beforeAll(async () => {
// factory logs in as Peter Lustig
await transactionLinkFactory(testEnv, {
email: 'peter@lustig.de',
amount: 19.99,
memo: `Kein Trick, keine Zauberrei,
bei Gradidio sei dabei!`,
})
const transactionLink = await TransactionLink.findOneOrFail()
resetToken()
await mutate({
mutation: createUser,
variables: { ...variables, email: 'neuer@user.de', redeemCode: transactionLink.code },
})
})
it('sets the referrer id to Peter Lustigs id', async () => {
await expect(User.findOne({ email: 'neuer@user.de' })).resolves.toEqual(expect.objectContaining({
referrerId: user[0].id,
}))
})
})
*/
})
})
describe('setPassword', () => {
@ -340,7 +442,6 @@ describe('UserResolver', () => {
expect.objectContaining({
data: {
login: {
coinanimation: true,
email: 'bibi@bloxberg.de',
firstName: 'Bibi',
hasElopage: false,
@ -475,7 +576,6 @@ describe('UserResolver', () => {
firstName: 'Bibi',
lastName: 'Bloxberg',
language: 'de',
coinanimation: true,
klickTipp: {
newsletterState: false,
},

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