mirror of
https://github.com/IT4Change/gradido.git
synced 2025-12-13 07:45:54 +00:00
Merge branch 'master' into 2004-Basic-Menu
This commit is contained in:
commit
5a26b4521d
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -528,7 +528,7 @@ jobs:
|
|||||||
report_name: Coverage Backend
|
report_name: Coverage Backend
|
||||||
type: lcov
|
type: lcov
|
||||||
result_path: ./backend/coverage/lcov.info
|
result_path: ./backend/coverage/lcov.info
|
||||||
min_coverage: 68
|
min_coverage: 74
|
||||||
token: ${{ github.token }}
|
token: ${{ github.token }}
|
||||||
|
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|||||||
141
CHANGELOG.md
141
CHANGELOG.md
@ -4,8 +4,149 @@ All notable changes to this project will be documented in this file. Dates are d
|
|||||||
|
|
||||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||||
|
|
||||||
|
#### [1.13.1](https://github.com/gradido/gradido/compare/1.13.0...1.13.1)
|
||||||
|
|
||||||
|
- Fix: correctly evaluate to EMAIL_TEST_MODE to false [`#2273`](https://github.com/gradido/gradido/pull/2273)
|
||||||
|
- Refactor: Contribution resolver logs and events [`#2231`](https://github.com/gradido/gradido/pull/2231)
|
||||||
|
|
||||||
|
#### [1.13.0](https://github.com/gradido/gradido/compare/1.12.1...1.13.0)
|
||||||
|
|
||||||
|
> 18 October 2022
|
||||||
|
|
||||||
|
- release: Version 1.13.0 [`#2269`](https://github.com/gradido/gradido/pull/2269)
|
||||||
|
- fix: Linked User Email in Transaction List [`#2268`](https://github.com/gradido/gradido/pull/2268)
|
||||||
|
- concept capturing alias [`#2148`](https://github.com/gradido/gradido/pull/2148)
|
||||||
|
- fix: 🍰 Daily Redeem Of Contribution Link [`#2265`](https://github.com/gradido/gradido/pull/2265)
|
||||||
|
- fix: 🐛 Prevent Loosing Redeem Code When Changing Between Register and Login in Auth Navbar [`#2260`](https://github.com/gradido/gradido/pull/2260)
|
||||||
|
- fix: Disable Change of Month on Update Contribution [`#2264`](https://github.com/gradido/gradido/pull/2264)
|
||||||
|
- feat: 🍰 Global Jest Extension For Decimal Equal [`#2261`](https://github.com/gradido/gradido/pull/2261)
|
||||||
|
- feat: 🍰 Daily Rule For Contribution Links In Admin Interface [`#2262`](https://github.com/gradido/gradido/pull/2262)
|
||||||
|
- feat: 🍰 Do Not Show Expired Contribution Links In Wallet [`#2257`](https://github.com/gradido/gradido/pull/2257)
|
||||||
|
- fix: 🍰 Disable Change Of Month For Update Contribution (wallet and admin) [`#2258`](https://github.com/gradido/gradido/pull/2258)
|
||||||
|
- refactor: 🍰 Login And Logout To Mutations [`#2232`](https://github.com/gradido/gradido/pull/2232)
|
||||||
|
- fix: 🐛 Verify Token Before Redeeming A Link [`#2254`](https://github.com/gradido/gradido/pull/2254)
|
||||||
|
- Refactor: Add all events to documentation table [`#2240`](https://github.com/gradido/gradido/pull/2240)
|
||||||
|
- reconfig log4js with rollover feature and userid in logevent-message [`#2221`](https://github.com/gradido/gradido/pull/2221)
|
||||||
|
- refactor: 🍰 Refactoring Components Of `CotributionMessagesListItem` [`#2251`](https://github.com/gradido/gradido/pull/2251)
|
||||||
|
- style: add border-radius on send form [`#2233`](https://github.com/gradido/gradido/pull/2233)
|
||||||
|
- 2198 adminarea more dates on created transaction [`#2212`](https://github.com/gradido/gradido/pull/2212)
|
||||||
|
- Bug: delete contribution link [`#2213`](https://github.com/gradido/gradido/pull/2213)
|
||||||
|
- chore: 🍰 Fix Cypress Tests Unreliability [`#2245`](https://github.com/gradido/gradido/pull/2245)
|
||||||
|
- docs: 🍰 Refine Deployment Documentation [`#2209`](https://github.com/gradido/gradido/pull/2209)
|
||||||
|
- End-to-end test setup [`#2047`](https://github.com/gradido/gradido/pull/2047)
|
||||||
|
- config testmodus flag for sending emails to test or team account instead of user account [`#2216`](https://github.com/gradido/gradido/pull/2216)
|
||||||
|
- GradidoID 1: adapt and migrate database schema [`#2058`](https://github.com/gradido/gradido/pull/2058)
|
||||||
|
- feat: Add Client Request Time to Context [`#2206`](https://github.com/gradido/gradido/pull/2206)
|
||||||
|
- 2219 feature rework eventprotocol [`#2234`](https://github.com/gradido/gradido/pull/2234)
|
||||||
|
- Refactor: Test register with redeem code [`#2214`](https://github.com/gradido/gradido/pull/2214)
|
||||||
|
- 2203 delete query modal when redeeming the redeem link [`#2211`](https://github.com/gradido/gradido/pull/2211)
|
||||||
|
- Refactor: 🍰 Change email templates [`#2228`](https://github.com/gradido/gradido/pull/2228)
|
||||||
|
- Refactor: Events and logs completed in User Resolver [`#2204`](https://github.com/gradido/gradido/pull/2204)
|
||||||
|
- change support mail [`#2210`](https://github.com/gradido/gradido/pull/2210)
|
||||||
|
- feat: 🍰 Send email when contribution is confirmed [`#2193`](https://github.com/gradido/gradido/pull/2193)
|
||||||
|
- feat: 🍰 Send email when admin writes message to contribution [`#2187`](https://github.com/gradido/gradido/pull/2187)
|
||||||
|
- feat: 🍰 Send Email To Transaction Link Sender After Receiver Redeemed It [`#2063`](https://github.com/gradido/gradido/pull/2063)
|
||||||
|
|
||||||
|
#### [1.12.1](https://github.com/gradido/gradido/compare/1.12.0...1.12.1)
|
||||||
|
|
||||||
|
> 13 September 2022
|
||||||
|
|
||||||
|
- release: Version 1.12.1 [`#2196`](https://github.com/gradido/gradido/pull/2196)
|
||||||
|
- fix: 🍰 Show Not Icons In `allContribution` List [`#2195`](https://github.com/gradido/gradido/pull/2195)
|
||||||
|
|
||||||
|
#### [1.12.0](https://github.com/gradido/gradido/compare/1.11.0...1.12.0)
|
||||||
|
|
||||||
|
> 12 September 2022
|
||||||
|
|
||||||
|
- release: v1.12.0 [`#2191`](https://github.com/gradido/gradido/pull/2191)
|
||||||
|
- if message empty else disabled button [`#2189`](https://github.com/gradido/gradido/pull/2189)
|
||||||
|
- messages show if Confirmed [`#2185`](https://github.com/gradido/gradido/pull/2185)
|
||||||
|
- text in messages smaller [`#2186`](https://github.com/gradido/gradido/pull/2186)
|
||||||
|
- feat: 🍰 Klicktipp retrieve not registered email [`#2181`](https://github.com/gradido/gradido/pull/2181)
|
||||||
|
- fix: 🍰 isModerator on messages to switch the messages side in the messages overview [`#2182`](https://github.com/gradido/gradido/pull/2182)
|
||||||
|
- Refactor locales for Nederlands [`#2174`](https://github.com/gradido/gradido/pull/2174)
|
||||||
|
- Add is moderator to contribution message [`#2180`](https://github.com/gradido/gradido/pull/2180)
|
||||||
|
- feat: 🍰 Moderator Cannot Answer Himself [`#2178`](https://github.com/gradido/gradido/pull/2178)
|
||||||
|
- refactor: Improve Statistics Query [`#2170`](https://github.com/gradido/gradido/pull/2170)
|
||||||
|
- fix: Remove Statistics from Wallet [`#2171`](https://github.com/gradido/gradido/pull/2171)
|
||||||
|
- feat: 🍰 Contribution Messages In Frontend [`#2164`](https://github.com/gradido/gradido/pull/2164)
|
||||||
|
- feat: 🚀 CRUD For Contribution Messages [`#2149`](https://github.com/gradido/gradido/pull/2149)
|
||||||
|
- fix: 🍰 Decay Calculation In Community Statistics [`#2167`](https://github.com/gradido/gradido/pull/2167)
|
||||||
|
- chore: 🍰 Remove Fetch Policy Network Only From Statistics [`#2159`](https://github.com/gradido/gradido/pull/2159)
|
||||||
|
- feat: 🍰 Remove Some Statistics Data From Frontend [`#2153`](https://github.com/gradido/gradido/pull/2153)
|
||||||
|
- feat: 🍰 Add Toogle Collaps On Language Name [`#2156`](https://github.com/gradido/gradido/pull/2156)
|
||||||
|
- 2145 corrections style for frontend [`#2147`](https://github.com/gradido/gradido/pull/2147)
|
||||||
|
- 2072 feature usecase contribution messaging [`#2073`](https://github.com/gradido/gradido/pull/2073)
|
||||||
|
- 2151 add hint to redeem link [`#2158`](https://github.com/gradido/gradido/pull/2158)
|
||||||
|
- 🍰 Create `contribution messages` table [`#2137`](https://github.com/gradido/gradido/pull/2137)
|
||||||
|
- feat: 🍰 Add The Languages French And Dutch [`#2138`](https://github.com/gradido/gradido/pull/2138)
|
||||||
|
- 1973 list open contribution links in the wallet [`#1975`](https://github.com/gradido/gradido/pull/1975)
|
||||||
|
- feat: 🍰 Admin Interface Displays Statistics [`#2124`](https://github.com/gradido/gradido/pull/2124)
|
||||||
|
- feat: Statistics Resolver [`#2041`](https://github.com/gradido/gradido/pull/2041)
|
||||||
|
- 2116 retrieve admin and moderators [`#2127`](https://github.com/gradido/gradido/pull/2127)
|
||||||
|
- 2125 feature gradido id: new column gradidoid in users table [`#2126`](https://github.com/gradido/gradido/pull/2126)
|
||||||
|
- 2119 new menu item gdt [`#2120`](https://github.com/gradido/gradido/pull/2120)
|
||||||
|
- feat: Migrate Contributions Table [`#2136`](https://github.com/gradido/gradido/pull/2136)
|
||||||
|
- chore: 🍰 Refactor Contribution Form Logic And Write Tests [`#2092`](https://github.com/gradido/gradido/pull/2092)
|
||||||
|
- fix: 🍰 Add `emailChecked` Before Changing `optIn` State & Log Error On klicktipp Middleware [`#2107`](https://github.com/gradido/gradido/pull/2107)
|
||||||
|
- Add RIGHTS.LIST_CONTRIBUTION_LINKS to ROLE_USER [`#2123`](https://github.com/gradido/gradido/pull/2123)
|
||||||
|
- 2121 translate locales to spanish [`#2122`](https://github.com/gradido/gradido/pull/2122)
|
||||||
|
- add formatter on input amount replace point and comma [`#2115`](https://github.com/gradido/gradido/pull/2115)
|
||||||
|
- remove required from form.memo [`#2114`](https://github.com/gradido/gradido/pull/2114)
|
||||||
|
- Fix pagination ellipsis [`#2104`](https://github.com/gradido/gradido/pull/2104)
|
||||||
|
|
||||||
|
#### [1.11.0](https://github.com/gradido/gradido/compare/1.10.1...1.11.0)
|
||||||
|
|
||||||
|
> 28 July 2022
|
||||||
|
|
||||||
|
- release: Version 1.11.0 [`#2103`](https://github.com/gradido/gradido/pull/2103)
|
||||||
|
- Fix navbar community item [`#2102`](https://github.com/gradido/gradido/pull/2102)
|
||||||
|
- Add validation date info to copied text after transaction link creation [`#2101`](https://github.com/gradido/gradido/pull/2101)
|
||||||
|
- Remove member area from menu (desktop and mobile), when user has no elopage account [`#2099`](https://github.com/gradido/gradido/pull/2099)
|
||||||
|
- fix: Use Inner Join for Contribution and User [`#2100`](https://github.com/gradido/gradido/pull/2100)
|
||||||
|
- Enable copying the link, username, amount, and memo text after transaction link creation [`#2098`](https://github.com/gradido/gradido/pull/2098)
|
||||||
|
- feat: Insert Missing Contributions Migration [`#2053`](https://github.com/gradido/gradido/pull/2053)
|
||||||
|
- [Bug] Wallet improvments for Contributions [`#2090`](https://github.com/gradido/gradido/pull/2090)
|
||||||
|
- [Fix] Add createdAt & contributionDate to ContributionListItems [`#2093`](https://github.com/gradido/gradido/pull/2093)
|
||||||
|
- fix: 🍰 Reset Amount In Contribution Form And Write A Test [`#2086`](https://github.com/gradido/gradido/pull/2086)
|
||||||
|
- [Feat] Replace logic to validation-provider. [`#2088`](https://github.com/gradido/gradido/pull/2088)
|
||||||
|
- fix: Add Confirm Dialog on Delete Contribution [`#2087`](https://github.com/gradido/gradido/pull/2087)
|
||||||
|
- fix: Admin Cannot Edit User Contribution [`#2085`](https://github.com/gradido/gradido/pull/2085)
|
||||||
|
- fix: Update contribution_date when Moved by Seed [`#2083`](https://github.com/gradido/gradido/pull/2083)
|
||||||
|
- chore: 🍰 Provide Volume For Backend Log-Files In Docker [`#2067`](https://github.com/gradido/gradido/pull/2067)
|
||||||
|
- feat: 🍰 Community Contribution Site And Form [`#2042`](https://github.com/gradido/gradido/pull/2042)
|
||||||
|
- [Refactor] Move MEMO_MIN_CHARS and MEMO_MAX_CHARS to const file [`#2082`](https://github.com/gradido/gradido/pull/2082)
|
||||||
|
- Fix: Test memo length on createContribution & updateContribution [`#2080`](https://github.com/gradido/gradido/pull/2080)
|
||||||
|
- chore: 🍰 Change `image` Entries In Docker Compose Files And Get Apple M1 Running [`#2050`](https://github.com/gradido/gradido/pull/2050)
|
||||||
|
- fix: Windows 0D 0A Linebreaks to Unix 0A [`#2064`](https://github.com/gradido/gradido/pull/2064)
|
||||||
|
- Add contributionDate to the Contribution object. [`#2066`](https://github.com/gradido/gradido/pull/2066)
|
||||||
|
- fix: Add Contributions to User [`#2062`](https://github.com/gradido/gradido/pull/2062)
|
||||||
|
- Feat: ContributionResolver - delete mutation [`#2035`](https://github.com/gradido/gradido/pull/2035)
|
||||||
|
- Fix: Add count to list contributions [`#2061`](https://github.com/gradido/gradido/pull/2061)
|
||||||
|
- docu: Explain how `.env` Files are Working [`#2022`](https://github.com/gradido/gradido/pull/2022)
|
||||||
|
- [WIP] 1794 feature event protocol 1 implement the basics of the business event protocol [`#1997`](https://github.com/gradido/gradido/pull/1997)
|
||||||
|
- docs: 🍰 Document The Setup Of The GraphQL Playground [`#2060`](https://github.com/gradido/gradido/pull/2060)
|
||||||
|
- Feat: List all contribution [`#2057`](https://github.com/gradido/gradido/pull/2057)
|
||||||
|
- feat: Do not log IntrospectionQuery from Query Browser [`#2059`](https://github.com/gradido/gradido/pull/2059)
|
||||||
|
- Feat: Add confirmedBy and confirmedAt for the contribution query. [`#2052`](https://github.com/gradido/gradido/pull/2052)
|
||||||
|
- Add open creations to webapp [`#2048`](https://github.com/gradido/gradido/pull/2048)
|
||||||
|
- Prevent session expiration modal from displaying negative seconds, when session is expired for more than 0 seconds [`#2054`](https://github.com/gradido/gradido/pull/2054)
|
||||||
|
- feat: mutation contribution update [`#2032`](https://github.com/gradido/gradido/pull/2032)
|
||||||
|
- feat: Login Returns Open Creations for User [`#2046`](https://github.com/gradido/gradido/pull/2046)
|
||||||
|
- Migrate transaction to valid dataset for gradido node [`#2029`](https://github.com/gradido/gradido/pull/2029)
|
||||||
|
- feat: implement contribution list query [`#2031`](https://github.com/gradido/gradido/pull/2031)
|
||||||
|
- add code for moving user creation date if transaction before exist [`#2034`](https://github.com/gradido/gradido/pull/2034)
|
||||||
|
- change text from page [`#2037`](https://github.com/gradido/gradido/pull/2037)
|
||||||
|
- Transaction link: copy link, text and more [`#2030`](https://github.com/gradido/gradido/pull/2030)
|
||||||
|
- change welcome in community text [`#2025`](https://github.com/gradido/gradido/pull/2025)
|
||||||
|
- changed link color in navbar and language switch [`#2024`](https://github.com/gradido/gradido/pull/2024)
|
||||||
|
- feat: ContributionResolver - createContribution [`#2009`](https://github.com/gradido/gradido/pull/2009)
|
||||||
|
|
||||||
#### [1.10.1](https://github.com/gradido/gradido/compare/1.10.0...1.10.1)
|
#### [1.10.1](https://github.com/gradido/gradido/compare/1.10.0...1.10.1)
|
||||||
|
|
||||||
|
> 30 June 2022
|
||||||
|
|
||||||
|
- release: 1.10.1 [`#2021`](https://github.com/gradido/gradido/pull/2021)
|
||||||
- automatic session logout with info modal [`#2001`](https://github.com/gradido/gradido/pull/2001)
|
- 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)
|
- 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)
|
- Origin/1921 additional parameter checks for createContributionLinks [`#1996`](https://github.com/gradido/gradido/pull/1996)
|
||||||
|
|||||||
44
DOCKER_MORE_CLOSELY.md
Normal file
44
DOCKER_MORE_CLOSELY.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Docker More Closely
|
||||||
|
|
||||||
|
## Apple M1 Platform
|
||||||
|
|
||||||
|
***Attention:** For using Docker commands in Apple M1 environments!*
|
||||||
|
|
||||||
|
### Enviroment Variable For Apple M1 Platform
|
||||||
|
|
||||||
|
To set the Docker platform environment variable in your terminal tab, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# set env variable for your shell
|
||||||
|
$ export DOCKER_DEFAULT_PLATFORM=linux/amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose Override File For Apple M1 Platform
|
||||||
|
|
||||||
|
For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in main folder
|
||||||
|
|
||||||
|
# for development
|
||||||
|
$ docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up
|
||||||
|
|
||||||
|
# for production
|
||||||
|
$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Analysing Docker Builds
|
||||||
|
|
||||||
|
To analyze a Docker build, there is a wonderful tool called [dive](https://github.com/wagoodman/dive). Please sponsor if you're using it!
|
||||||
|
|
||||||
|
The `dive build` command is exactly the right one to fulfill what we are looking for.
|
||||||
|
We can use it just like the `docker build` command and get an analysis afterwards.
|
||||||
|
|
||||||
|
So, in our main folder, we use it in the following way:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# in main folder
|
||||||
|
$ dive build --target <layer-name> -t "gradido/<app-name>:local-<layer-name>" <app-folder-name-or-dot>/
|
||||||
|
```
|
||||||
|
|
||||||
|
For the specific applications, see our [publish.yml](.github/workflows/publish.yml).
|
||||||
13
README.md
13
README.md
@ -121,6 +121,19 @@ 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.
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
| Problem | Issue | Solution | Description |
|
| Problem | Issue | Solution | Description |
|
||||||
|
|||||||
@ -26,5 +26,5 @@ module.exports = {
|
|||||||
testMatch: ['**/?(*.)+(spec|test).js?(x)'],
|
testMatch: ['**/?(*.)+(spec|test).js?(x)'],
|
||||||
// snapshotSerializers: ['jest-serializer-vue'],
|
// snapshotSerializers: ['jest-serializer-vue'],
|
||||||
transformIgnorePatterns: ['<rootDir>/node_modules/(?!vee-validate/dist/rules)'],
|
transformIgnorePatterns: ['<rootDir>/node_modules/(?!vee-validate/dist/rules)'],
|
||||||
testEnvironment: 'jest-environment-jsdom-sixteen',
|
testEnvironment: 'jest-environment-jsdom-sixteen', // why this is still needed? should not be needed anymore since jest@26, see: https://www.npmjs.com/package/jest-environment-jsdom-sixteen
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
"description": "Administraion Interface for Gradido",
|
"description": "Administraion Interface for Gradido",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"author": "Moriz Wahl",
|
"author": "Moriz Wahl",
|
||||||
"version": "1.10.1",
|
"version": "1.13.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -39,6 +39,7 @@
|
|||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "26.6.3",
|
"jest": "26.6.3",
|
||||||
"jest-canvas-mock": "^2.3.1",
|
"jest-canvas-mock": "^2.3.1",
|
||||||
|
"jest-environment-jsdom-sixteen": "^2.0.0",
|
||||||
"portal-vue": "^2.1.7",
|
"portal-vue": "^2.1.7",
|
||||||
"qrcanvas-vue": "2.1.1",
|
"qrcanvas-vue": "2.1.1",
|
||||||
"regenerator-runtime": "^0.13.9",
|
"regenerator-runtime": "^0.13.9",
|
||||||
@ -70,7 +71,6 @@
|
|||||||
"eslint-plugin-prettier": "3.3.1",
|
"eslint-plugin-prettier": "3.3.1",
|
||||||
"eslint-plugin-promise": "^5.1.1",
|
"eslint-plugin-promise": "^5.1.1",
|
||||||
"eslint-plugin-vue": "^7.20.0",
|
"eslint-plugin-vue": "^7.20.0",
|
||||||
"jest-environment-jsdom-sixteen": "^2.0.0",
|
|
||||||
"postcss": "^8.4.8",
|
"postcss": "^8.4.8",
|
||||||
"postcss-html": "^1.3.0",
|
"postcss-html": "^1.3.0",
|
||||||
"postcss-scss": "^4.0.3",
|
"postcss-scss": "^4.0.3",
|
||||||
|
|||||||
39
admin/src/components/CommunityStatistic.spec.js
Normal file
39
admin/src/components/CommunityStatistic.spec.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import CommunityStatistic from './CommunityStatistic'
|
||||||
|
|
||||||
|
const localVue = global.localVue
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
$t: jest.fn((t) => t),
|
||||||
|
$n: jest.fn((n) => n),
|
||||||
|
}
|
||||||
|
|
||||||
|
const propsData = {
|
||||||
|
value: {
|
||||||
|
totalUsers: '123',
|
||||||
|
activeUsers: '100',
|
||||||
|
deletedUsers: '5',
|
||||||
|
totalGradidoCreated: '2500',
|
||||||
|
totalGradidoDecayed: '200',
|
||||||
|
totalGradidoAvailable: '500',
|
||||||
|
totalGradidoUnbookedDecayed: '111',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CommunityStatistic', () => {
|
||||||
|
let wrapper
|
||||||
|
|
||||||
|
const Wrapper = () => {
|
||||||
|
return mount(CommunityStatistic, { localVue, mocks, propsData })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mount', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the Div Element ".community-statistic"', () => {
|
||||||
|
expect(wrapper.find('div.community-statistic').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
59
admin/src/components/CommunityStatistic.vue
Normal file
59
admin/src/components/CommunityStatistic.vue
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="community-statistic">
|
||||||
|
<div>
|
||||||
|
<b-jumbotron bg-variant="info" text-variant="white" border-variant="dark">
|
||||||
|
<template #header>{{ $t('statistic.name') }}</template>
|
||||||
|
|
||||||
|
<hr class="my-4" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{{ $t('statistic.totalUsers') }}{{ $t('math.colon') }}
|
||||||
|
<b>{{ value.totalUsers }}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('statistic.activeUsers') }}{{ $t('math.colon') }}
|
||||||
|
<b>{{ value.activeUsers }}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('statistic.deletedUsers') }}{{ $t('math.colon') }}
|
||||||
|
<b>{{ value.deletedUsers }}</b>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('statistic.totalGradidoCreated') }}{{ $t('math.colon') }}
|
||||||
|
<b>{{ $n(value.totalGradidoCreated, 'decimal') }} {{ $t('GDD') }}</b>
|
||||||
|
<small class="ml-5">{{ value.totalGradidoCreated }}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('statistic.totalGradidoDecayed') }}{{ $t('math.colon') }}
|
||||||
|
<b>{{ $n(value.totalGradidoDecayed, 'decimal') }} {{ $t('GDD') }}</b>
|
||||||
|
<small class="ml-5">{{ value.totalGradidoDecayed }}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('statistic.totalGradidoAvailable') }}{{ $t('math.colon') }}
|
||||||
|
<b>{{ $n(value.totalGradidoAvailable, 'decimal') }} {{ $t('GDD') }}</b>
|
||||||
|
<small class="ml-5">{{ value.totalGradidoAvailable }}</small>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('statistic.totalGradidoUnbookedDecayed') }}{{ $t('math.colon') }}
|
||||||
|
<b>{{ $n(value.totalGradidoUnbookedDecayed, 'decimal') }} {{ $t('GDD') }}</b>
|
||||||
|
<small class="ml-5">{{ value.totalGradidoUnbookedDecayed }}</small>
|
||||||
|
</div>
|
||||||
|
</b-jumbotron>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import CONFIG from '@/config'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CommunityStatistic',
|
||||||
|
props: {
|
||||||
|
value: { type: Object },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
CONFIG,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
29
admin/src/components/ContentFooter.spec.js
Normal file
29
admin/src/components/ContentFooter.spec.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ContentFooter from './ContentFooter'
|
||||||
|
|
||||||
|
const localVue = global.localVue
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
$t: jest.fn((t) => t),
|
||||||
|
$i18n: {
|
||||||
|
locale: jest.fn(() => 'en'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ContentFooter', () => {
|
||||||
|
let wrapper
|
||||||
|
|
||||||
|
const Wrapper = () => {
|
||||||
|
return mount(ContentFooter, { localVue, mocks })
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mount', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the div element ".content-footer"', () => {
|
||||||
|
expect(wrapper.find('div.content-footer').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="content-footer">
|
||||||
<hr />
|
<hr />
|
||||||
<b-row align-v="center" class="mt-4 justify-content-lg-between">
|
<b-row align-v="center" class="mt-4 justify-content-lg-between">
|
||||||
<b-col>
|
<b-col>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ const localVue = global.localVue
|
|||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
$t: jest.fn((t) => t),
|
$t: jest.fn((t) => t),
|
||||||
|
$d: jest.fn((d) => d),
|
||||||
}
|
}
|
||||||
|
|
||||||
const propsData = {
|
const propsData = {
|
||||||
|
|||||||
@ -15,7 +15,10 @@
|
|||||||
<b-collapse v-model="visible" id="newContribution" class="mt-2">
|
<b-collapse v-model="visible" id="newContribution" class="mt-2">
|
||||||
<b-card>
|
<b-card>
|
||||||
<p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p>
|
<p class="h2 ml-5">{{ $t('contributionLink.contributionLinks') }}</p>
|
||||||
<contribution-link-form :contributionLinkData="contributionLinkData" />
|
<contribution-link-form
|
||||||
|
:contributionLinkData="contributionLinkData"
|
||||||
|
@get-contribution-links="$emit('get-contribution-links')"
|
||||||
|
/>
|
||||||
</b-card>
|
</b-card>
|
||||||
</b-collapse>
|
</b-collapse>
|
||||||
|
|
||||||
@ -24,6 +27,7 @@
|
|||||||
v-if="count > 0"
|
v-if="count > 0"
|
||||||
:items="items"
|
:items="items"
|
||||||
@editContributionLinkData="editContributionLinkData"
|
@editContributionLinkData="editContributionLinkData"
|
||||||
|
@get-contribution-links="$emit('get-contribution-links')"
|
||||||
/>
|
/>
|
||||||
<div v-else>{{ $t('contributionLink.noContributionLinks') }}</div>
|
<div v-else>{{ $t('contributionLink.noContributionLinks') }}</div>
|
||||||
</b-card-text>
|
</b-card-text>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import ContributionLinkForm from './ContributionLinkForm.vue'
|
import ContributionLinkForm from './ContributionLinkForm.vue'
|
||||||
|
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||||
|
import { createContributionLink } from '@/graphql/createContributionLink.js'
|
||||||
|
|
||||||
const localVue = global.localVue
|
const localVue = global.localVue
|
||||||
|
|
||||||
@ -8,9 +10,13 @@ global.alert = jest.fn()
|
|||||||
const propsData = {
|
const propsData = {
|
||||||
contributionLinkData: {},
|
contributionLinkData: {},
|
||||||
}
|
}
|
||||||
|
const apolloMutateMock = jest.fn().mockResolvedValue()
|
||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
$t: jest.fn((t) => t),
|
$t: jest.fn((t) => t),
|
||||||
|
$apollo: {
|
||||||
|
mutate: apolloMutateMock,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// const mockAPIcall = jest.fn()
|
// const mockAPIcall = jest.fn()
|
||||||
@ -67,36 +73,70 @@ describe('ContributionLinkForm', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// describe('successfull submit', () => {
|
describe('successfull submit', () => {
|
||||||
// beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// mockAPIcall.mockResolvedValue({
|
apolloMutateMock.mockResolvedValue({
|
||||||
// data: {
|
data: {
|
||||||
// createContributionLink: {
|
createContributionLink: {
|
||||||
// link: 'https://localhost/redeem/CL-1a2345678',
|
link: 'https://localhost/redeem/CL-1a2345678',
|
||||||
// },
|
},
|
||||||
// },
|
},
|
||||||
// })
|
})
|
||||||
// await wrapper.find('input.test-validFrom').setValue('2022-6-18')
|
await wrapper
|
||||||
// await wrapper.find('input.test-validTo').setValue('2022-7-18')
|
.findAllComponents({ name: 'BFormDatepicker' })
|
||||||
// await wrapper.find('input.test-name').setValue('test name')
|
.at(0)
|
||||||
// await wrapper.find('input.test-memo').setValue('test memo')
|
.vm.$emit('input', '2022-6-18')
|
||||||
// await wrapper.find('input.test-amount').setValue('100')
|
await wrapper
|
||||||
// await wrapper.find('form').trigger('submit')
|
.findAllComponents({ name: 'BFormDatepicker' })
|
||||||
// })
|
.at(1)
|
||||||
|
.vm.$emit('input', '2022-7-18')
|
||||||
|
await wrapper.find('input.test-name').setValue('test name')
|
||||||
|
await wrapper.find('textarea.test-memo').setValue('test memo')
|
||||||
|
await wrapper.find('input.test-amount').setValue('100')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
})
|
||||||
|
|
||||||
// it('calls the API', () => {
|
it('calls the API', () => {
|
||||||
// expect(mockAPIcall).toHaveBeenCalledWith(
|
expect(apolloMutateMock).toHaveBeenCalledWith({
|
||||||
// expect.objectContaining({
|
mutation: createContributionLink,
|
||||||
// variables: {
|
variables: {
|
||||||
// link: 'https://localhost/redeem/CL-1a2345678',
|
validFrom: '2022-6-18',
|
||||||
// },
|
validTo: '2022-7-18',
|
||||||
// }),
|
name: 'test name',
|
||||||
// )
|
amount: '100',
|
||||||
// })
|
memo: 'test memo',
|
||||||
|
cycle: 'ONCE',
|
||||||
|
maxPerCycle: 1,
|
||||||
|
maxAmountPerMonth: '0',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// it('displays the new username', () => {
|
it('toasts a succes message', () => {
|
||||||
// expect(wrapper.find('div.display-username').text()).toEqual('@username')
|
expect(toastSuccessSpy).toBeCalledWith('https://localhost/redeem/CL-1a2345678')
|
||||||
// })
|
})
|
||||||
// })
|
})
|
||||||
|
|
||||||
|
describe('send createContributionLink with error', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
|
||||||
|
await wrapper
|
||||||
|
.findAllComponents({ name: 'BFormDatepicker' })
|
||||||
|
.at(0)
|
||||||
|
.vm.$emit('input', '2022-6-18')
|
||||||
|
await wrapper
|
||||||
|
.findAllComponents({ name: 'BFormDatepicker' })
|
||||||
|
.at(1)
|
||||||
|
.vm.$emit('input', '2022-7-18')
|
||||||
|
await wrapper.find('input.test-name').setValue('test name')
|
||||||
|
await wrapper.find('textarea.test-memo').setValue('test memo')
|
||||||
|
await wrapper.find('input.test-amount').setValue('100')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts an error message', () => {
|
||||||
|
expect(toastErrorSpy).toBeCalledWith('OUCH!')
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contribution-link-form">
|
<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">
|
<b-form class="m-5" @submit.prevent="onSubmit" ref="contributionLinkForm">
|
||||||
<!-- Date -->
|
<!-- Date -->
|
||||||
<b-row>
|
<b-row>
|
||||||
@ -68,34 +65,32 @@
|
|||||||
class="test-amount"
|
class="test-amount"
|
||||||
></b-form-input>
|
></b-form-input>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
<b-collapse id="collapse-2">
|
<b-row class="mb-4">
|
||||||
<b-jumbotron>
|
<b-col>
|
||||||
<b-row class="mb-4">
|
<!-- Cycle -->
|
||||||
<b-col>
|
<label for="cycle">{{ $t('contributionLink.cycle') }}</label>
|
||||||
<!-- Cycle -->
|
<b-form-select
|
||||||
<label for="cycle">{{ $t('contributionLink.cycle') }}</label>
|
v-model="form.cycle"
|
||||||
<b-form-select
|
:options="cycle"
|
||||||
v-model="form.cycle"
|
class="mb-3"
|
||||||
:options="cycle"
|
size="lg"
|
||||||
:disabled="disabled"
|
></b-form-select>
|
||||||
class="mb-3"
|
</b-col>
|
||||||
size="lg"
|
<b-col>
|
||||||
></b-form-select>
|
<!-- maxPerCycle -->
|
||||||
</b-col>
|
<label for="maxPerCycle">{{ $t('contributionLink.maxPerCycle') }}</label>
|
||||||
<b-col>
|
<b-form-select
|
||||||
<!-- maxPerCycle -->
|
v-model="form.maxPerCycle"
|
||||||
<label for="maxPerCycle">{{ $t('contributionLink.maxPerCycle') }}</label>
|
:options="maxPerCycle"
|
||||||
<b-form-select
|
:disabled="disabled"
|
||||||
v-model="form.maxPerCycle"
|
class="mb-3"
|
||||||
:options="maxPerCycle"
|
size="lg"
|
||||||
:disabled="disabled"
|
></b-form-select>
|
||||||
class="mb-3"
|
</b-col>
|
||||||
size="lg"
|
</b-row>
|
||||||
></b-form-select>
|
|
||||||
</b-col>
|
|
||||||
</b-row>
|
|
||||||
|
|
||||||
<!-- Max amount -->
|
<!-- Max amount -->
|
||||||
|
<!--
|
||||||
<b-form-group :label="$t('contributionLink.maximumAmount')">
|
<b-form-group :label="$t('contributionLink.maximumAmount')">
|
||||||
<b-form-input
|
<b-form-input
|
||||||
v-model="form.maxAmountPerMonth"
|
v-model="form.maxAmountPerMonth"
|
||||||
@ -105,8 +100,7 @@
|
|||||||
placeholder="0"
|
placeholder="0"
|
||||||
></b-form-input>
|
></b-form-input>
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
</b-jumbotron>
|
-->
|
||||||
</b-collapse>
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<b-button type="submit" variant="primary">{{ $t('contributionLink.create') }}</b-button>
|
<b-button type="submit" variant="primary">{{ $t('contributionLink.create') }}</b-button>
|
||||||
<b-button type="reset" variant="danger" @click.prevent="onReset">
|
<b-button type="reset" variant="danger" @click.prevent="onReset">
|
||||||
@ -143,18 +137,18 @@ export default {
|
|||||||
min: new Date(),
|
min: new Date(),
|
||||||
cycle: [
|
cycle: [
|
||||||
{ value: 'ONCE', text: this.$t('contributionLink.options.cycle.once') },
|
{ value: 'ONCE', text: this.$t('contributionLink.options.cycle.once') },
|
||||||
{ value: 'hourly', text: this.$t('contributionLink.options.cycle.hourly') },
|
// { value: 'hourly', text: this.$t('contributionLink.options.cycle.hourly') },
|
||||||
{ value: 'daily', text: this.$t('contributionLink.options.cycle.daily') },
|
{ value: 'DAILY', text: this.$t('contributionLink.options.cycle.daily') },
|
||||||
{ value: 'weekly', text: this.$t('contributionLink.options.cycle.weekly') },
|
// { value: 'weekly', text: this.$t('contributionLink.options.cycle.weekly') },
|
||||||
{ value: 'monthly', text: this.$t('contributionLink.options.cycle.monthly') },
|
// { value: 'monthly', text: this.$t('contributionLink.options.cycle.monthly') },
|
||||||
{ value: 'yearly', text: this.$t('contributionLink.options.cycle.yearly') },
|
// { value: 'yearly', text: this.$t('contributionLink.options.cycle.yearly') },
|
||||||
],
|
],
|
||||||
maxPerCycle: [
|
maxPerCycle: [
|
||||||
{ value: '1', text: '1 x' },
|
{ value: '1', text: '1 x' },
|
||||||
{ value: '2', text: '2 x' },
|
// { value: '2', text: '2 x' },
|
||||||
{ value: '3', text: '3 x' },
|
// { value: '3', text: '3 x' },
|
||||||
{ value: '4', text: '4 x' },
|
// { value: '4', text: '4 x' },
|
||||||
{ value: '5', text: '5 x' },
|
// { value: '5', text: '5 x' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -163,7 +157,6 @@ export default {
|
|||||||
if (this.form.validFrom === null)
|
if (this.form.validFrom === null)
|
||||||
return this.toastError(this.$t('contributionLink.noStartDate'))
|
return this.toastError(this.$t('contributionLink.noStartDate'))
|
||||||
if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate'))
|
if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate'))
|
||||||
// alert(JSON.stringify(this.form))
|
|
||||||
this.$apollo
|
this.$apollo
|
||||||
.mutate({
|
.mutate({
|
||||||
mutation: createContributionLink,
|
mutation: createContributionLink,
|
||||||
@ -182,6 +175,8 @@ export default {
|
|||||||
this.link = result.data.createContributionLink.link
|
this.link = result.data.createContributionLink.link
|
||||||
this.toastSuccess(this.link)
|
this.toastSuccess(this.link)
|
||||||
this.onReset()
|
this.onReset()
|
||||||
|
this.$root.$emit('bv::toggle::collapse', 'newContribution')
|
||||||
|
this.$emit('get-contribution-links')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.toastError(error.message)
|
this.toastError(error.message)
|
||||||
@ -194,12 +189,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
updateData() {
|
|
||||||
return this.contributionLinkData
|
|
||||||
},
|
|
||||||
disabled() {
|
disabled() {
|
||||||
if (this.form.cycle === 'ONCE') return true
|
return true
|
||||||
return false
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ const mockAPIcall = jest.fn()
|
|||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
$t: jest.fn((t) => t),
|
$t: jest.fn((t) => t),
|
||||||
|
$d: jest.fn((d) => d),
|
||||||
$apollo: {
|
$apollo: {
|
||||||
mutate: mockAPIcall,
|
mutate: mockAPIcall,
|
||||||
},
|
},
|
||||||
@ -95,7 +96,7 @@ describe('ContributionLinkList', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('toasts a success message', () => {
|
it('toasts a success message', () => {
|
||||||
expect(toastSuccessSpy).toBeCalledWith('TODO: request message deleted ')
|
expect(toastSuccessSpy).toBeCalledWith('contributionLink.deleted')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contribution-link-list">
|
<div class="contribution-link-list">
|
||||||
<b-table striped hover :items="items" :fields="fields">
|
<b-table striped hover :items="items" :fields="fields">
|
||||||
<template #cell(delete)>
|
<template #cell(delete)="data">
|
||||||
<b-button
|
<b-button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="md"
|
size="md"
|
||||||
class="mr-2 test-delete-link"
|
class="mr-2 test-delete-link"
|
||||||
@click="deleteContributionLink"
|
@click="deleteContributionLink(data.item.id, data.item.name)"
|
||||||
>
|
>
|
||||||
<b-icon icon="trash" variant="light"></b-icon>
|
<b-icon icon="trash" variant="light"></b-icon>
|
||||||
</b-button>
|
</b-button>
|
||||||
@ -34,7 +34,7 @@
|
|||||||
<h6 class="mb-0">{{ modalData ? modalData.name : '' }}</h6>
|
<h6 class="mb-0">{{ modalData ? modalData.name : '' }}</h6>
|
||||||
</template>
|
</template>
|
||||||
<b-card-text>
|
<b-card-text>
|
||||||
{{ modalData }}
|
{{ modalData.memo ? modalData.memo : '' }}
|
||||||
<figure-qr-code :link="modalData ? modalData.link : ''" />
|
<figure-qr-code :link="modalData ? modalData.link : ''" />
|
||||||
</b-card-text>
|
</b-card-text>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@ -64,34 +64,56 @@ export default {
|
|||||||
'amount',
|
'amount',
|
||||||
{ key: 'cycle', label: this.$t('contributionLink.cycle') },
|
{ key: 'cycle', label: this.$t('contributionLink.cycle') },
|
||||||
{ key: 'maxPerCycle', label: this.$t('contributionLink.maxPerCycle') },
|
{ key: 'maxPerCycle', label: this.$t('contributionLink.maxPerCycle') },
|
||||||
{ key: 'validFrom', label: this.$t('contributionLink.validFrom') },
|
{
|
||||||
{ key: 'validTo', label: this.$t('contributionLink.validTo') },
|
key: 'validFrom',
|
||||||
|
label: this.$t('contributionLink.validFrom'),
|
||||||
|
formatter: (value, key, item) => {
|
||||||
|
if (value) {
|
||||||
|
return this.$d(new Date(value))
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'validTo',
|
||||||
|
label: this.$t('contributionLink.validTo'),
|
||||||
|
formatter: (value, key, item) => {
|
||||||
|
if (value) {
|
||||||
|
return this.$d(new Date(value))
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
'delete',
|
'delete',
|
||||||
'edit',
|
'edit',
|
||||||
'show',
|
'show',
|
||||||
],
|
],
|
||||||
modalData: null,
|
modalData: {},
|
||||||
modalDataLink: null,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
deleteContributionLink() {
|
deleteContributionLink(id, name) {
|
||||||
this.$bvModal.msgBoxConfirm(this.$t('contributionLink.deleteNow')).then(async (value) => {
|
this.$bvModal
|
||||||
if (value)
|
.msgBoxConfirm(this.$t('contributionLink.deleteNow', { name: name }))
|
||||||
await this.$apollo
|
.then(async (value) => {
|
||||||
.mutate({
|
if (value)
|
||||||
mutation: deleteContributionLink,
|
await this.$apollo
|
||||||
variables: {
|
.mutate({
|
||||||
id: this.id,
|
mutation: deleteContributionLink,
|
||||||
},
|
variables: {
|
||||||
})
|
id: id,
|
||||||
.then(() => {
|
},
|
||||||
this.toastSuccess('TODO: request message deleted ')
|
})
|
||||||
})
|
.then(() => {
|
||||||
.catch((err) => {
|
this.toastSuccess(this.$t('contributionLink.deleted'))
|
||||||
this.toastError(err.message)
|
this.$emit('get-contribution-links')
|
||||||
})
|
})
|
||||||
})
|
.catch((err) => {
|
||||||
|
this.toastError(err.message)
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
editContributionLink(row) {
|
editContributionLink(row) {
|
||||||
this.$emit('editContributionLinkData', row)
|
this.$emit('editContributionLinkData', row)
|
||||||
|
|||||||
@ -0,0 +1,111 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ContributionMessagesFormular from './ContributionMessagesFormular.vue'
|
||||||
|
import { toastErrorSpy, toastSuccessSpy } from '../../../test/testSetup'
|
||||||
|
|
||||||
|
const localVue = global.localVue
|
||||||
|
|
||||||
|
const apolloMutateMock = jest.fn().mockResolvedValue()
|
||||||
|
|
||||||
|
describe('ContributionMessagesFormular', () => {
|
||||||
|
let wrapper
|
||||||
|
|
||||||
|
const propsData = {
|
||||||
|
contributionId: 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
$t: jest.fn((t) => t),
|
||||||
|
$apollo: {
|
||||||
|
mutate: apolloMutateMock,
|
||||||
|
},
|
||||||
|
$i18n: {
|
||||||
|
locale: 'en',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const Wrapper = () => {
|
||||||
|
return mount(ContributionMessagesFormular, {
|
||||||
|
localVue,
|
||||||
|
mocks,
|
||||||
|
propsData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mount', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a DIV .contribution-messages-formular', () => {
|
||||||
|
expect(wrapper.find('div.contribution-messages-formular').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('on trigger reset', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
wrapper.setData({
|
||||||
|
form: {
|
||||||
|
text: 'text form message',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wrapper.find('form').trigger('reset')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('form has empty text', () => {
|
||||||
|
expect(wrapper.vm.form).toEqual({
|
||||||
|
text: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('on trigger submit', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
wrapper.setData({
|
||||||
|
form: {
|
||||||
|
text: 'text form message',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emitted "get-list-contribution-messages" with data', async () => {
|
||||||
|
expect(wrapper.emitted('get-list-contribution-messages')).toEqual(
|
||||||
|
expect.arrayContaining([expect.arrayContaining([42])]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emitted "update-state" with data', async () => {
|
||||||
|
expect(wrapper.emitted('update-state')).toEqual(
|
||||||
|
expect.arrayContaining([expect.arrayContaining([42])]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('send contribution message with error', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
|
||||||
|
wrapper = Wrapper()
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts an error message', () => {
|
||||||
|
expect(toastErrorSpy).toBeCalledWith('OUCH!')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('send contribution message with success', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
wrapper.setData({
|
||||||
|
form: {
|
||||||
|
text: 'text form message',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
wrapper = Wrapper()
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts an success message', () => {
|
||||||
|
expect(toastSuccessSpy).toBeCalledWith('message.request')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contribution-messages-formular">
|
||||||
|
<div class="mt-5">
|
||||||
|
<b-form @submit.prevent="onSubmit" @reset.prevent="onReset">
|
||||||
|
<b-form-textarea
|
||||||
|
id="textarea"
|
||||||
|
v-model="form.text"
|
||||||
|
:placeholder="$t('contributionLink.memo')"
|
||||||
|
rows="3"
|
||||||
|
></b-form-textarea>
|
||||||
|
<b-row class="mt-4 mb-6">
|
||||||
|
<b-col>
|
||||||
|
<b-button type="reset" variant="danger">{{ $t('form.cancel') }}</b-button>
|
||||||
|
</b-col>
|
||||||
|
<b-col class="text-right">
|
||||||
|
<b-button type="submit" variant="primary" :disabled="disabled">
|
||||||
|
{{ $t('form.submit') }}
|
||||||
|
</b-button>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
</b-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ContributionMessagesFormular',
|
||||||
|
props: {
|
||||||
|
contributionId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onSubmit(event) {
|
||||||
|
this.$apollo
|
||||||
|
.mutate({
|
||||||
|
mutation: adminCreateContributionMessage,
|
||||||
|
variables: {
|
||||||
|
contributionId: this.contributionId,
|
||||||
|
message: this.form.text,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
this.$emit('get-list-contribution-messages', this.contributionId)
|
||||||
|
this.$emit('update-state', this.contributionId)
|
||||||
|
this.form.text = ''
|
||||||
|
this.toastSuccess(this.$t('message.request'))
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.toastError(error.message)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onReset(event) {
|
||||||
|
this.form.text = ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
disabled() {
|
||||||
|
if (this.form.text !== '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ContributionMessagesList from './ContributionMessagesList.vue'
|
||||||
|
|
||||||
|
const localVue = global.localVue
|
||||||
|
|
||||||
|
const apolloQueryMock = jest.fn().mockResolvedValue()
|
||||||
|
|
||||||
|
describe('ContributionMessagesList', () => {
|
||||||
|
let wrapper
|
||||||
|
|
||||||
|
const propsData = {
|
||||||
|
contributionId: 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
$t: jest.fn((t) => t),
|
||||||
|
$i18n: {
|
||||||
|
locale: 'en',
|
||||||
|
},
|
||||||
|
$apollo: {
|
||||||
|
query: apolloQueryMock,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const Wrapper = () => {
|
||||||
|
return mount(ContributionMessagesList, {
|
||||||
|
localVue,
|
||||||
|
mocks,
|
||||||
|
propsData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mount', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
wrapper = Wrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sends query to Apollo when created', () => {
|
||||||
|
expect(apolloQueryMock).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
variables: {
|
||||||
|
contributionId: propsData.contributionId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a DIV .contribution-messages-list', () => {
|
||||||
|
expect(wrapper.find('div.contribution-messages-list').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a Component ContributionMessagesFormular', () => {
|
||||||
|
expect(wrapper.findComponent({ name: 'ContributionMessagesFormular' }).exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contribution-messages-list">
|
||||||
|
<b-container>
|
||||||
|
{{ messages.lenght }}
|
||||||
|
<div v-for="message in messages" v-bind:key="message.id">
|
||||||
|
<contribution-messages-list-item :message="message" />
|
||||||
|
</div>
|
||||||
|
</b-container>
|
||||||
|
|
||||||
|
<contribution-messages-formular
|
||||||
|
:contributionId="contributionId"
|
||||||
|
@get-list-contribution-messages="getListContributionMessages"
|
||||||
|
@update-state="updateState"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import ContributionMessagesListItem from './slots/ContributionMessagesListItem.vue'
|
||||||
|
import ContributionMessagesFormular from '../ContributionMessages/ContributionMessagesFormular.vue'
|
||||||
|
import { listContributionMessages } from '../../graphql/listContributionMessages.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ContributionMessagesList',
|
||||||
|
components: {
|
||||||
|
ContributionMessagesListItem,
|
||||||
|
ContributionMessagesFormular,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
contributionId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
messages: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getListContributionMessages(id) {
|
||||||
|
this.$apollo
|
||||||
|
.query({
|
||||||
|
query: listContributionMessages,
|
||||||
|
variables: {
|
||||||
|
contributionId: id,
|
||||||
|
},
|
||||||
|
fetchPolicy: 'no-cache',
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
this.messages = result.data.listContributionMessages.messages
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.toastError(error.message)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateState(id) {
|
||||||
|
this.$emit('update-state', id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.getListContributionMessages(this.contributionId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.temp-message {
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import ContributionMessagesListItem from './ContributionMessagesListItem.vue'
|
||||||
|
|
||||||
|
const localVue = global.localVue
|
||||||
|
|
||||||
|
describe('ContributionMessagesListItem', () => {
|
||||||
|
let wrapper
|
||||||
|
|
||||||
|
const mocks = {
|
||||||
|
$t: jest.fn((t) => t),
|
||||||
|
$d: jest.fn((d) => d),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('if message author has moderator role', () => {
|
||||||
|
const propsData = {
|
||||||
|
contributionId: 42,
|
||||||
|
state: 'PENDING',
|
||||||
|
message: {
|
||||||
|
id: 111,
|
||||||
|
message: 'Lorem ipsum?',
|
||||||
|
createdAt: '2022-08-29T12:23:27.000Z',
|
||||||
|
updatedAt: null,
|
||||||
|
type: 'DIALOG',
|
||||||
|
userFirstName: 'Peter',
|
||||||
|
userLastName: 'Lustig',
|
||||||
|
userId: 107,
|
||||||
|
isModerator: true,
|
||||||
|
__typename: 'ContributionMessage',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModeratorItemWrapper = () => {
|
||||||
|
return mount(ContributionMessagesListItem, {
|
||||||
|
localVue,
|
||||||
|
mocks,
|
||||||
|
propsData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mount', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
wrapper = ModeratorItemWrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a DIV .text-right.is-moderator', () => {
|
||||||
|
expect(wrapper.find('div.text-right.is-moderator').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has the complete user name', () => {
|
||||||
|
expect(wrapper.find('div.text-right.is-moderator > span:nth-child(2)').text()).toBe(
|
||||||
|
'Peter Lustig',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has the message creation date', () => {
|
||||||
|
expect(wrapper.find('div.text-right.is-moderator > span:nth-child(3)').text()).toMatch(
|
||||||
|
'Mon Aug 29 2022 12:23:27 GMT+0000',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has the moderator label', () => {
|
||||||
|
expect(wrapper.find('div.text-right.is-moderator > small:nth-child(4)').text()).toBe(
|
||||||
|
'moderator',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has the message', () => {
|
||||||
|
expect(wrapper.find('div.text-right.is-moderator > div:nth-child(5)').text()).toBe(
|
||||||
|
'Lorem ipsum?',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('if message author does not have moderator role', () => {
|
||||||
|
const propsData = {
|
||||||
|
contributionId: 42,
|
||||||
|
state: 'PENDING',
|
||||||
|
message: {
|
||||||
|
id: 113,
|
||||||
|
message: 'Asda sdad ad asdasd, das Ass das Das. ',
|
||||||
|
createdAt: '2022-08-29T12:25:34.000Z',
|
||||||
|
updatedAt: null,
|
||||||
|
type: 'DIALOG',
|
||||||
|
userFirstName: 'Bibi',
|
||||||
|
userLastName: 'Bloxberg',
|
||||||
|
userId: 108,
|
||||||
|
__typename: 'ContributionMessage',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemWrapper = () => {
|
||||||
|
return mount(ContributionMessagesListItem, {
|
||||||
|
localVue,
|
||||||
|
mocks,
|
||||||
|
propsData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mount', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
wrapper = ItemWrapper()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a DIV .text-left.is-not-moderator', () => {
|
||||||
|
expect(wrapper.find('div.text-left.is-not-moderator').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has the complete user name', () => {
|
||||||
|
expect(wrapper.find('div.is-not-moderator.text-left > span:nth-child(2)').text()).toBe(
|
||||||
|
'Bibi Bloxberg',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has the message creation date', () => {
|
||||||
|
expect(wrapper.find('div.is-not-moderator.text-left > span:nth-child(3)').text()).toMatch(
|
||||||
|
'Mon Aug 29 2022 12:25:34 GMT+0000',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has the message', () => {
|
||||||
|
expect(wrapper.find('div.is-not-moderator.text-left > div:nth-child(4)').text()).toBe(
|
||||||
|
'Asda sdad ad asdasd, das Ass das Das.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contribution-messages-list-item">
|
||||||
|
<div v-if="message.isModerator" class="text-right is-moderator">
|
||||||
|
<b-avatar square :text="initialLetters" variant="warning"></b-avatar>
|
||||||
|
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
|
||||||
|
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
|
||||||
|
<small class="ml-4 text-success">{{ $t('moderator') }}</small>
|
||||||
|
<div class="mt-2">{{ message.message }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-left is-not-moderator">
|
||||||
|
<b-avatar :text="initialLetters" variant="info"></b-avatar>
|
||||||
|
<span class="ml-2 mr-2">{{ message.userFirstName }} {{ message.userLastName }}</span>
|
||||||
|
<span class="ml-2">{{ $d(new Date(message.createdAt), 'short') }}</span>
|
||||||
|
<div class="mt-2">{{ message.message }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ContributionMessagesListItem',
|
||||||
|
props: {
|
||||||
|
message: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.is-not-moderator {
|
||||||
|
clear: both;
|
||||||
|
width: 75%;
|
||||||
|
margin-top: 20px;
|
||||||
|
/* background-color: rgb(261, 204, 221); */
|
||||||
|
}
|
||||||
|
.is-moderator {
|
||||||
|
clear: both;
|
||||||
|
float: right;
|
||||||
|
width: 75%;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
/* background-color: rgb(255, 255, 128); */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -6,30 +6,29 @@ const localVue = global.localVue
|
|||||||
|
|
||||||
const apolloQueryMock = jest.fn().mockResolvedValue({
|
const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
creationTransactionList: [
|
creationTransactionList: {
|
||||||
{
|
contributionCount: 2,
|
||||||
id: 1,
|
contributionList: [
|
||||||
amount: 100,
|
{
|
||||||
balanceDate: 0,
|
id: 1,
|
||||||
creationDate: new Date(),
|
amount: 5.8,
|
||||||
memo: 'Testing',
|
createdAt: '2022-09-21T11:09:51.000Z',
|
||||||
linkedUser: {
|
confirmedAt: null,
|
||||||
firstName: 'Gradido',
|
contributionDate: '2022-08-01T00:00:00.000Z',
|
||||||
lastName: 'Akademie',
|
memo: 'für deine Hilfe, Fräulein Rottenmeier',
|
||||||
|
state: 'PENDING',
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
id: 2,
|
||||||
id: 2,
|
amount: '47',
|
||||||
amount: 200,
|
createdAt: '2022-09-21T11:09:28.000Z',
|
||||||
balanceDate: 0,
|
confirmedAt: '2022-09-21T11:09:28.000Z',
|
||||||
creationDate: new Date(),
|
contributionDate: '2022-08-01T00:00:00.000Z',
|
||||||
memo: 'Testing 2',
|
memo: 'für deine Hilfe, Frau Holle',
|
||||||
linkedUser: {
|
state: 'CONFIRMED',
|
||||||
firstName: 'Gradido',
|
|
||||||
lastName: 'Akademie',
|
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -43,7 +42,7 @@ const mocks = {
|
|||||||
|
|
||||||
const propsData = {
|
const propsData = {
|
||||||
userId: 1,
|
userId: 1,
|
||||||
fields: ['date', 'balance', 'name', 'memo', 'decay'],
|
fields: ['createdAt', 'contributionDate', 'confirmedAt', 'amount', 'memo'],
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('CreationTransactionList', () => {
|
describe('CreationTransactionList', () => {
|
||||||
@ -63,7 +62,7 @@ describe('CreationTransactionList', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
variables: {
|
variables: {
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pageSize: 25,
|
pageSize: 10,
|
||||||
order: 'DESC',
|
order: 'DESC',
|
||||||
userId: 1,
|
userId: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,7 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="component-creation-transaction-list">
|
<div class="component-creation-transaction-list">
|
||||||
<div class="h3">{{ $t('transactionlist.title') }}</div>
|
<div class="h3">{{ $t('transactionlist.title') }}</div>
|
||||||
<b-table striped hover :fields="fields" :items="items"></b-table>
|
<b-table striped hover :fields="fields" :items="items">
|
||||||
|
<template #cell(contributionDate)="data">
|
||||||
|
<div class="font-weight-bold">
|
||||||
|
{{ $d(new Date(data.item.contributionDate), 'month') }}
|
||||||
|
</div>
|
||||||
|
<div>{{ $d(new Date(data.item.contributionDate)) }}</div>
|
||||||
|
</template>
|
||||||
|
</b-table>
|
||||||
|
<div>
|
||||||
|
<b-pagination
|
||||||
|
pills
|
||||||
|
size="lg"
|
||||||
|
v-model="currentPage"
|
||||||
|
:per-page="perPage"
|
||||||
|
:total-rows="rows"
|
||||||
|
align="center"
|
||||||
|
:hide-ellipsis="true"
|
||||||
|
></b-pagination>
|
||||||
|
<b-button v-b-toggle.collapse-1 variant="light" size="sm">{{ $t('help.help') }}</b-button>
|
||||||
|
<b-collapse id="collapse-1" class="mt-2">
|
||||||
|
<div>
|
||||||
|
{{ $t('transactionlist.submitted') }} {{ $t('math.equals') }}
|
||||||
|
{{ $t('help.transactionlist.submitted') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('transactionlist.period') }} {{ $t('math.equals') }}
|
||||||
|
{{ $t('help.transactionlist.periods') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('transactionlist.confirmed') }} {{ $t('math.equals') }}
|
||||||
|
{{ $t('help.transactionlist.confirmed') }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{ $t('transactionlist.state') }} {{ $t('math.equals') }}
|
||||||
|
{{ $t('help.transactionlist.state') }}
|
||||||
|
</div>
|
||||||
|
</b-collapse>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
@ -13,14 +50,37 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
items: [],
|
||||||
|
rows: 0,
|
||||||
|
currentPage: 1,
|
||||||
|
perPage: 10,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
key: 'creationDate',
|
key: 'createdAt',
|
||||||
label: this.$t('transactionlist.date'),
|
label: this.$t('transactionlist.submitted'),
|
||||||
formatter: (value, key, item) => {
|
formatter: (value, key, item) => {
|
||||||
return this.$d(new Date(value))
|
return this.$d(new Date(value))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'contributionDate',
|
||||||
|
label: this.$t('transactionlist.period'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'confirmedAt',
|
||||||
|
label: this.$t('transactionlist.confirmed'),
|
||||||
|
formatter: (value, key, item) => {
|
||||||
|
if (value) {
|
||||||
|
return this.$d(new Date(value))
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
label: this.$t('transactionlist.state'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'amount',
|
key: 'amount',
|
||||||
label: this.$t('transactionlist.amount'),
|
label: this.$t('transactionlist.amount'),
|
||||||
@ -28,23 +88,8 @@ export default {
|
|||||||
return `${value} GDD`
|
return `${value} GDD`
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'linkedUser',
|
|
||||||
label: this.$t('transactionlist.community'),
|
|
||||||
formatter: (value, key, item) => {
|
|
||||||
return `${value.firstName} ${value.lastName}`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ key: 'memo', label: this.$t('transactionlist.memo') },
|
{ key: 'memo', label: this.$t('transactionlist.memo') },
|
||||||
{
|
|
||||||
key: 'balanceDate',
|
|
||||||
label: this.$t('transactionlist.balanceDate'),
|
|
||||||
formatter: (value, key, item) => {
|
|
||||||
return this.$d(new Date(value))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
items: [],
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -53,14 +98,15 @@ export default {
|
|||||||
.query({
|
.query({
|
||||||
query: creationTransactionList,
|
query: creationTransactionList,
|
||||||
variables: {
|
variables: {
|
||||||
currentPage: 1,
|
currentPage: this.currentPage,
|
||||||
pageSize: 25,
|
pageSize: this.perPage,
|
||||||
order: 'DESC',
|
order: 'DESC',
|
||||||
userId: parseInt(this.userId),
|
userId: parseInt(this.userId),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
this.items = result.data.creationTransactionList
|
this.rows = result.data.creationTransactionList.contributionCount
|
||||||
|
this.items = result.data.creationTransactionList.contributionList
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.toastError(error.message)
|
this.toastError(error.message)
|
||||||
@ -70,5 +116,10 @@ export default {
|
|||||||
created() {
|
created() {
|
||||||
this.getTransactions()
|
this.getTransactions()
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
currentPage() {
|
||||||
|
this.getTransactions()
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
value-field="item"
|
value-field="item"
|
||||||
text-field="name"
|
text-field="name"
|
||||||
name="month-selection"
|
name="month-selection"
|
||||||
|
:disabled="true"
|
||||||
></b-form-radio-group>
|
></b-form-radio-group>
|
||||||
</b-row>
|
</b-row>
|
||||||
<div class="m-4">
|
<div class="m-4">
|
||||||
|
|||||||
@ -3,11 +3,15 @@ import NavBar from './NavBar.vue'
|
|||||||
|
|
||||||
const localVue = global.localVue
|
const localVue = global.localVue
|
||||||
|
|
||||||
|
const apolloMutateMock = jest.fn()
|
||||||
const storeDispatchMock = jest.fn()
|
const storeDispatchMock = jest.fn()
|
||||||
const routerPushMock = jest.fn()
|
const routerPushMock = jest.fn()
|
||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
$t: jest.fn((t) => t),
|
$t: jest.fn((t) => t),
|
||||||
|
$apollo: {
|
||||||
|
mutate: apolloMutateMock,
|
||||||
|
},
|
||||||
$store: {
|
$store: {
|
||||||
state: {
|
state: {
|
||||||
openCreations: 1,
|
openCreations: 1,
|
||||||
@ -69,5 +73,9 @@ describe('NavBar', () => {
|
|||||||
it('dispatches logout to store', () => {
|
it('dispatches logout to store', () => {
|
||||||
expect(storeDispatchMock).toBeCalledWith('logout')
|
expect(storeDispatchMock).toBeCalledWith('logout')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('has called logout mutation', () => {
|
||||||
|
expect(apolloMutateMock).toBeCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -28,14 +28,18 @@
|
|||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import CONFIG from '../config'
|
import CONFIG from '../config'
|
||||||
|
import { logout } from '../graphql/logout'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'navbar',
|
name: 'navbar',
|
||||||
methods: {
|
methods: {
|
||||||
logout() {
|
async logout() {
|
||||||
window.location.assign(CONFIG.WALLET_URL)
|
window.location.assign(CONFIG.WALLET_URL)
|
||||||
// window.location = CONFIG.WALLET_URL
|
// window.location = CONFIG.WALLET_URL
|
||||||
this.$store.dispatch('logout')
|
this.$store.dispatch('logout')
|
||||||
|
await this.$apollo.mutate({
|
||||||
|
mutation: logout,
|
||||||
|
})
|
||||||
},
|
},
|
||||||
wallet() {
|
wallet() {
|
||||||
window.location = CONFIG.WALLET_AUTH_URL.replace('{token}', this.$store.state.token)
|
window.location = CONFIG.WALLET_AUTH_URL.replace('{token}', this.$store.state.token)
|
||||||
|
|||||||
@ -28,7 +28,7 @@ const propsData = {
|
|||||||
amount: 210,
|
amount: 210,
|
||||||
memo: 'Aktives Grundeinkommen für Januar 2022',
|
memo: 'Aktives Grundeinkommen für Januar 2022',
|
||||||
date: '2022-01-01T00:00:00.000Z',
|
date: '2022-01-01T00:00:00.000Z',
|
||||||
moderator: 1,
|
moderator: null,
|
||||||
creation: [790, 1000, 1000],
|
creation: [790, 1000, 1000],
|
||||||
__typename: 'PendingCreation',
|
__typename: 'PendingCreation',
|
||||||
},
|
},
|
||||||
@ -66,7 +66,7 @@ const propsData = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'moderator', label: 'moderator' },
|
{ key: 'moderator', label: 'moderator' },
|
||||||
{ key: 'edit_creation', label: 'edit' },
|
{ key: 'editCreation', label: 'edit' },
|
||||||
{ key: 'confirm', label: 'save' },
|
{ key: 'confirm', label: 'save' },
|
||||||
],
|
],
|
||||||
toggleDetails: false,
|
toggleDetails: false,
|
||||||
@ -113,6 +113,10 @@ describe('OpenCreationsTable', () => {
|
|||||||
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBe(true)
|
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('has no button.bi-pencil-square for user contribution ', () => {
|
||||||
|
expect(wrapper.findAll('tr').at(2).find('.bi-pencil-square').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
describe('show edit details', () => {
|
describe('show edit details', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await wrapper.findAll('tr').at(1).find('.bi-pencil-square').trigger('click')
|
await wrapper.findAll('tr').at(1).find('.bi-pencil-square').trigger('click')
|
||||||
|
|||||||
@ -11,15 +11,43 @@
|
|||||||
<b-icon icon="x" variant="light"></b-icon>
|
<b-icon icon="x" variant="light"></b-icon>
|
||||||
</b-button>
|
</b-button>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(edit_creation)="row">
|
<template #cell(editCreation)="row">
|
||||||
<b-button variant="info" size="md" @click="rowToggleDetails(row, 0)" class="mr-2">
|
<div v-if="$store.state.moderator.id !== row.item.userId">
|
||||||
<b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon>
|
<b-button
|
||||||
</b-button>
|
v-if="row.item.moderator"
|
||||||
|
variant="info"
|
||||||
|
size="md"
|
||||||
|
@click="rowToggleDetails(row, 0)"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon>
|
||||||
|
</b-button>
|
||||||
|
<b-button v-else @click="rowToggleDetails(row, 0)">
|
||||||
|
<b-icon icon="chat-dots"></b-icon>
|
||||||
|
<b-icon
|
||||||
|
v-if="row.item.state === 'PENDING' && row.item.messageCount > 0"
|
||||||
|
icon="exclamation-circle-fill"
|
||||||
|
variant="warning"
|
||||||
|
></b-icon>
|
||||||
|
<b-icon
|
||||||
|
v-if="row.item.state === 'IN_PROGRESS' && row.item.messageCount > 0"
|
||||||
|
icon="question-diamond"
|
||||||
|
variant="light"
|
||||||
|
></b-icon>
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell(confirm)="row">
|
<template #cell(confirm)="row">
|
||||||
<b-button variant="success" size="md" @click="$emit('show-overlay', row.item)" class="mr-2">
|
<div v-if="$store.state.moderator.id !== row.item.userId">
|
||||||
<b-icon icon="check" scale="2" variant=""></b-icon>
|
<b-button
|
||||||
</b-button>
|
variant="success"
|
||||||
|
size="md"
|
||||||
|
@click="$emit('show-overlay', row.item)"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
<b-icon icon="check" scale="2" variant=""></b-icon>
|
||||||
|
</b-button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #row-details="row">
|
<template #row-details="row">
|
||||||
<row-details
|
<row-details
|
||||||
@ -27,10 +55,10 @@
|
|||||||
type="show-creation"
|
type="show-creation"
|
||||||
slotName="show-creation"
|
slotName="show-creation"
|
||||||
:index="0"
|
:index="0"
|
||||||
@row-toggle-details="rowToggleDetails"
|
@row-toggle-details="rowToggleDetails(row, 0)"
|
||||||
>
|
>
|
||||||
<template #show-creation>
|
<template #show-creation>
|
||||||
<div>
|
<div v-if="row.item.moderator">
|
||||||
<edit-creation-formular
|
<edit-creation-formular
|
||||||
type="singleCreation"
|
type="singleCreation"
|
||||||
:creation="row.item.creation"
|
:creation="row.item.creation"
|
||||||
@ -38,6 +66,12 @@
|
|||||||
:row="row"
|
:row="row"
|
||||||
:creationUserData="creationUserData"
|
:creationUserData="creationUserData"
|
||||||
@update-creation-data="updateCreationData"
|
@update-creation-data="updateCreationData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<contribution-messages-list
|
||||||
|
:contributionId="row.item.id"
|
||||||
|
@update-state="updateState"
|
||||||
@update-user-data="updateUserData"
|
@update-user-data="updateUserData"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -52,6 +86,7 @@
|
|||||||
import { toggleRowDetails } from '../../mixins/toggleRowDetails'
|
import { toggleRowDetails } from '../../mixins/toggleRowDetails'
|
||||||
import RowDetails from '../RowDetails.vue'
|
import RowDetails from '../RowDetails.vue'
|
||||||
import EditCreationFormular from '../EditCreationFormular.vue'
|
import EditCreationFormular from '../EditCreationFormular.vue'
|
||||||
|
import ContributionMessagesList from '../ContributionMessages/ContributionMessagesList.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'OpenCreationsTable',
|
name: 'OpenCreationsTable',
|
||||||
@ -59,6 +94,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
EditCreationFormular,
|
EditCreationFormular,
|
||||||
RowDetails,
|
RowDetails,
|
||||||
|
ContributionMessagesList,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
items: {
|
items: {
|
||||||
@ -92,6 +128,9 @@ export default {
|
|||||||
updateUserData(rowItem, newCreation) {
|
updateUserData(rowItem, newCreation) {
|
||||||
rowItem.creation = newCreation
|
rowItem.creation = newCreation
|
||||||
},
|
},
|
||||||
|
updateState(id) {
|
||||||
|
this.$emit('update-state', id)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
:per-page="perPage"
|
:per-page="perPage"
|
||||||
:total-rows="rows"
|
:total-rows="rows"
|
||||||
align="center"
|
align="center"
|
||||||
|
:hide-ellipsis="true"
|
||||||
></b-pagination>
|
></b-pagination>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
15
admin/src/graphql/adminCreateContributionMessage.js
Normal file
15
admin/src/graphql/adminCreateContributionMessage.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
|
export const adminCreateContributionMessage = gql`
|
||||||
|
mutation ($contributionId: Float!, $message: String!) {
|
||||||
|
adminCreateContributionMessage(contributionId: $contributionId, message: $message) {
|
||||||
|
id
|
||||||
|
message
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
type
|
||||||
|
userFirstName
|
||||||
|
userLastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
15
admin/src/graphql/communityStatistics.js
Normal file
15
admin/src/graphql/communityStatistics.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
|
export const communityStatistics = gql`
|
||||||
|
query {
|
||||||
|
communityStatistics {
|
||||||
|
totalUsers
|
||||||
|
activeUsers
|
||||||
|
deletedUsers
|
||||||
|
totalGradidoCreated
|
||||||
|
totalGradidoDecayed
|
||||||
|
totalGradidoAvailable
|
||||||
|
totalGradidoUnbookedDecayed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
@ -8,14 +8,15 @@ export const creationTransactionList = gql`
|
|||||||
order: $order
|
order: $order
|
||||||
userId: $userId
|
userId: $userId
|
||||||
) {
|
) {
|
||||||
id
|
contributionCount
|
||||||
amount
|
contributionList {
|
||||||
balanceDate
|
id
|
||||||
creationDate
|
amount
|
||||||
memo
|
createdAt
|
||||||
linkedUser {
|
confirmedAt
|
||||||
firstName
|
contributionDate
|
||||||
lastName
|
memo
|
||||||
|
state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
admin/src/graphql/listContributionMessages.js
Normal file
25
admin/src/graphql/listContributionMessages.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
|
export const listContributionMessages = gql`
|
||||||
|
query ($contributionId: Float!, $pageSize: Int = 25, $currentPage: Int = 1, $order: Order = ASC) {
|
||||||
|
listContributionMessages(
|
||||||
|
contributionId: $contributionId
|
||||||
|
pageSize: $pageSize
|
||||||
|
currentPage: $currentPage
|
||||||
|
order: $order
|
||||||
|
) {
|
||||||
|
count
|
||||||
|
messages {
|
||||||
|
id
|
||||||
|
message
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
type
|
||||||
|
userFirstName
|
||||||
|
userLastName
|
||||||
|
userId
|
||||||
|
isModerator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
@ -6,12 +6,15 @@ export const listUnconfirmedContributions = gql`
|
|||||||
id
|
id
|
||||||
firstName
|
firstName
|
||||||
lastName
|
lastName
|
||||||
|
userId
|
||||||
email
|
email
|
||||||
amount
|
amount
|
||||||
memo
|
memo
|
||||||
date
|
date
|
||||||
moderator
|
moderator
|
||||||
creation
|
creation
|
||||||
|
state
|
||||||
|
messageCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
7
admin/src/graphql/logout.js
Normal file
7
admin/src/graphql/logout.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import gql from 'graphql-tag'
|
||||||
|
|
||||||
|
export const logout = gql`
|
||||||
|
mutation {
|
||||||
|
logout
|
||||||
|
}
|
||||||
|
`
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import gql from 'graphql-tag'
|
|
||||||
|
|
||||||
export const showContributionLink = gql`
|
|
||||||
query ($id: Int!) {
|
|
||||||
showContributionLink {
|
|
||||||
id
|
|
||||||
validFrom
|
|
||||||
validTo
|
|
||||||
name
|
|
||||||
memo
|
|
||||||
amount
|
|
||||||
cycle
|
|
||||||
maxPerCycle
|
|
||||||
maxAmountPerMonth
|
|
||||||
code
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
@ -7,8 +7,8 @@
|
|||||||
"contributionLinks": "Beitragslinks",
|
"contributionLinks": "Beitragslinks",
|
||||||
"create": "Anlegen",
|
"create": "Anlegen",
|
||||||
"cycle": "Zyklus",
|
"cycle": "Zyklus",
|
||||||
"deleteNow": "Automatische Creations wirklich löschen?",
|
"deleted": "Automatische Schöpfung gelöscht!",
|
||||||
"maximumAmount": "maximaler Betrag",
|
"deleteNow": "Automatische Creations '{name}' wirklich löschen?",
|
||||||
"maxPerCycle": "Wiederholungen",
|
"maxPerCycle": "Wiederholungen",
|
||||||
"memo": "Nachricht",
|
"memo": "Nachricht",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@ -20,11 +20,7 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"cycle": {
|
"cycle": {
|
||||||
"daily": "täglich",
|
"daily": "täglich",
|
||||||
"hourly": "stündlich",
|
"once": "einmalig"
|
||||||
"monthly": "monatlich",
|
|
||||||
"once": "einmalig",
|
|
||||||
"weekly": "wöchentlich",
|
|
||||||
"yearly": "jährlich"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"validFrom": "Startdatum",
|
"validFrom": "Startdatum",
|
||||||
@ -35,6 +31,7 @@
|
|||||||
"creation_form": {
|
"creation_form": {
|
||||||
"creation_failed": "Ausstehende Schöpfung für {email} konnte nicht erzeugt werden.",
|
"creation_failed": "Ausstehende Schöpfung für {email} konnte nicht erzeugt werden.",
|
||||||
"creation_for": "Aktives Grundeinkommen für",
|
"creation_for": "Aktives Grundeinkommen für",
|
||||||
|
"deleteNow": "Möchtest du diesen Beitrag zur Gemeinschaft wirklich löschen?",
|
||||||
"enter_text": "Text eintragen",
|
"enter_text": "Text eintragen",
|
||||||
"form": "Schöpfungsformular",
|
"form": "Schöpfungsformular",
|
||||||
"min_characters": "Mindestens 10 Zeichen eingeben",
|
"min_characters": "Mindestens 10 Zeichen eingeben",
|
||||||
@ -68,14 +65,32 @@
|
|||||||
},
|
},
|
||||||
"short_hash": "({shortHash})"
|
"short_hash": "({shortHash})"
|
||||||
},
|
},
|
||||||
|
"form": {
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"submit": "Senden"
|
||||||
|
},
|
||||||
"GDD": "GDD",
|
"GDD": "GDD",
|
||||||
|
"help": {
|
||||||
|
"help": "Hilfe",
|
||||||
|
"transactionlist": {
|
||||||
|
"confirmed": "Wann wurde es von einem Moderator / Admin bestätigt.",
|
||||||
|
"periods": "Für welchen Zeitraum wurde vom Mitglied eingereicht.",
|
||||||
|
"state": "[PENDING = eingereicht, DELETED = gelöscht, IN_PROGRESS = im Dialog mit Moderator, DENIED = abgelehnt, CONFIRMED = bestätigt]",
|
||||||
|
"submitted": "Wann wurde es vom Mitglied eingereicht"
|
||||||
|
}
|
||||||
|
},
|
||||||
"hide_details": "Details verbergen",
|
"hide_details": "Details verbergen",
|
||||||
"lastname": "Nachname",
|
"lastname": "Nachname",
|
||||||
"math": {
|
"math": {
|
||||||
|
"colon": ":",
|
||||||
|
"equals": "=",
|
||||||
"exclaim": "!",
|
"exclaim": "!",
|
||||||
"pipe": "|",
|
"pipe": "|",
|
||||||
"plus": "+"
|
"plus": "+"
|
||||||
},
|
},
|
||||||
|
"message": {
|
||||||
|
"request": "Die Anfrage wurde gesendet."
|
||||||
|
},
|
||||||
"moderator": "Moderator",
|
"moderator": "Moderator",
|
||||||
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
|
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@ -104,6 +119,16 @@
|
|||||||
"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",
|
"remove_all": "alle Nutzer entfernen",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
"statistic": {
|
||||||
|
"activeUsers": "Aktive Mitglieder",
|
||||||
|
"deletedUsers": "Gelöschte Mitglieder",
|
||||||
|
"name": "Statistik",
|
||||||
|
"totalGradidoAvailable": "GDD insgesamt im Umlauf",
|
||||||
|
"totalGradidoCreated": "GDD insgesamt geschöpft",
|
||||||
|
"totalGradidoDecayed": "GDD insgesamt verfallen",
|
||||||
|
"totalGradidoUnbookedDecayed": "Ungebuchter GDD Verfall",
|
||||||
|
"totalUsers": "Mitglieder"
|
||||||
|
},
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"success": "Erfolg",
|
"success": "Erfolg",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
@ -114,10 +139,11 @@
|
|||||||
},
|
},
|
||||||
"transactionlist": {
|
"transactionlist": {
|
||||||
"amount": "Betrag",
|
"amount": "Betrag",
|
||||||
"balanceDate": "Schöpfungsdatum",
|
"confirmed": "Bestätigt",
|
||||||
"community": "Gemeinschaft",
|
|
||||||
"date": "Datum",
|
|
||||||
"memo": "Nachricht",
|
"memo": "Nachricht",
|
||||||
|
"period": "Zeitraum",
|
||||||
|
"state": "Status",
|
||||||
|
"submitted": "Eingereicht",
|
||||||
"title": "Alle geschöpften Transaktionen für den Nutzer"
|
"title": "Alle geschöpften Transaktionen für den Nutzer"
|
||||||
},
|
},
|
||||||
"undelete_user": "Nutzer wiederherstellen",
|
"undelete_user": "Nutzer wiederherstellen",
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
"contributionLinks": "Contribution Links",
|
"contributionLinks": "Contribution Links",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"cycle": "Cycle",
|
"cycle": "Cycle",
|
||||||
"deleteNow": "Do you really delete automatic creations?",
|
"deleted": "Automatic creation deleted!",
|
||||||
"maximumAmount": "Maximum amount",
|
"deleteNow": "Do you really delete automatic creations '{name}'?",
|
||||||
"maxPerCycle": "Repetition",
|
"maxPerCycle": "Repetition",
|
||||||
"memo": "Memo",
|
"memo": "Memo",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@ -20,11 +20,7 @@
|
|||||||
"options": {
|
"options": {
|
||||||
"cycle": {
|
"cycle": {
|
||||||
"daily": "daily",
|
"daily": "daily",
|
||||||
"hourly": "hourly",
|
"once": "once"
|
||||||
"monthly": "monthly",
|
|
||||||
"once": "once",
|
|
||||||
"weekly": "weekly",
|
|
||||||
"yearly": "yearly"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"validFrom": "Start-date",
|
"validFrom": "Start-date",
|
||||||
@ -35,6 +31,7 @@
|
|||||||
"creation_form": {
|
"creation_form": {
|
||||||
"creation_failed": "Could not create pending creation for {email}",
|
"creation_failed": "Could not create pending creation for {email}",
|
||||||
"creation_for": "Active Basic Income for",
|
"creation_for": "Active Basic Income for",
|
||||||
|
"deleteNow": "Do you really want to delete this contribution to the community?",
|
||||||
"enter_text": "Enter text",
|
"enter_text": "Enter text",
|
||||||
"form": "Creation form",
|
"form": "Creation form",
|
||||||
"min_characters": "Enter at least 10 characters",
|
"min_characters": "Enter at least 10 characters",
|
||||||
@ -68,14 +65,32 @@
|
|||||||
},
|
},
|
||||||
"short_hash": "({shortHash})"
|
"short_hash": "({shortHash})"
|
||||||
},
|
},
|
||||||
|
"form": {
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"submit": "Send"
|
||||||
|
},
|
||||||
"GDD": "GDD",
|
"GDD": "GDD",
|
||||||
|
"help": {
|
||||||
|
"help": "Help",
|
||||||
|
"transactionlist": {
|
||||||
|
"confirmed": "When was it confirmed by a moderator / admin.",
|
||||||
|
"periods": "For what period was it submitted by the member.",
|
||||||
|
"state": "[PENDING = submitted, DELETED = deleted, IN_PROGRESS = in dialogue with moderator, DENIED = denied, CONFIRMED = confirmed]",
|
||||||
|
"submitted": "When was it submitted by the member"
|
||||||
|
}
|
||||||
|
},
|
||||||
"hide_details": "Hide details",
|
"hide_details": "Hide details",
|
||||||
"lastname": "Lastname",
|
"lastname": "Lastname",
|
||||||
"math": {
|
"math": {
|
||||||
|
"colon": ":",
|
||||||
|
"equals": "=",
|
||||||
"exclaim": "!",
|
"exclaim": "!",
|
||||||
"pipe": "|",
|
"pipe": "|",
|
||||||
"plus": "+"
|
"plus": "+"
|
||||||
},
|
},
|
||||||
|
"message": {
|
||||||
|
"request": "Request has been sent."
|
||||||
|
},
|
||||||
"moderator": "Moderator",
|
"moderator": "Moderator",
|
||||||
"multiple_creation_text": "Please select one or more members for which you would like to perform creations.",
|
"multiple_creation_text": "Please select one or more members for which you would like to perform creations.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@ -104,6 +119,16 @@
|
|||||||
"removeNotSelf": "As an admin/moderator, you cannot delete yourself.",
|
"removeNotSelf": "As an admin/moderator, you cannot delete yourself.",
|
||||||
"remove_all": "Remove all users",
|
"remove_all": "Remove all users",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
"statistic": {
|
||||||
|
"activeUsers": "Active members",
|
||||||
|
"deletedUsers": "Deleted members",
|
||||||
|
"name": "Statistic",
|
||||||
|
"totalGradidoAvailable": "Total GDD in circulation",
|
||||||
|
"totalGradidoCreated": "Total created GDD",
|
||||||
|
"totalGradidoDecayed": "Total GDD decay",
|
||||||
|
"totalGradidoUnbookedDecayed": "Unbooked GDD decay",
|
||||||
|
"totalUsers": "Members"
|
||||||
|
},
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
@ -114,10 +139,11 @@
|
|||||||
},
|
},
|
||||||
"transactionlist": {
|
"transactionlist": {
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
"balanceDate": "Creation date",
|
"confirmed": "Confirmed",
|
||||||
"community": "Community",
|
|
||||||
"date": "Date",
|
|
||||||
"memo": "Message",
|
"memo": "Message",
|
||||||
|
"period": "Period",
|
||||||
|
"state": "State",
|
||||||
|
"submitted": "Submitted",
|
||||||
"title": "All creation-transactions for the user"
|
"title": "All creation-transactions for the user"
|
||||||
},
|
},
|
||||||
"undelete_user": "Undelete User",
|
"undelete_user": "Undelete User",
|
||||||
|
|||||||
@ -29,6 +29,7 @@
|
|||||||
per-page="perPage"
|
per-page="perPage"
|
||||||
:total-rows="rows"
|
:total-rows="rows"
|
||||||
align="center"
|
align="center"
|
||||||
|
:hide-ellipsis="true"
|
||||||
></b-pagination>
|
></b-pagination>
|
||||||
</b-col>
|
</b-col>
|
||||||
<b-col cols="12" lg="6" class="shadow p-3 mb-5 rounded bg-info">
|
<b-col cols="12" lg="6" class="shadow p-3 mb-5 rounded bg-info">
|
||||||
|
|||||||
@ -14,21 +14,23 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
|||||||
id: 1,
|
id: 1,
|
||||||
firstName: 'Bibi',
|
firstName: 'Bibi',
|
||||||
lastName: 'Bloxberg',
|
lastName: 'Bloxberg',
|
||||||
|
userId: 99,
|
||||||
email: 'bibi@bloxberg.de',
|
email: 'bibi@bloxberg.de',
|
||||||
amount: 500,
|
amount: 500,
|
||||||
memo: 'Danke für alles',
|
memo: 'Danke für alles',
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
moderator: 0,
|
moderator: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
firstName: 'Räuber',
|
firstName: 'Räuber',
|
||||||
lastName: 'Hotzenplotz',
|
lastName: 'Hotzenplotz',
|
||||||
|
userId: 100,
|
||||||
email: 'raeuber@hotzenplotz.de',
|
email: 'raeuber@hotzenplotz.de',
|
||||||
amount: 1000000,
|
amount: 1000000,
|
||||||
memo: 'Gut Ergattert',
|
memo: 'Gut Ergattert',
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
moderator: 0,
|
moderator: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -41,6 +43,15 @@ const mocks = {
|
|||||||
$d: jest.fn((d) => d),
|
$d: jest.fn((d) => d),
|
||||||
$store: {
|
$store: {
|
||||||
commit: storeCommitMock,
|
commit: storeCommitMock,
|
||||||
|
state: {
|
||||||
|
moderator: {
|
||||||
|
firstName: 'Peter',
|
||||||
|
lastName: 'Lustig',
|
||||||
|
isAdmin: '2022-08-30T07:41:31.000Z',
|
||||||
|
id: 263,
|
||||||
|
language: 'de',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
$apollo: {
|
$apollo: {
|
||||||
query: apolloQueryMock,
|
query: apolloQueryMock,
|
||||||
@ -80,28 +91,54 @@ describe('CreationConfirm', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('remove creation with success', () => {
|
describe('remove creation with success', () => {
|
||||||
beforeEach(async () => {
|
let spy
|
||||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls the adminDeleteContribution mutation', () => {
|
describe('admin confirms deletion', () => {
|
||||||
expect(apolloMutateMock).toBeCalledWith({
|
beforeEach(async () => {
|
||||||
mutation: adminDeleteContribution,
|
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||||
variables: { id: 1 },
|
spy.mockImplementation(() => Promise.resolve('some value'))
|
||||||
|
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a modal', () => {
|
||||||
|
expect(spy).toBeCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls the adminDeleteContribution mutation', () => {
|
||||||
|
expect(apolloMutateMock).toBeCalledWith({
|
||||||
|
mutation: adminDeleteContribution,
|
||||||
|
variables: { id: 1 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits openCreationsMinus to store', () => {
|
||||||
|
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toasts a success message', () => {
|
||||||
|
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('commits openCreationsMinus to store', () => {
|
describe('admin cancels deletion', () => {
|
||||||
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
|
beforeEach(async () => {
|
||||||
})
|
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||||
|
spy.mockImplementation(() => Promise.resolve(false))
|
||||||
|
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||||
|
})
|
||||||
|
|
||||||
it('toasts a success message', () => {
|
it('does not call the adminDeleteContribution mutation', () => {
|
||||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
|
expect(apolloMutateMock).not.toBeCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('remove creation with error', () => {
|
describe('remove creation with error', () => {
|
||||||
|
let spy
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||||
|
spy.mockImplementation(() => Promise.resolve('some value'))
|
||||||
apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' })
|
apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' })
|
||||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
:fields="fields"
|
:fields="fields"
|
||||||
@remove-creation="removeCreation"
|
@remove-creation="removeCreation"
|
||||||
@show-overlay="showOverlay"
|
@show-overlay="showOverlay"
|
||||||
|
@update-state="updateState"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -34,20 +35,23 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
removeCreation(item) {
|
removeCreation(item) {
|
||||||
this.$apollo
|
this.$bvModal.msgBoxConfirm(this.$t('creation_form.deleteNow')).then(async (value) => {
|
||||||
.mutate({
|
if (value)
|
||||||
mutation: adminDeleteContribution,
|
await this.$apollo
|
||||||
variables: {
|
.mutate({
|
||||||
id: item.id,
|
mutation: adminDeleteContribution,
|
||||||
},
|
variables: {
|
||||||
})
|
id: item.id,
|
||||||
.then((result) => {
|
},
|
||||||
this.updatePendingCreations(item.id)
|
})
|
||||||
this.toastSuccess(this.$t('creation_form.toasted_delete'))
|
.then((result) => {
|
||||||
})
|
this.updatePendingCreations(item.id)
|
||||||
.catch((error) => {
|
this.toastSuccess(this.$t('creation_form.toasted_delete'))
|
||||||
this.toastError(error.message)
|
})
|
||||||
})
|
.catch((error) => {
|
||||||
|
this.toastError(error.message)
|
||||||
|
})
|
||||||
|
})
|
||||||
},
|
},
|
||||||
confirmCreation() {
|
confirmCreation() {
|
||||||
this.$apollo
|
this.$apollo
|
||||||
@ -90,6 +94,10 @@ export default {
|
|||||||
this.overlay = true
|
this.overlay = true
|
||||||
this.item = item
|
this.item = item
|
||||||
},
|
},
|
||||||
|
updateState(id) {
|
||||||
|
this.pendingCreations.find((obj) => obj.id === id).messagesCount++
|
||||||
|
this.pendingCreations.find((obj) => obj.id === id).state = 'IN_PROGRESS'
|
||||||
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
fields() {
|
fields() {
|
||||||
@ -114,7 +122,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ key: 'moderator', label: this.$t('moderator') },
|
{ key: 'moderator', label: this.$t('moderator') },
|
||||||
{ key: 'edit_creation', label: this.$t('edit') },
|
{ key: 'editCreation', label: this.$t('edit') },
|
||||||
{ key: 'confirm', label: this.$t('save') },
|
{ key: 'confirm', label: this.$t('save') },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,28 +1,84 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import Overview from './Overview.vue'
|
import Overview from './Overview.vue'
|
||||||
|
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
|
||||||
|
import { communityStatistics } from '@/graphql/communityStatistics.js'
|
||||||
|
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
|
||||||
|
|
||||||
const localVue = global.localVue
|
const localVue = global.localVue
|
||||||
|
|
||||||
const apolloQueryMock = jest.fn().mockResolvedValue({
|
const apolloQueryMock = jest
|
||||||
data: {
|
.fn()
|
||||||
listUnconfirmedContributions: [
|
.mockResolvedValueOnce({
|
||||||
{
|
data: {
|
||||||
pending: true,
|
listUnconfirmedContributions: [
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
communityStatistics: {
|
||||||
|
totalUsers: 3113,
|
||||||
|
activeUsers: 1057,
|
||||||
|
deletedUsers: 35,
|
||||||
|
totalGradidoCreated: '4083774.05000000000000000000',
|
||||||
|
totalGradidoDecayed: '-1062639.13634129622923372197',
|
||||||
|
totalGradidoAvailable: '2513565.869444365732411569',
|
||||||
|
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
pending: true,
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
listContributionLinks: {
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Meditation',
|
||||||
|
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
|
||||||
|
amount: '200',
|
||||||
|
validFrom: '2022-04-01',
|
||||||
|
validTo: '2022-08-01',
|
||||||
|
cycle: 'täglich',
|
||||||
|
maxPerCycle: '3',
|
||||||
|
maxAmountPerMonth: 0,
|
||||||
|
link: 'https://localhost/redeem/CL-1a2345678',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
count: 1,
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
pending: true,
|
})
|
||||||
},
|
.mockResolvedValue({
|
||||||
],
|
data: {
|
||||||
},
|
listUnconfirmedContributions: [
|
||||||
})
|
{
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pending: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const storeCommitMock = jest.fn()
|
const storeCommitMock = jest.fn()
|
||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
$t: jest.fn((t) => t),
|
$t: jest.fn((t) => t),
|
||||||
|
$n: jest.fn((n) => n),
|
||||||
|
$d: jest.fn((d) => d),
|
||||||
$apollo: {
|
$apollo: {
|
||||||
query: apolloQueryMock,
|
query: apolloQueryMock,
|
||||||
},
|
},
|
||||||
@ -47,10 +103,30 @@ describe('Overview', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('calls listUnconfirmedContributions', () => {
|
it('calls listUnconfirmedContributions', () => {
|
||||||
expect(apolloQueryMock).toBeCalled()
|
expect(apolloQueryMock).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
query: listUnconfirmedContributions,
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('commts three pending creations to store', () => {
|
it('calls communityStatistics', () => {
|
||||||
|
expect(apolloQueryMock).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
query: communityStatistics,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls listContributionLinks', () => {
|
||||||
|
expect(apolloQueryMock).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
query: listContributionLinks,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits three pending creations to store', () => {
|
||||||
expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3)
|
expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -28,27 +28,44 @@
|
|||||||
</b-link>
|
</b-link>
|
||||||
</b-card-text>
|
</b-card-text>
|
||||||
</b-card>
|
</b-card>
|
||||||
<contribution-link :items="items" :count="count" />
|
<contribution-link
|
||||||
|
:items="items"
|
||||||
|
:count="count"
|
||||||
|
@get-contribution-links="getContributionLinks"
|
||||||
|
/>
|
||||||
|
<community-statistic class="mt-5" v-model="statistics" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
|
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
|
||||||
|
import { communityStatistics } from '@/graphql/communityStatistics.js'
|
||||||
import ContributionLink from '../components/ContributionLink.vue'
|
import ContributionLink from '../components/ContributionLink.vue'
|
||||||
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
|
import CommunityStatistic from '../components/CommunityStatistic.vue'
|
||||||
|
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'overview',
|
name: 'overview',
|
||||||
components: {
|
components: {
|
||||||
ContributionLink,
|
ContributionLink,
|
||||||
|
CommunityStatistic,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
items: [],
|
items: [],
|
||||||
count: 0,
|
count: 0,
|
||||||
|
statistics: {
|
||||||
|
totalUsers: null,
|
||||||
|
activeUsers: null,
|
||||||
|
deletedUsers: null,
|
||||||
|
totalGradidoCreated: null,
|
||||||
|
totalGradidoDecayed: null,
|
||||||
|
totalGradidoAvailable: null,
|
||||||
|
totalGradidoUnbookedDecayed: null,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async getPendingCreations() {
|
getPendingCreations() {
|
||||||
this.$apollo
|
this.$apollo
|
||||||
.query({
|
.query({
|
||||||
query: listUnconfirmedContributions,
|
query: listUnconfirmedContributions,
|
||||||
@ -58,7 +75,7 @@ export default {
|
|||||||
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
|
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async getContributionLinks() {
|
getContributionLinks() {
|
||||||
this.$apollo
|
this.$apollo
|
||||||
.query({
|
.query({
|
||||||
query: listContributionLinks,
|
query: listContributionLinks,
|
||||||
@ -72,9 +89,30 @@ export default {
|
|||||||
this.toastError('listContributionLinks has no result, use default data')
|
this.toastError('listContributionLinks has no result, use default data')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
getCommunityStatistics() {
|
||||||
|
this.$apollo
|
||||||
|
.query({
|
||||||
|
query: communityStatistics,
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
this.statistics.totalUsers = result.data.communityStatistics.totalUsers
|
||||||
|
this.statistics.activeUsers = result.data.communityStatistics.activeUsers
|
||||||
|
this.statistics.deletedUsers = result.data.communityStatistics.deletedUsers
|
||||||
|
this.statistics.totalGradidoCreated = result.data.communityStatistics.totalGradidoCreated
|
||||||
|
this.statistics.totalGradidoDecayed = result.data.communityStatistics.totalGradidoDecayed
|
||||||
|
this.statistics.totalGradidoAvailable =
|
||||||
|
result.data.communityStatistics.totalGradidoAvailable
|
||||||
|
this.statistics.totalGradidoUnbookedDecayed =
|
||||||
|
result.data.communityStatistics.totalGradidoUnbookedDecayed
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.toastError('communityStatistics has no result, use default data')
|
||||||
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.getPendingCreations()
|
this.getPendingCreations()
|
||||||
|
this.getCommunityStatistics()
|
||||||
this.getContributionLinks()
|
this.getContributionLinks()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,6 +52,7 @@
|
|||||||
per-page="perPage"
|
per-page="perPage"
|
||||||
:total-rows="rows"
|
:total-rows="rows"
|
||||||
align="center"
|
align="center"
|
||||||
|
:hide-ellipsis="true"
|
||||||
></b-pagination>
|
></b-pagination>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const authLink = new ApolloLink((operation, forward) => {
|
|||||||
operation.setContext({
|
operation.setContext({
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
|
Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
|
||||||
|
clientRequestTime: new Date().toString(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return forward(operation).map((response) => {
|
return forward(operation).map((response) => {
|
||||||
|
|||||||
@ -94,6 +94,7 @@ describe('apolloProvider', () => {
|
|||||||
expect(setContextMock).toBeCalledWith({
|
expect(setContextMock).toBeCalledWith({
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: 'Bearer some-token',
|
Authorization: 'Bearer some-token',
|
||||||
|
clientRequestTime: expect.any(String),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -109,6 +110,7 @@ describe('apolloProvider', () => {
|
|||||||
expect(setContextMock).toBeCalledWith({
|
expect(setContextMock).toBeCalledWith({
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: '',
|
Authorization: '',
|
||||||
|
clientRequestTime: expect.any(String),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
CONFIG_VERSION=v8.2022-06-20
|
CONFIG_VERSION=v10.2022-09-20
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
PORT=4000
|
PORT=4000
|
||||||
@ -37,6 +37,8 @@ LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
|
|||||||
|
|
||||||
# EMail
|
# EMail
|
||||||
EMAIL=false
|
EMAIL=false
|
||||||
|
EMAIL_TEST_MODUS=false
|
||||||
|
EMAIL_TEST_RECEIVER=stage1@gradido.net
|
||||||
EMAIL_USERNAME=gradido_email
|
EMAIL_USERNAME=gradido_email
|
||||||
EMAIL_SENDER=info@gradido.net
|
EMAIL_SENDER=info@gradido.net
|
||||||
EMAIL_PASSWORD=xxx
|
EMAIL_PASSWORD=xxx
|
||||||
@ -52,6 +54,9 @@ EMAIL_CODE_REQUEST_TIME=10
|
|||||||
# Webhook
|
# Webhook
|
||||||
WEBHOOK_ELOPAGE_SECRET=secret
|
WEBHOOK_ELOPAGE_SECRET=secret
|
||||||
|
|
||||||
|
# EventProtocol
|
||||||
|
EVENT_PROTOCOL_DISABLED=false
|
||||||
|
|
||||||
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
|
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
|
||||||
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
|
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
|
|||||||
@ -36,6 +36,8 @@ LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
|
|||||||
|
|
||||||
# EMail
|
# EMail
|
||||||
EMAIL=$EMAIL
|
EMAIL=$EMAIL
|
||||||
|
EMAIL_TEST_MODUS=$EMAIL_TEST_MODUS
|
||||||
|
EMAIL_TEST_RECEIVER=$EMAIL_TEST_RECEIVER
|
||||||
EMAIL_USERNAME=$EMAIL_USERNAME
|
EMAIL_USERNAME=$EMAIL_USERNAME
|
||||||
EMAIL_SENDER=$EMAIL_SENDER
|
EMAIL_SENDER=$EMAIL_SENDER
|
||||||
EMAIL_PASSWORD=$EMAIL_PASSWORD
|
EMAIL_PASSWORD=$EMAIL_PASSWORD
|
||||||
@ -50,3 +52,6 @@ EMAIL_CODE_REQUEST_TIME=$EMAIL_CODE_REQUEST_TIME
|
|||||||
|
|
||||||
# Webhook
|
# Webhook
|
||||||
WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET
|
WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET
|
||||||
|
|
||||||
|
# EventProtocol
|
||||||
|
EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
# backend
|
# backend
|
||||||
|
|
||||||
## Project setup
|
## Project setup
|
||||||
```
|
|
||||||
|
```bash
|
||||||
yarn install
|
yarn install
|
||||||
```
|
```
|
||||||
|
|
||||||
## Seed DB
|
## Seed DB
|
||||||
```
|
|
||||||
|
```bash
|
||||||
yarn seed
|
yarn seed
|
||||||
```
|
```
|
||||||
Deletes all data in database. Then seeds data in database.
|
|
||||||
|
|
||||||
|
Deletes all data in database. Then seeds data in database.
|
||||||
|
|
||||||
## Seeded Users
|
## Seeded Users
|
||||||
|
|
||||||
@ -22,3 +24,47 @@ Deletes all data in database. Then seeds data in database.
|
|||||||
| bob@baumeister.de | `Aa12345_` | `false` | `true` | `false` |
|
| bob@baumeister.de | `Aa12345_` | `false` | `true` | `false` |
|
||||||
| garrick@ollivander.com | | `false` | `false` | `false` |
|
| garrick@ollivander.com | | `false` | `false` | `false` |
|
||||||
| stephen@hawking.uk | `Aa12345_` | `false` | `true` | `true` |
|
| 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.
|
||||||
|
|||||||
@ -5,6 +5,7 @@ module.exports = {
|
|||||||
collectCoverage: true,
|
collectCoverage: true,
|
||||||
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
|
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
|
||||||
setupFiles: ['<rootDir>/test/testSetup.ts'],
|
setupFiles: ['<rootDir>/test/testSetup.ts'],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/test/extensions.ts'],
|
||||||
modulePathIgnorePatterns: ['<rootDir>/build/'],
|
modulePathIgnorePatterns: ['<rootDir>/build/'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'@/(.*)': '<rootDir>/src/$1',
|
'@/(.*)': '<rootDir>/src/$1',
|
||||||
|
|||||||
@ -5,33 +5,66 @@
|
|||||||
{
|
{
|
||||||
"type": "dateFile",
|
"type": "dateFile",
|
||||||
"filename": "../logs/backend/access.log",
|
"filename": "../logs/backend/access.log",
|
||||||
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
|
"pattern": "yyyy-MM-dd",
|
||||||
|
"layout":
|
||||||
|
{
|
||||||
|
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||||
|
},
|
||||||
"keepFileExt" : true,
|
"keepFileExt" : true,
|
||||||
"fileNameSep" : "_"
|
"fileNameSep" : "_",
|
||||||
|
"numBackups" : 30
|
||||||
},
|
},
|
||||||
"apollo":
|
"apollo":
|
||||||
{
|
{
|
||||||
"type": "dateFile",
|
"type": "dateFile",
|
||||||
"filename": "../logs/backend/apollo.log",
|
"filename": "../logs/backend/apollo.log",
|
||||||
"pattern": "%d{ISO8601} %p %c %m",
|
"pattern": "yyyy-MM-dd",
|
||||||
|
"layout":
|
||||||
|
{
|
||||||
|
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||||
|
},
|
||||||
"keepFileExt" : true,
|
"keepFileExt" : true,
|
||||||
"fileNameSep" : "_"
|
"fileNameSep" : "_",
|
||||||
|
"numBackups" : 30
|
||||||
},
|
},
|
||||||
"backend":
|
"backend":
|
||||||
{
|
{
|
||||||
"type": "dateFile",
|
"type": "dateFile",
|
||||||
"filename": "../logs/backend/backend.log",
|
"filename": "../logs/backend/backend.log",
|
||||||
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
|
"pattern": "yyyy-MM-dd",
|
||||||
|
"layout":
|
||||||
|
{
|
||||||
|
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||||
|
},
|
||||||
"keepFileExt" : true,
|
"keepFileExt" : true,
|
||||||
"fileNameSep" : "_"
|
"fileNameSep" : "_",
|
||||||
|
"numBackups" : 30
|
||||||
|
},
|
||||||
|
"klicktipp":
|
||||||
|
{
|
||||||
|
"type": "dateFile",
|
||||||
|
"filename": "../logs/backend/klicktipp.log",
|
||||||
|
"pattern": "yyyy-MM-dd",
|
||||||
|
"layout":
|
||||||
|
{
|
||||||
|
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||||
|
},
|
||||||
|
"keepFileExt" : true,
|
||||||
|
"fileNameSep" : "_",
|
||||||
|
"numBackups" : 30
|
||||||
},
|
},
|
||||||
"errorFile":
|
"errorFile":
|
||||||
{
|
{
|
||||||
"type": "dateFile",
|
"type": "dateFile",
|
||||||
"filename": "../logs/backend/errors.log",
|
"filename": "../logs/backend/errors.log",
|
||||||
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
|
"pattern": "yyyy-MM-dd",
|
||||||
|
"layout":
|
||||||
|
{
|
||||||
|
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||||
|
},
|
||||||
"keepFileExt" : true,
|
"keepFileExt" : true,
|
||||||
"fileNameSep" : "_"
|
"fileNameSep" : "_",
|
||||||
|
"numBackups" : 30
|
||||||
},
|
},
|
||||||
"errors":
|
"errors":
|
||||||
{
|
{
|
||||||
@ -44,7 +77,7 @@
|
|||||||
"type": "stdout",
|
"type": "stdout",
|
||||||
"layout":
|
"layout":
|
||||||
{
|
{
|
||||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m"
|
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apolloOut":
|
"apolloOut":
|
||||||
@ -52,7 +85,7 @@
|
|||||||
"type": "stdout",
|
"type": "stdout",
|
||||||
"layout":
|
"layout":
|
||||||
{
|
{
|
||||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c %m"
|
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -90,6 +123,17 @@
|
|||||||
"level": "debug",
|
"level": "debug",
|
||||||
"enableCallStack": true
|
"enableCallStack": true
|
||||||
},
|
},
|
||||||
|
"klicktipp":
|
||||||
|
{
|
||||||
|
"appenders":
|
||||||
|
[
|
||||||
|
"klicktipp",
|
||||||
|
"out",
|
||||||
|
"errors"
|
||||||
|
],
|
||||||
|
"level": "debug",
|
||||||
|
"enableCallStack": true
|
||||||
|
},
|
||||||
"http":
|
"http":
|
||||||
{
|
{
|
||||||
"appenders":
|
"appenders":
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gradido-backend",
|
"name": "gradido-backend",
|
||||||
"version": "1.10.1",
|
"version": "1.13.1",
|
||||||
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
|
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"repository": "https://github.com/gradido/gradido/backend",
|
"repository": "https://github.com/gradido/gradido/backend",
|
||||||
@ -14,11 +14,13 @@
|
|||||||
"dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r tsconfig-paths/register src/index.ts",
|
"dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r tsconfig-paths/register src/index.ts",
|
||||||
"lint": "eslint --max-warnings=0 --ext .js,.ts .",
|
"lint": "eslint --max-warnings=0 --ext .js,.ts .",
|
||||||
"test": "cross-env TZ=UTC NODE_ENV=development jest --runInBand --coverage --forceExit --detectOpenHandles",
|
"test": "cross-env TZ=UTC NODE_ENV=development jest --runInBand --coverage --forceExit --detectOpenHandles",
|
||||||
"seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts"
|
"seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts",
|
||||||
|
"klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/klicktipp.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/jest": "^27.0.2",
|
"@types/jest": "^27.0.2",
|
||||||
"@types/lodash.clonedeep": "^4.5.6",
|
"@types/lodash.clonedeep": "^4.5.6",
|
||||||
|
"@types/uuid": "^8.3.4",
|
||||||
"apollo-server-express": "^2.25.2",
|
"apollo-server-express": "^2.25.2",
|
||||||
"apollo-server-testing": "^2.25.2",
|
"apollo-server-testing": "^2.25.2",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.1",
|
||||||
@ -39,7 +41,8 @@
|
|||||||
"reflect-metadata": "^0.1.13",
|
"reflect-metadata": "^0.1.13",
|
||||||
"sodium-native": "^3.3.0",
|
"sodium-native": "^3.3.0",
|
||||||
"ts-jest": "^27.0.5",
|
"ts-jest": "^27.0.5",
|
||||||
"type-graphql": "^1.1.1"
|
"type-graphql": "^1.1.1",
|
||||||
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.12",
|
"@types/express": "^4.17.12",
|
||||||
|
|||||||
@ -25,6 +25,16 @@ export enum RIGHTS {
|
|||||||
REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK',
|
REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK',
|
||||||
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
|
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
|
||||||
GDT_BALANCE = 'GDT_BALANCE',
|
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',
|
||||||
|
LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS',
|
||||||
|
COMMUNITY_STATISTICS = 'COMMUNITY_STATISTICS',
|
||||||
|
SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS',
|
||||||
|
CREATE_CONTRIBUTION_MESSAGE = 'CREATE_CONTRIBUTION_MESSAGE',
|
||||||
|
LIST_ALL_CONTRIBUTION_MESSAGES = 'LIST_ALL_CONTRIBUTION_MESSAGES',
|
||||||
// Admin
|
// Admin
|
||||||
SEARCH_USERS = 'SEARCH_USERS',
|
SEARCH_USERS = 'SEARCH_USERS',
|
||||||
SET_USER_ROLE = 'SET_USER_ROLE',
|
SET_USER_ROLE = 'SET_USER_ROLE',
|
||||||
@ -40,7 +50,7 @@ export enum RIGHTS {
|
|||||||
CREATION_TRANSACTION_LIST = 'CREATION_TRANSACTION_LIST',
|
CREATION_TRANSACTION_LIST = 'CREATION_TRANSACTION_LIST',
|
||||||
LIST_TRANSACTION_LINKS_ADMIN = 'LIST_TRANSACTION_LINKS_ADMIN',
|
LIST_TRANSACTION_LINKS_ADMIN = 'LIST_TRANSACTION_LINKS_ADMIN',
|
||||||
CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK',
|
CREATE_CONTRIBUTION_LINK = 'CREATE_CONTRIBUTION_LINK',
|
||||||
LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS',
|
|
||||||
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
|
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
|
||||||
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
|
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
|
||||||
|
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,16 @@ export const ROLE_USER = new Role('user', [
|
|||||||
RIGHTS.REDEEM_TRANSACTION_LINK,
|
RIGHTS.REDEEM_TRANSACTION_LINK,
|
||||||
RIGHTS.LIST_TRANSACTION_LINKS,
|
RIGHTS.LIST_TRANSACTION_LINKS,
|
||||||
RIGHTS.GDT_BALANCE,
|
RIGHTS.GDT_BALANCE,
|
||||||
|
RIGHTS.CREATE_CONTRIBUTION,
|
||||||
|
RIGHTS.DELETE_CONTRIBUTION,
|
||||||
|
RIGHTS.LIST_CONTRIBUTIONS,
|
||||||
|
RIGHTS.LIST_ALL_CONTRIBUTIONS,
|
||||||
|
RIGHTS.UPDATE_CONTRIBUTION,
|
||||||
|
RIGHTS.SEARCH_ADMIN_USERS,
|
||||||
|
RIGHTS.LIST_CONTRIBUTION_LINKS,
|
||||||
|
RIGHTS.COMMUNITY_STATISTICS,
|
||||||
|
RIGHTS.CREATE_CONTRIBUTION_MESSAGE,
|
||||||
|
RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES,
|
||||||
])
|
])
|
||||||
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights
|
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights
|
||||||
|
|
||||||
|
|||||||
@ -10,14 +10,14 @@ Decimal.set({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const constants = {
|
const constants = {
|
||||||
DB_VERSION: '0040-add_contribution_link_id_to_user',
|
DB_VERSION: '0050-add_messageId_to_event_protocol',
|
||||||
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
|
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
|
||||||
LOG4JS_CONFIG: 'log4js-config.json',
|
LOG4JS_CONFIG: 'log4js-config.json',
|
||||||
// default log level on production should be info
|
// default log level on production should be info
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
|
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
|
||||||
CONFIG_VERSION: {
|
CONFIG_VERSION: {
|
||||||
DEFAULT: 'DEFAULT',
|
DEFAULT: 'DEFAULT',
|
||||||
EXPECTED: 'v8.2022-06-20',
|
EXPECTED: 'v10.2022-09-20',
|
||||||
CURRENT: '',
|
CURRENT: '',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -67,6 +67,8 @@ const loginServer = {
|
|||||||
|
|
||||||
const email = {
|
const email = {
|
||||||
EMAIL: process.env.EMAIL === 'true' || false,
|
EMAIL: process.env.EMAIL === 'true' || false,
|
||||||
|
EMAIL_TEST_MODUS: process.env.EMAIL_TEST_MODUS === 'true' || false,
|
||||||
|
EMAIL_TEST_RECEIVER: process.env.EMAIL_TEST_RECEIVER || 'stage1@gradido.net',
|
||||||
EMAIL_USERNAME: process.env.EMAIL_USERNAME || 'gradido_email',
|
EMAIL_USERNAME: process.env.EMAIL_USERNAME || 'gradido_email',
|
||||||
EMAIL_SENDER: process.env.EMAIL_SENDER || 'info@gradido.net',
|
EMAIL_SENDER: process.env.EMAIL_SENDER || 'info@gradido.net',
|
||||||
EMAIL_PASSWORD: process.env.EMAIL_PASSWORD || 'xxx',
|
EMAIL_PASSWORD: process.env.EMAIL_PASSWORD || 'xxx',
|
||||||
@ -94,6 +96,11 @@ const webhook = {
|
|||||||
WEBHOOK_ELOPAGE_SECRET: process.env.WEBHOOK_ELOPAGE_SECRET || 'secret',
|
WEBHOOK_ELOPAGE_SECRET: process.env.WEBHOOK_ELOPAGE_SECRET || 'secret',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const eventProtocol = {
|
||||||
|
// global switch to enable writing of EventProtocol-Entries
|
||||||
|
EVENT_PROTOCOL_DISABLED: process.env.EVENT_PROTOCOL_DISABLED === 'true' || false,
|
||||||
|
}
|
||||||
|
|
||||||
// This is needed by graphql-directive-auth
|
// This is needed by graphql-directive-auth
|
||||||
process.env.APP_SECRET = server.JWT_SECRET
|
process.env.APP_SECRET = server.JWT_SECRET
|
||||||
|
|
||||||
@ -118,6 +125,7 @@ const CONFIG = {
|
|||||||
...email,
|
...email,
|
||||||
...loginServer,
|
...loginServer,
|
||||||
...webhook,
|
...webhook,
|
||||||
|
...eventProtocol,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default CONFIG
|
export default CONFIG
|
||||||
|
|||||||
429
backend/src/event/Event.ts
Normal file
429
backend/src/event/Event.ts
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
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 {
|
||||||
|
transactionId: number
|
||||||
|
amount: decimal
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventBasicTxX extends EventBasicTx {
|
||||||
|
xUserId: number
|
||||||
|
xCommunityId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventBasicCt extends EventBasicUserId {
|
||||||
|
contributionId: number
|
||||||
|
amount: decimal
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventBasicCtX extends EventBasicCt {
|
||||||
|
xUserId: number
|
||||||
|
xCommunityId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventBasicRedeem extends EventBasicUserId {
|
||||||
|
transactionId?: number
|
||||||
|
contributionId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventBasicCtMsg extends EventBasicCt {
|
||||||
|
messageId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EventVisitGradido extends EventBasic {}
|
||||||
|
export class EventRegister extends EventBasicUserId {}
|
||||||
|
export class EventRedeemRegister extends EventBasicRedeem {}
|
||||||
|
export class EventVerifyRedeem extends EventBasicRedeem {}
|
||||||
|
export class EventInactiveAccount extends EventBasicUserId {}
|
||||||
|
export class EventSendConfirmationEmail extends EventBasicUserId {}
|
||||||
|
export class EventSendAccountMultiRegistrationEmail extends EventBasicUserId {}
|
||||||
|
export class EventSendForgotPasswordEmail extends EventBasicUserId {}
|
||||||
|
export class EventSendTransactionSendEmail extends EventBasicTxX {}
|
||||||
|
export class EventSendTransactionReceiveEmail extends EventBasicTxX {}
|
||||||
|
export class EventSendTransactionLinkRedeemEmail extends EventBasicTxX {}
|
||||||
|
export class EventSendAddedContributionEmail extends EventBasicCt {}
|
||||||
|
export class EventSendContributionConfirmEmail extends EventBasicCt {}
|
||||||
|
export class EventConfirmationEmail extends EventBasicUserId {}
|
||||||
|
export class EventRegisterEmailKlicktipp extends EventBasicUserId {}
|
||||||
|
export class EventLogin extends EventBasicUserId {}
|
||||||
|
export class EventLogout extends EventBasicUserId {}
|
||||||
|
export class EventRedeemLogin extends EventBasicRedeem {}
|
||||||
|
export class EventActivateAccount extends EventBasicUserId {}
|
||||||
|
export class EventPasswordChange extends EventBasicUserId {}
|
||||||
|
export class EventTransactionSend extends EventBasicTxX {}
|
||||||
|
export class EventTransactionSendRedeem extends EventBasicTxX {}
|
||||||
|
export class EventTransactionRepeateRedeem extends EventBasicTxX {}
|
||||||
|
export class EventTransactionCreation extends EventBasicTx {}
|
||||||
|
export class EventTransactionReceive extends EventBasicTxX {}
|
||||||
|
export class EventTransactionReceiveRedeem extends EventBasicTxX {}
|
||||||
|
export class EventContributionCreate extends EventBasicCt {}
|
||||||
|
export class EventUserCreateContributionMessage extends EventBasicCtMsg {}
|
||||||
|
export class EventAdminCreateContributionMessage extends EventBasicCtMsg {}
|
||||||
|
export class EventContributionDelete extends EventBasicCt {}
|
||||||
|
export class EventContributionUpdate extends EventBasicCt {}
|
||||||
|
export class EventContributionConfirm extends EventBasicCtX {}
|
||||||
|
export class EventContributionDeny extends EventBasicCtX {}
|
||||||
|
export class EventContributionLinkDefine extends EventBasicCt {}
|
||||||
|
export class EventContributionLinkActivateRedeem extends EventBasicCt {}
|
||||||
|
|
||||||
|
export class 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 setEventVerifyRedeem(ev: EventVerifyRedeem): Event {
|
||||||
|
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
|
||||||
|
this.type = EventProtocolType.VERIFY_REDEEM
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventInactiveAccount(ev: EventInactiveAccount): Event {
|
||||||
|
this.setByBasicUser(ev.userId)
|
||||||
|
this.type = EventProtocolType.INACTIVE_ACCOUNT
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventSendConfirmationEmail(ev: EventSendConfirmationEmail): Event {
|
||||||
|
this.setByBasicUser(ev.userId)
|
||||||
|
this.type = EventProtocolType.SEND_CONFIRMATION_EMAIL
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventSendAccountMultiRegistrationEmail(
|
||||||
|
ev: EventSendAccountMultiRegistrationEmail,
|
||||||
|
): Event {
|
||||||
|
this.setByBasicUser(ev.userId)
|
||||||
|
this.type = EventProtocolType.SEND_ACCOUNT_MULTIREGISTRATION_EMAIL
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventSendForgotPasswordEmail(ev: EventSendForgotPasswordEmail): Event {
|
||||||
|
this.setByBasicUser(ev.userId)
|
||||||
|
this.type = EventProtocolType.SEND_FORGOT_PASSWORD_EMAIL
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventSendTransactionSendEmail(ev: EventSendTransactionSendEmail): Event {
|
||||||
|
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.SEND_TRANSACTION_SEND_EMAIL
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventSendTransactionReceiveEmail(ev: EventSendTransactionReceiveEmail): Event {
|
||||||
|
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.SEND_TRANSACTION_RECEIVE_EMAIL
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventSendTransactionLinkRedeemEmail(ev: EventSendTransactionLinkRedeemEmail): Event {
|
||||||
|
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.SEND_TRANSACTION_LINK_REDEEM_EMAIL
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventSendAddedContributionEmail(ev: EventSendAddedContributionEmail): Event {
|
||||||
|
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
|
||||||
|
this.type = EventProtocolType.SEND_ADDED_CONTRIBUTION_EMAIL
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventSendContributionConfirmEmail(ev: EventSendContributionConfirmEmail): Event {
|
||||||
|
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
|
||||||
|
this.type = EventProtocolType.SEND_CONTRIBUTION_CONFIRM_EMAIL
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
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 setEventLogout(ev: EventLogout): Event {
|
||||||
|
this.setByBasicUser(ev.userId)
|
||||||
|
this.type = EventProtocolType.LOGOUT
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventRedeemLogin(ev: EventRedeemLogin): Event {
|
||||||
|
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
|
||||||
|
this.type = EventProtocolType.REDEEM_LOGIN
|
||||||
|
|
||||||
|
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.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.TRANSACTION_SEND
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventTransactionSendRedeem(ev: EventTransactionSendRedeem): Event {
|
||||||
|
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.TRANSACTION_SEND_REDEEM
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventTransactionRepeateRedeem(ev: EventTransactionRepeateRedeem): Event {
|
||||||
|
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.TRANSACTION_REPEATE_REDEEM
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventTransactionCreation(ev: EventTransactionCreation): Event {
|
||||||
|
this.setByBasicTx(ev.userId, ev.transactionId, ev.amount)
|
||||||
|
this.type = EventProtocolType.TRANSACTION_CREATION
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventTransactionReceive(ev: EventTransactionReceive): Event {
|
||||||
|
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.TRANSACTION_RECEIVE
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventTransactionReceiveRedeem(ev: EventTransactionReceiveRedeem): Event {
|
||||||
|
this.setByBasicTxX(ev.userId, ev.transactionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
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 setEventUserCreateContributionMessage(ev: EventUserCreateContributionMessage): Event {
|
||||||
|
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
|
||||||
|
this.type = EventProtocolType.USER_CREATE_CONTRIBUTION_MESSAGE
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventAdminCreateContributionMessage(ev: EventAdminCreateContributionMessage): Event {
|
||||||
|
this.setByBasicCtMsg(ev.userId, ev.contributionId, ev.amount, ev.messageId)
|
||||||
|
this.type = EventProtocolType.ADMIN_CREATE_CONTRIBUTION_MESSAGE
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventContributionDelete(ev: EventContributionDelete): Event {
|
||||||
|
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
|
||||||
|
this.type = EventProtocolType.CONTRIBUTION_DELETE
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventContributionUpdate(ev: EventContributionUpdate): Event {
|
||||||
|
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
|
||||||
|
this.type = EventProtocolType.CONTRIBUTION_UPDATE
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventContributionConfirm(ev: EventContributionConfirm): Event {
|
||||||
|
this.setByBasicCtX(ev.userId, ev.contributionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.CONTRIBUTION_CONFIRM
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventContributionDeny(ev: EventContributionDeny): Event {
|
||||||
|
this.setByBasicCtX(ev.userId, ev.contributionId, ev.amount, ev.xUserId, ev.xCommunityId)
|
||||||
|
this.type = EventProtocolType.CONTRIBUTION_DENY
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEventContributionLinkDefine(ev: EventContributionLinkDefine): Event {
|
||||||
|
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
|
||||||
|
this.type = EventProtocolType.CONTRIBUTION_LINK_DEFINE
|
||||||
|
|
||||||
|
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, transactionId: number, amount: decimal): Event {
|
||||||
|
this.setByBasicUser(userId)
|
||||||
|
this.transactionId = transactionId
|
||||||
|
this.amount = amount
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setByBasicTxX(
|
||||||
|
userId: number,
|
||||||
|
transactionId: number,
|
||||||
|
amount: decimal,
|
||||||
|
xUserId: number,
|
||||||
|
xCommunityId: number,
|
||||||
|
): Event {
|
||||||
|
this.setByBasicTx(userId, transactionId, amount)
|
||||||
|
this.xUserId = xUserId
|
||||||
|
this.xCommunityId = xCommunityId
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setByBasicCt(userId: number, contributionId: number, amount: decimal): Event {
|
||||||
|
this.setByBasicUser(userId)
|
||||||
|
this.contributionId = contributionId
|
||||||
|
this.amount = amount
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setByBasicCtMsg(
|
||||||
|
userId: number,
|
||||||
|
contributionId: number,
|
||||||
|
amount: decimal,
|
||||||
|
messageId: number,
|
||||||
|
): Event {
|
||||||
|
this.setByBasicCt(userId, contributionId, amount)
|
||||||
|
this.messageId = messageId
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setByBasicCtX(
|
||||||
|
userId: number,
|
||||||
|
contributionId: number,
|
||||||
|
amount: decimal,
|
||||||
|
xUserId: number,
|
||||||
|
xCommunityId: number,
|
||||||
|
): Event {
|
||||||
|
this.setByBasicCt(userId, contributionId, amount)
|
||||||
|
this.xUserId = xUserId
|
||||||
|
this.xCommunityId = xCommunityId
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
setByBasicRedeem(userId: number, transactionId?: number, contributionId?: number): Event {
|
||||||
|
this.setByBasicUser(userId)
|
||||||
|
if (transactionId) this.transactionId = transactionId
|
||||||
|
if (contributionId) this.contributionId = contributionId
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
createdAt: Date
|
||||||
|
userId: number
|
||||||
|
xUserId?: number
|
||||||
|
xCommunityId?: number
|
||||||
|
transactionId?: number
|
||||||
|
contributionId?: number
|
||||||
|
amount?: decimal
|
||||||
|
messageId?: number
|
||||||
|
}
|
||||||
39
backend/src/event/EventProtocolEmitter.ts
Normal file
39
backend/src/event/EventProtocolEmitter.ts
Normal 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()
|
||||||
38
backend/src/event/EventProtocolType.ts
Normal file
38
backend/src/event/EventProtocolType.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
export enum EventProtocolType {
|
||||||
|
BASIC = 'BASIC',
|
||||||
|
VISIT_GRADIDO = 'VISIT_GRADIDO',
|
||||||
|
REGISTER = 'REGISTER',
|
||||||
|
REDEEM_REGISTER = 'REDEEM_REGISTER',
|
||||||
|
VERIFY_REDEEM = 'VERIFY_REDEEM',
|
||||||
|
INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT',
|
||||||
|
SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL',
|
||||||
|
SEND_ACCOUNT_MULTIREGISTRATION_EMAIL = 'SEND_ACCOUNT_MULTIREGISTRATION_EMAIL',
|
||||||
|
CONFIRM_EMAIL = 'CONFIRM_EMAIL',
|
||||||
|
REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP',
|
||||||
|
LOGIN = 'LOGIN',
|
||||||
|
LOGOUT = 'LOGOUT',
|
||||||
|
REDEEM_LOGIN = 'REDEEM_LOGIN',
|
||||||
|
ACTIVATE_ACCOUNT = 'ACTIVATE_ACCOUNT',
|
||||||
|
SEND_FORGOT_PASSWORD_EMAIL = 'SEND_FORGOT_PASSWORD_EMAIL',
|
||||||
|
PASSWORD_CHANGE = 'PASSWORD_CHANGE',
|
||||||
|
SEND_TRANSACTION_SEND_EMAIL = 'SEND_TRANSACTION_SEND_EMAIL',
|
||||||
|
SEND_TRANSACTION_RECEIVE_EMAIL = 'SEND_TRANSACTION_RECEIVE_EMAIL',
|
||||||
|
TRANSACTION_SEND = 'TRANSACTION_SEND',
|
||||||
|
TRANSACTION_SEND_REDEEM = 'TRANSACTION_SEND_REDEEM',
|
||||||
|
TRANSACTION_REPEATE_REDEEM = 'TRANSACTION_REPEATE_REDEEM',
|
||||||
|
TRANSACTION_CREATION = 'TRANSACTION_CREATION',
|
||||||
|
TRANSACTION_RECEIVE = 'TRANSACTION_RECEIVE',
|
||||||
|
TRANSACTION_RECEIVE_REDEEM = 'TRANSACTION_RECEIVE_REDEEM',
|
||||||
|
SEND_TRANSACTION_LINK_REDEEM_EMAIL = 'SEND_TRANSACTION_LINK_REDEEM_EMAIL',
|
||||||
|
SEND_ADDED_CONTRIBUTION_EMAIL = 'SEND_ADDED_CONTRIBUTION_EMAIL',
|
||||||
|
SEND_CONTRIBUTION_CONFIRM_EMAIL = 'SEND_CONTRIBUTION_CONFIRM_EMAIL',
|
||||||
|
CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE',
|
||||||
|
CONTRIBUTION_CONFIRM = 'CONTRIBUTION_CONFIRM',
|
||||||
|
CONTRIBUTION_DENY = 'CONTRIBUTION_DENY',
|
||||||
|
CONTRIBUTION_LINK_DEFINE = 'CONTRIBUTION_LINK_DEFINE',
|
||||||
|
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
|
||||||
|
CONTRIBUTION_DELETE = 'CONTRIBUTION_DELETE',
|
||||||
|
CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE',
|
||||||
|
USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE',
|
||||||
|
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
|
||||||
|
}
|
||||||
15
backend/src/graphql/arg/ContributionArgs.ts
Normal file
15
backend/src/graphql/arg/ContributionArgs.ts
Normal 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
|
||||||
|
}
|
||||||
11
backend/src/graphql/arg/ContributionMessageArgs.ts
Normal file
11
backend/src/graphql/arg/ContributionMessageArgs.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { ArgsType, Field, InputType } from 'type-graphql'
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
@ArgsType()
|
||||||
|
export default class ContributionMessageArgs {
|
||||||
|
@Field(() => Number)
|
||||||
|
contributionId: number
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
message: string
|
||||||
|
}
|
||||||
@ -31,7 +31,7 @@ const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
|
|||||||
|
|
||||||
// TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests
|
// TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests
|
||||||
// TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey
|
// TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey
|
||||||
const userRepository = await getCustomRepository(UserRepository)
|
const userRepository = getCustomRepository(UserRepository)
|
||||||
try {
|
try {
|
||||||
const user = await userRepository.findByPubkeyHex(context.pubKey)
|
const user = await userRepository.findByPubkeyHex(context.pubKey)
|
||||||
context.user = user
|
context.user = user
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { registerEnumType } from 'type-graphql'
|
import { registerEnumType } from 'type-graphql'
|
||||||
|
|
||||||
|
// lowercase values are not implemented yet
|
||||||
export enum ContributionCycleType {
|
export enum ContributionCycleType {
|
||||||
ONCE = 'once',
|
ONCE = 'ONCE',
|
||||||
HOUR = 'hour',
|
HOUR = 'hour',
|
||||||
TWO_HOURS = 'two_hours',
|
TWO_HOURS = 'two_hours',
|
||||||
FOUR_HOURS = 'four_hours',
|
FOUR_HOURS = 'four_hours',
|
||||||
EIGHT_HOURS = 'eight_hours',
|
EIGHT_HOURS = 'eight_hours',
|
||||||
HALF_DAY = 'half_day',
|
HALF_DAY = 'half_day',
|
||||||
DAY = 'day',
|
DAILY = 'DAILY',
|
||||||
TWO_DAYS = 'two_days',
|
TWO_DAYS = 'two_days',
|
||||||
THREE_DAYS = 'three_days',
|
THREE_DAYS = 'three_days',
|
||||||
FOUR_DAYS = 'four_days',
|
FOUR_DAYS = 'four_days',
|
||||||
|
|||||||
14
backend/src/graphql/enum/ContributionStatus.ts
Normal file
14
backend/src/graphql/enum/ContributionStatus.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { registerEnumType } from 'type-graphql'
|
||||||
|
|
||||||
|
export enum ContributionStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
DELETED = 'DELETED',
|
||||||
|
IN_PROGRESS = 'IN_PROGRESS',
|
||||||
|
DENIED = 'DENIED',
|
||||||
|
CONFIRMED = 'CONFIRMED',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(ContributionStatus, {
|
||||||
|
name: 'ContributionStatus',
|
||||||
|
description: 'Name of the Type of the Contribution Status',
|
||||||
|
})
|
||||||
12
backend/src/graphql/enum/ContributionType.ts
Normal file
12
backend/src/graphql/enum/ContributionType.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { registerEnumType } from 'type-graphql'
|
||||||
|
|
||||||
|
export enum ContributionType {
|
||||||
|
ADMIN = 'ADMIN',
|
||||||
|
USER = 'USER',
|
||||||
|
LINK = 'LINK',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(ContributionType, {
|
||||||
|
name: 'ContributionType',
|
||||||
|
description: 'Name of the Type of the Contribution',
|
||||||
|
})
|
||||||
11
backend/src/graphql/enum/MessageType.ts
Normal file
11
backend/src/graphql/enum/MessageType.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { registerEnumType } from 'type-graphql'
|
||||||
|
|
||||||
|
export enum ContributionMessageType {
|
||||||
|
HISTORY = 'HISTORY',
|
||||||
|
DIALOG = 'DIALOG',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(ContributionMessageType, {
|
||||||
|
name: 'ContributionMessageType',
|
||||||
|
description: 'Name of the Type of the ContributionMessage',
|
||||||
|
})
|
||||||
11
backend/src/graphql/enum/UserContactType.ts
Normal file
11
backend/src/graphql/enum/UserContactType.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { registerEnumType } from 'type-graphql'
|
||||||
|
|
||||||
|
export enum UserContactType {
|
||||||
|
USER_CONTACT_EMAIL = 'EMAIL',
|
||||||
|
USER_CONTACT_PHONE = 'PHONE',
|
||||||
|
}
|
||||||
|
|
||||||
|
registerEnumType(UserContactType, {
|
||||||
|
name: 'UserContactType', // this one is mandatory
|
||||||
|
description: 'Type of the user contact', // this one is optional
|
||||||
|
})
|
||||||
25
backend/src/graphql/model/AdminUser.ts
Normal file
25
backend/src/graphql/model/AdminUser.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { User } from '@entity/User'
|
||||||
|
import { Field, Int, ObjectType } from 'type-graphql'
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class AdminUser {
|
||||||
|
constructor(user: User) {
|
||||||
|
this.firstName = user.firstName
|
||||||
|
this.lastName = user.lastName
|
||||||
|
}
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
firstName: string
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
lastName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class SearchAdminUsersResult {
|
||||||
|
@Field(() => Int)
|
||||||
|
userCount: number
|
||||||
|
|
||||||
|
@Field(() => [AdminUser])
|
||||||
|
userList: AdminUser[]
|
||||||
|
}
|
||||||
26
backend/src/graphql/model/CommunityStatistics.ts
Normal file
26
backend/src/graphql/model/CommunityStatistics.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { ObjectType, Field } from 'type-graphql'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class CommunityStatistics {
|
||||||
|
@Field(() => Number)
|
||||||
|
totalUsers: number
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
activeUsers: number
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
deletedUsers: number
|
||||||
|
|
||||||
|
@Field(() => Decimal)
|
||||||
|
totalGradidoCreated: Decimal
|
||||||
|
|
||||||
|
@Field(() => Decimal)
|
||||||
|
totalGradidoDecayed: Decimal
|
||||||
|
|
||||||
|
@Field(() => Decimal)
|
||||||
|
totalGradidoAvailable: Decimal
|
||||||
|
|
||||||
|
@Field(() => Decimal)
|
||||||
|
totalGradidoUnbookedDecayed: Decimal
|
||||||
|
}
|
||||||
72
backend/src/graphql/model/Contribution.ts
Normal file
72
backend/src/graphql/model/Contribution.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { ObjectType, Field, Int } from 'type-graphql'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { Contribution as dbContribution } from '@entity/Contribution'
|
||||||
|
import { User } from '@entity/User'
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class Contribution {
|
||||||
|
constructor(contribution: dbContribution, user?: User | null) {
|
||||||
|
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
|
||||||
|
this.contributionDate = contribution.contributionDate
|
||||||
|
this.state = contribution.contributionStatus
|
||||||
|
this.messagesCount = contribution.messages ? contribution.messages.length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
contributionDate: Date
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
messagesCount: number
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ContributionListResult {
|
||||||
|
constructor(count: number, list: Contribution[]) {
|
||||||
|
this.contributionCount = count
|
||||||
|
this.contributionList = list
|
||||||
|
}
|
||||||
|
|
||||||
|
@Field(() => Int)
|
||||||
|
contributionCount: number
|
||||||
|
|
||||||
|
@Field(() => [Contribution])
|
||||||
|
contributionList: Contribution[]
|
||||||
|
}
|
||||||
53
backend/src/graphql/model/ContributionMessage.ts
Normal file
53
backend/src/graphql/model/ContributionMessage.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Field, ObjectType } from 'type-graphql'
|
||||||
|
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
|
||||||
|
import { User } from '@entity/User'
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class ContributionMessage {
|
||||||
|
constructor(contributionMessage: DbContributionMessage, user: User) {
|
||||||
|
this.id = contributionMessage.id
|
||||||
|
this.message = contributionMessage.message
|
||||||
|
this.createdAt = contributionMessage.createdAt
|
||||||
|
this.updatedAt = contributionMessage.updatedAt
|
||||||
|
this.type = contributionMessage.type
|
||||||
|
this.userFirstName = user.firstName
|
||||||
|
this.userLastName = user.lastName
|
||||||
|
this.userId = user.id
|
||||||
|
this.isModerator = contributionMessage.isModerator
|
||||||
|
}
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
id: number
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
message: string
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
createdAt: Date
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
updatedAt?: Date | null
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
type: string
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
userFirstName: string | null
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
userLastName: string | null
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
userId: number | null
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
isModerator: boolean
|
||||||
|
}
|
||||||
|
@ObjectType()
|
||||||
|
export class ContributionMessageListResult {
|
||||||
|
@Field(() => Number)
|
||||||
|
count: number
|
||||||
|
|
||||||
|
@Field(() => [ContributionMessage])
|
||||||
|
messages: ContributionMessage[]
|
||||||
|
}
|
||||||
@ -1,8 +1,25 @@
|
|||||||
import { ObjectType, Field, Int } from 'type-graphql'
|
import { ObjectType, Field, Int } from 'type-graphql'
|
||||||
import Decimal from 'decimal.js-light'
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { Contribution } from '@entity/Contribution'
|
||||||
|
import { User } from '@entity/User'
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class UnconfirmedContribution {
|
export class UnconfirmedContribution {
|
||||||
|
constructor(contribution: Contribution, user: User | undefined, 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.emailContact.email : ''
|
||||||
|
this.moderator = contribution.moderatorId
|
||||||
|
this.creation = creations
|
||||||
|
this.state = contribution.contributionStatus
|
||||||
|
this.messageCount = contribution.messages ? contribution.messages.length : 0
|
||||||
|
}
|
||||||
|
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
firstName: string
|
firstName: string
|
||||||
|
|
||||||
@ -27,9 +44,15 @@ export class UnconfirmedContribution {
|
|||||||
@Field(() => Decimal)
|
@Field(() => Decimal)
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
|
|
||||||
@Field(() => Number)
|
@Field(() => Number, { nullable: true })
|
||||||
moderator: number
|
moderator: number | null
|
||||||
|
|
||||||
@Field(() => [Decimal])
|
@Field(() => [Decimal])
|
||||||
creation: Decimal[]
|
creation: Decimal[]
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
state: string
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
messageCount: number
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,32 @@
|
|||||||
import { ObjectType, Field } from 'type-graphql'
|
import { ObjectType, Field } from 'type-graphql'
|
||||||
import { KlickTipp } from './KlickTipp'
|
import { KlickTipp } from './KlickTipp'
|
||||||
import { User as dbUser } from '@entity/User'
|
import { User as dbUser } from '@entity/User'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { FULL_CREATION_AVAILABLE } from '../resolver/const/const'
|
||||||
|
import { UserContact } from './UserContact'
|
||||||
|
|
||||||
@ObjectType()
|
@ObjectType()
|
||||||
export class User {
|
export class User {
|
||||||
constructor(user: dbUser) {
|
constructor(user: dbUser, creation: Decimal[] = FULL_CREATION_AVAILABLE) {
|
||||||
this.id = user.id
|
this.id = user.id
|
||||||
this.email = user.email
|
this.gradidoID = user.gradidoID
|
||||||
|
this.alias = user.alias
|
||||||
|
this.emailId = user.emailId
|
||||||
|
if (user.emailContact) {
|
||||||
|
this.email = user.emailContact.email
|
||||||
|
this.emailContact = new UserContact(user.emailContact)
|
||||||
|
this.emailChecked = user.emailContact.emailChecked
|
||||||
|
}
|
||||||
this.firstName = user.firstName
|
this.firstName = user.firstName
|
||||||
this.lastName = user.lastName
|
this.lastName = user.lastName
|
||||||
this.deletedAt = user.deletedAt
|
this.deletedAt = user.deletedAt
|
||||||
this.createdAt = user.createdAt
|
this.createdAt = user.createdAt
|
||||||
this.emailChecked = user.emailChecked
|
|
||||||
this.language = user.language
|
this.language = user.language
|
||||||
this.publisherId = user.publisherId
|
this.publisherId = user.publisherId
|
||||||
this.isAdmin = user.isAdmin
|
this.isAdmin = user.isAdmin
|
||||||
this.klickTipp = null
|
this.klickTipp = null
|
||||||
this.hasElopage = null
|
this.hasElopage = null
|
||||||
|
this.creation = creation
|
||||||
}
|
}
|
||||||
|
|
||||||
@Field(() => Number)
|
@Field(() => Number)
|
||||||
@ -25,10 +35,22 @@ export class User {
|
|||||||
// `public_key` binary(32) DEFAULT NULL,
|
// `public_key` binary(32) DEFAULT NULL,
|
||||||
// `privkey` binary(80) DEFAULT NULL,
|
// `privkey` binary(80) DEFAULT NULL,
|
||||||
|
|
||||||
// TODO privacy issue here
|
|
||||||
@Field(() => String)
|
@Field(() => String)
|
||||||
|
gradidoID: string
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
alias?: string
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
emailId: number | null
|
||||||
|
|
||||||
|
// TODO privacy issue here
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
email: string
|
email: string
|
||||||
|
|
||||||
|
@Field(() => UserContact)
|
||||||
|
emailContact: UserContact
|
||||||
|
|
||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
firstName: string | null
|
firstName: string | null
|
||||||
|
|
||||||
@ -64,4 +86,7 @@ export class User {
|
|||||||
|
|
||||||
@Field(() => Boolean, { nullable: true })
|
@Field(() => Boolean, { nullable: true })
|
||||||
hasElopage: boolean | null
|
hasElopage: boolean | null
|
||||||
|
|
||||||
|
@Field(() => [Decimal])
|
||||||
|
creation: Decimal[]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,11 @@ import { User } from '@entity/User'
|
|||||||
export class UserAdmin {
|
export class UserAdmin {
|
||||||
constructor(user: User, creation: Decimal[], hasElopage: boolean, emailConfirmationSend: string) {
|
constructor(user: User, creation: Decimal[], hasElopage: boolean, emailConfirmationSend: string) {
|
||||||
this.userId = user.id
|
this.userId = user.id
|
||||||
this.email = user.email
|
this.email = user.emailContact.email
|
||||||
this.firstName = user.firstName
|
this.firstName = user.firstName
|
||||||
this.lastName = user.lastName
|
this.lastName = user.lastName
|
||||||
this.creation = creation
|
this.creation = creation
|
||||||
this.emailChecked = user.emailChecked
|
this.emailChecked = user.emailContact.emailChecked
|
||||||
this.hasElopage = hasElopage
|
this.hasElopage = hasElopage
|
||||||
this.deletedAt = user.deletedAt
|
this.deletedAt = user.deletedAt
|
||||||
this.emailConfirmationSend = emailConfirmationSend
|
this.emailConfirmationSend = emailConfirmationSend
|
||||||
|
|||||||
56
backend/src/graphql/model/UserContact.ts
Normal file
56
backend/src/graphql/model/UserContact.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { ObjectType, Field } from 'type-graphql'
|
||||||
|
import { UserContact as dbUserContact } from '@entity/UserContact'
|
||||||
|
|
||||||
|
@ObjectType()
|
||||||
|
export class UserContact {
|
||||||
|
constructor(userContact: dbUserContact) {
|
||||||
|
this.id = userContact.id
|
||||||
|
this.type = userContact.type
|
||||||
|
this.userId = userContact.userId
|
||||||
|
this.email = userContact.email
|
||||||
|
// this.emailVerificationCode = userContact.emailVerificationCode
|
||||||
|
this.emailOptInTypeId = userContact.emailOptInTypeId
|
||||||
|
this.emailResendCount = userContact.emailResendCount
|
||||||
|
this.emailChecked = userContact.emailChecked
|
||||||
|
this.phone = userContact.phone
|
||||||
|
this.createdAt = userContact.createdAt
|
||||||
|
this.updatedAt = userContact.updatedAt
|
||||||
|
this.deletedAt = userContact.deletedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
id: number
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
type: string
|
||||||
|
|
||||||
|
@Field(() => Number)
|
||||||
|
userId: number
|
||||||
|
|
||||||
|
@Field(() => String)
|
||||||
|
email: string
|
||||||
|
|
||||||
|
// @Field(() => BigInt, { nullable: true })
|
||||||
|
// emailVerificationCode: BigInt | null
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
emailOptInTypeId: number | null
|
||||||
|
|
||||||
|
@Field(() => Number, { nullable: true })
|
||||||
|
emailResendCount: number | null
|
||||||
|
|
||||||
|
@Field(() => Boolean)
|
||||||
|
emailChecked: boolean
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
phone: string | null
|
||||||
|
|
||||||
|
@Field(() => Date)
|
||||||
|
createdAt: Date
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
updatedAt: Date | null
|
||||||
|
|
||||||
|
@Field(() => Date, { nullable: true })
|
||||||
|
deletedAt: Date | null
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
|
|||||||
import { stephenHawking } from '@/seeds/users/stephen-hawking'
|
import { stephenHawking } from '@/seeds/users/stephen-hawking'
|
||||||
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
|
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
|
||||||
import {
|
import {
|
||||||
|
login,
|
||||||
setUserRole,
|
setUserRole,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
unDeleteUser,
|
unDeleteUser,
|
||||||
@ -27,7 +28,6 @@ import {
|
|||||||
} from '@/seeds/graphql/mutations'
|
} from '@/seeds/graphql/mutations'
|
||||||
import {
|
import {
|
||||||
listUnconfirmedContributions,
|
listUnconfirmedContributions,
|
||||||
login,
|
|
||||||
searchUsers,
|
searchUsers,
|
||||||
listTransactionLinksAdmin,
|
listTransactionLinksAdmin,
|
||||||
listContributionLinks,
|
listContributionLinks,
|
||||||
@ -40,6 +40,7 @@ import Decimal from 'decimal.js-light'
|
|||||||
import { Contribution } from '@entity/Contribution'
|
import { Contribution } from '@entity/Contribution'
|
||||||
import { Transaction as DbTransaction } from '@entity/Transaction'
|
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||||
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||||
|
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
|
||||||
|
|
||||||
// mock account activation email to avoid console spam
|
// mock account activation email to avoid console spam
|
||||||
jest.mock('@/mailer/sendAccountActivationEmail', () => {
|
jest.mock('@/mailer/sendAccountActivationEmail', () => {
|
||||||
@ -49,6 +50,14 @@ jest.mock('@/mailer/sendAccountActivationEmail', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// mock account activation email to avoid console spam
|
||||||
|
jest.mock('@/mailer/sendContributionConfirmedEmail', () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
sendContributionConfirmedEmail: jest.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let mutate: any, query: any, con: any
|
let mutate: any, query: any, con: any
|
||||||
let testEnv: any
|
let testEnv: any
|
||||||
|
|
||||||
@ -87,8 +96,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('without admin rights', () => {
|
describe('without admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await userFactory(testEnv, bibiBloxberg)
|
user = await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -112,8 +121,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('with admin rights', () => {
|
describe('with admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
admin = await userFactory(testEnv, peterLustig)
|
admin = await userFactory(testEnv, peterLustig)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -240,8 +249,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('without admin rights', () => {
|
describe('without admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await userFactory(testEnv, bibiBloxberg)
|
user = await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -265,8 +274,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('with admin rights', () => {
|
describe('with admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
admin = await userFactory(testEnv, peterLustig)
|
admin = await userFactory(testEnv, peterLustig)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -348,8 +357,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('without admin rights', () => {
|
describe('without admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await userFactory(testEnv, bibiBloxberg)
|
user = await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -373,8 +382,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('with admin rights', () => {
|
describe('with admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
admin = await userFactory(testEnv, peterLustig)
|
admin = await userFactory(testEnv, peterLustig)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -460,8 +469,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('without admin rights', () => {
|
describe('without admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await userFactory(testEnv, bibiBloxberg)
|
user = await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -505,8 +514,8 @@ describe('AdminResolver', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
admin = await userFactory(testEnv, peterLustig)
|
admin = await userFactory(testEnv, peterLustig)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -757,8 +766,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('without admin rights', () => {
|
describe('without admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await userFactory(testEnv, bibiBloxberg)
|
user = await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -866,8 +875,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('with admin rights', () => {
|
describe('with admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
admin = await userFactory(testEnv, peterLustig)
|
admin = await userFactory(testEnv, peterLustig)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1117,7 +1126,9 @@ describe('AdminResolver', () => {
|
|||||||
}),
|
}),
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
errors: [new GraphQLError('Could not find user with email: bob@baumeister.de')],
|
errors: [
|
||||||
|
new GraphQLError('Could not find UserContact with email: bob@baumeister.de'),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -1191,7 +1202,8 @@ describe('AdminResolver', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('creation update is not valid', () => {
|
describe('creation update is not valid', () => {
|
||||||
it('throws an error', async () => {
|
// as this test has not clearly defined that date, it is a false positive
|
||||||
|
it.skip('throws an error', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
mutate({
|
mutate({
|
||||||
mutation: adminUpdateContribution,
|
mutation: adminUpdateContribution,
|
||||||
@ -1216,7 +1228,8 @@ describe('AdminResolver', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('creation update is successful changing month', () => {
|
describe('creation update is successful changing month', () => {
|
||||||
it('returns update creation object', async () => {
|
// skipped as changing the month is currently disable
|
||||||
|
it.skip('returns update creation object', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
mutate({
|
mutate({
|
||||||
mutation: adminUpdateContribution,
|
mutation: adminUpdateContribution,
|
||||||
@ -1244,7 +1257,8 @@ describe('AdminResolver', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('creation update is successful without changing month', () => {
|
describe('creation update is successful without changing month', () => {
|
||||||
it('returns update creation object', async () => {
|
// actually this mutation IS changing the month
|
||||||
|
it.skip('returns update creation object', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
mutate({
|
mutate({
|
||||||
mutation: adminUpdateContribution,
|
mutation: adminUpdateContribution,
|
||||||
@ -1288,10 +1302,10 @@ describe('AdminResolver', () => {
|
|||||||
lastName: 'Lustig',
|
lastName: 'Lustig',
|
||||||
email: 'peter@lustig.de',
|
email: 'peter@lustig.de',
|
||||||
date: expect.any(String),
|
date: expect.any(String),
|
||||||
memo: 'Das war leider zu Viel!',
|
memo: 'Herzlich Willkommen bei Gradido!',
|
||||||
amount: '200',
|
amount: '400',
|
||||||
moderator: admin.id,
|
moderator: admin.id,
|
||||||
creation: ['1000', '1000', '300'],
|
creation: ['1000', '600', '500'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: expect.any(Number),
|
id: expect.any(Number),
|
||||||
@ -1302,7 +1316,7 @@ describe('AdminResolver', () => {
|
|||||||
memo: 'Grundeinkommen',
|
memo: 'Grundeinkommen',
|
||||||
amount: '500',
|
amount: '500',
|
||||||
moderator: admin.id,
|
moderator: admin.id,
|
||||||
creation: ['1000', '1000', '300'],
|
creation: ['1000', '600', '500'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: expect.any(Number),
|
id: expect.any(Number),
|
||||||
@ -1450,6 +1464,20 @@ describe('AdminResolver', () => {
|
|||||||
expect(transaction[0].linkedUserId).toEqual(null)
|
expect(transaction[0].linkedUserId).toEqual(null)
|
||||||
expect(transaction[0].typeId).toEqual(1)
|
expect(transaction[0].typeId).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('calls sendContributionConfirmedEmail', async () => {
|
||||||
|
expect(sendContributionConfirmedEmail).toBeCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
contributionMemo: 'Herzlich Willkommen bei Gradido liebe Bibi!',
|
||||||
|
overviewURL: 'http://localhost/overview',
|
||||||
|
recipientEmail: 'bibi@bloxberg.de',
|
||||||
|
recipientFirstName: 'Bibi',
|
||||||
|
recipientLastName: 'Bloxberg',
|
||||||
|
senderFirstName: 'Peter',
|
||||||
|
senderLastName: 'Lustig',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('confirm two creations one after the other quickly', () => {
|
describe('confirm two creations one after the other quickly', () => {
|
||||||
@ -1493,6 +1521,7 @@ describe('AdminResolver', () => {
|
|||||||
)
|
)
|
||||||
await expect(r2).resolves.toEqual(
|
await expect(r2).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
// data: { confirmContribution: true },
|
||||||
errors: [new GraphQLError('Creation was not successful.')],
|
errors: [new GraphQLError('Creation was not successful.')],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@ -1530,8 +1559,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('without admin rights', () => {
|
describe('without admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await userFactory(testEnv, bibiBloxberg)
|
user = await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1576,8 +1605,8 @@ describe('AdminResolver', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// admin: only now log in
|
// admin: only now log in
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1766,13 +1795,14 @@ describe('AdminResolver', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Contribution Links', () => {
|
describe('Contribution Links', () => {
|
||||||
|
const now = new Date()
|
||||||
const variables = {
|
const variables = {
|
||||||
amount: new Decimal(200),
|
amount: new Decimal(200),
|
||||||
name: 'Dokumenta 2022',
|
name: 'Dokumenta 2022',
|
||||||
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
|
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
|
||||||
cycle: 'once',
|
cycle: 'once',
|
||||||
validFrom: new Date(2022, 5, 18).toISOString(),
|
validFrom: new Date(2022, 5, 18).toISOString(),
|
||||||
validTo: new Date(2022, 7, 14).toISOString(),
|
validTo: new Date(now.getFullYear() + 1, 7, 14).toISOString(),
|
||||||
maxAmountPerMonth: new Decimal(200),
|
maxAmountPerMonth: new Decimal(200),
|
||||||
maxPerCycle: 1,
|
maxPerCycle: 1,
|
||||||
}
|
}
|
||||||
@ -1836,8 +1866,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('without admin rights', () => {
|
describe('without admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await userFactory(testEnv, bibiBloxberg)
|
user = await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1857,11 +1887,17 @@ describe('AdminResolver', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO: Set this test in new location to have datas
|
||||||
describe('listContributionLinks', () => {
|
describe('listContributionLinks', () => {
|
||||||
it('returns an error', async () => {
|
it('returns an empty object', async () => {
|
||||||
await expect(query({ query: listContributionLinks })).resolves.toEqual(
|
await expect(query({ query: listContributionLinks })).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
errors: [new GraphQLError('401 Unauthorized')],
|
data: {
|
||||||
|
listContributionLinks: {
|
||||||
|
count: 0,
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -1904,8 +1940,8 @@ describe('AdminResolver', () => {
|
|||||||
describe('with admin rights', () => {
|
describe('with admin rights', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await userFactory(testEnv, peterLustig)
|
user = await userFactory(testEnv, peterLustig)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -1948,7 +1984,7 @@ describe('AdminResolver', () => {
|
|||||||
name: 'Dokumenta 2022',
|
name: 'Dokumenta 2022',
|
||||||
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
|
memo: 'Danke für deine Teilnahme an der Dokumenta 2022',
|
||||||
validFrom: new Date('2022-06-18T00:00:00.000Z'),
|
validFrom: new Date('2022-06-18T00:00:00.000Z'),
|
||||||
validTo: new Date('2022-08-14T00:00:00.000Z'),
|
validTo: expect.any(Date),
|
||||||
cycle: 'once',
|
cycle: 'once',
|
||||||
maxPerCycle: 1,
|
maxPerCycle: 1,
|
||||||
totalMaxCountOfContribution: null,
|
totalMaxCountOfContribution: null,
|
||||||
@ -1958,8 +1994,8 @@ describe('AdminResolver', () => {
|
|||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
|
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
|
||||||
linkEnabled: true,
|
linkEnabled: true,
|
||||||
// amount: '200',
|
amount: expect.decimalEqual(200),
|
||||||
// maxAmountPerMonth: '200',
|
maxAmountPerMonth: expect.decimalEqual(200),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -2248,7 +2284,7 @@ describe('AdminResolver', () => {
|
|||||||
id: linkId,
|
id: linkId,
|
||||||
name: 'Dokumenta 2023',
|
name: 'Dokumenta 2023',
|
||||||
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
|
memo: 'Danke für deine Teilnahme an der Dokumenta 2023',
|
||||||
// amount: '400',
|
amount: expect.decimalEqual(400),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,8 +4,6 @@ import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx, Int } from 'type
|
|||||||
import {
|
import {
|
||||||
getCustomRepository,
|
getCustomRepository,
|
||||||
IsNull,
|
IsNull,
|
||||||
Not,
|
|
||||||
ObjectLiteral,
|
|
||||||
getConnection,
|
getConnection,
|
||||||
In,
|
In,
|
||||||
MoreThan,
|
MoreThan,
|
||||||
@ -17,6 +15,7 @@ import { AdminCreateContributions } from '@model/AdminCreateContributions'
|
|||||||
import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
|
import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
|
||||||
import { ContributionLink } from '@model/ContributionLink'
|
import { ContributionLink } from '@model/ContributionLink'
|
||||||
import { ContributionLinkList } from '@model/ContributionLinkList'
|
import { ContributionLinkList } from '@model/ContributionLinkList'
|
||||||
|
import { Contribution } from '@model/Contribution'
|
||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
import { UserRepository } from '@repository/User'
|
import { UserRepository } from '@repository/User'
|
||||||
import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs'
|
import AdminCreateContributionArgs from '@arg/AdminCreateContributionArgs'
|
||||||
@ -25,36 +24,50 @@ import SearchUsersArgs from '@arg/SearchUsersArgs'
|
|||||||
import ContributionLinkArgs from '@arg/ContributionLinkArgs'
|
import ContributionLinkArgs from '@arg/ContributionLinkArgs'
|
||||||
import { Transaction as DbTransaction } from '@entity/Transaction'
|
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||||
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||||
import { Transaction } from '@model/Transaction'
|
|
||||||
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
|
import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
|
||||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||||
import { TransactionRepository } from '@repository/Transaction'
|
|
||||||
import { calculateDecay } from '@/util/decay'
|
import { calculateDecay } from '@/util/decay'
|
||||||
import { Contribution } from '@entity/Contribution'
|
import { Contribution as DbContribution } from '@entity/Contribution'
|
||||||
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
||||||
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
|
|
||||||
import { User as dbUser } from '@entity/User'
|
import { User as dbUser } from '@entity/User'
|
||||||
import { User } from '@model/User'
|
import { User } from '@model/User'
|
||||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||||
|
import { ContributionType } from '@enum/ContributionType'
|
||||||
|
import { ContributionStatus } from '@enum/ContributionStatus'
|
||||||
import Decimal from 'decimal.js-light'
|
import Decimal from 'decimal.js-light'
|
||||||
import { Decay } from '@model/Decay'
|
import { Decay } from '@model/Decay'
|
||||||
import Paginated from '@arg/Paginated'
|
import Paginated from '@arg/Paginated'
|
||||||
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
|
import TransactionLinkFilters from '@arg/TransactionLinkFilters'
|
||||||
import { Order } from '@enum/Order'
|
import { Order } from '@enum/Order'
|
||||||
import { communityUser } from '@/util/communityUser'
|
import { findUserByEmail, activationLink, printTimeDuration } from './UserResolver'
|
||||||
import { checkOptInCode, activationLink, printTimeDuration } from './UserResolver'
|
|
||||||
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
|
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
|
||||||
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
|
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
|
||||||
import CONFIG from '@/config'
|
import CONFIG from '@/config'
|
||||||
|
import {
|
||||||
|
getUserCreation,
|
||||||
|
getUserCreations,
|
||||||
|
validateContribution,
|
||||||
|
isStartEndDateValid,
|
||||||
|
updateCreations,
|
||||||
|
} from './util/creations'
|
||||||
|
import {
|
||||||
|
CONTRIBUTIONLINK_NAME_MAX_CHARS,
|
||||||
|
CONTRIBUTIONLINK_NAME_MIN_CHARS,
|
||||||
|
FULL_CREATION_AVAILABLE,
|
||||||
|
MEMO_MAX_CHARS,
|
||||||
|
MEMO_MIN_CHARS,
|
||||||
|
} from './const/const'
|
||||||
|
import { UserContact } from '@entity/UserContact'
|
||||||
|
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
|
||||||
|
import ContributionMessageArgs from '@arg/ContributionMessageArgs'
|
||||||
|
import { ContributionMessageType } from '@enum/MessageType'
|
||||||
|
import { ContributionMessage } from '@model/ContributionMessage'
|
||||||
|
import { sendContributionConfirmedEmail } from '@/mailer/sendContributionConfirmedEmail'
|
||||||
|
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail'
|
||||||
|
import { ContributionListResult } from '../model/Contribution'
|
||||||
|
|
||||||
// const EMAIL_OPT_IN_REGISTER = 1
|
// const EMAIL_OPT_IN_REGISTER = 1
|
||||||
// const EMAIL_OPT_UNKNOWN = 3 // elopage?
|
// 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]
|
|
||||||
const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100
|
|
||||||
const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5
|
|
||||||
const CONTRIBUTIONLINK_MEMO_MAX_CHARS = 255
|
|
||||||
const CONTRIBUTIONLINK_MEMO_MIN_CHARS = 5
|
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
export class AdminResolver {
|
export class AdminResolver {
|
||||||
@ -65,24 +78,12 @@ export class AdminResolver {
|
|||||||
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
|
{ searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs,
|
||||||
): Promise<SearchUsersResult> {
|
): Promise<SearchUsersResult> {
|
||||||
const userRepository = getCustomRepository(UserRepository)
|
const userRepository = getCustomRepository(UserRepository)
|
||||||
|
|
||||||
const filterCriteria: ObjectLiteral[] = []
|
|
||||||
if (filters) {
|
|
||||||
if (filters.byActivated !== null) {
|
|
||||||
filterCriteria.push({ emailChecked: filters.byActivated })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.byDeleted !== null) {
|
|
||||||
filterCriteria.push({ deletedAt: filters.byDeleted ? Not(IsNull()) : IsNull() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const userFields = [
|
const userFields = [
|
||||||
'id',
|
'id',
|
||||||
'firstName',
|
'firstName',
|
||||||
'lastName',
|
'lastName',
|
||||||
'email',
|
'emailId',
|
||||||
'emailChecked',
|
'emailContact',
|
||||||
'deletedAt',
|
'deletedAt',
|
||||||
'isAdmin',
|
'isAdmin',
|
||||||
]
|
]
|
||||||
@ -91,7 +92,7 @@ export class AdminResolver {
|
|||||||
return 'user.' + fieldName
|
return 'user.' + fieldName
|
||||||
}),
|
}),
|
||||||
searchText,
|
searchText,
|
||||||
filterCriteria,
|
filters,
|
||||||
currentPage,
|
currentPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
)
|
)
|
||||||
@ -108,32 +109,18 @@ export class AdminResolver {
|
|||||||
const adminUsers = await Promise.all(
|
const adminUsers = await Promise.all(
|
||||||
users.map(async (user) => {
|
users.map(async (user) => {
|
||||||
let emailConfirmationSend = ''
|
let emailConfirmationSend = ''
|
||||||
if (!user.emailChecked) {
|
if (!user.emailContact.emailChecked) {
|
||||||
const emailOptIn = await LoginEmailOptIn.findOne(
|
if (user.emailContact.updatedAt) {
|
||||||
{
|
emailConfirmationSend = user.emailContact.updatedAt.toISOString()
|
||||||
userId: user.id,
|
} else {
|
||||||
},
|
emailConfirmationSend = user.emailContact.createdAt.toISOString()
|
||||||
{
|
|
||||||
order: {
|
|
||||||
updatedAt: 'DESC',
|
|
||||||
createdAt: 'DESC',
|
|
||||||
},
|
|
||||||
select: ['updatedAt', 'createdAt'],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (emailOptIn) {
|
|
||||||
if (emailOptIn.updatedAt) {
|
|
||||||
emailConfirmationSend = emailOptIn.updatedAt.toISOString()
|
|
||||||
} else {
|
|
||||||
emailConfirmationSend = emailOptIn.createdAt.toISOString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const userCreations = creations.find((c) => c.id === user.id)
|
const userCreations = creations.find((c) => c.id === user.id)
|
||||||
const adminUser = new UserAdmin(
|
const adminUser = new UserAdmin(
|
||||||
user,
|
user,
|
||||||
userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE,
|
userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE,
|
||||||
await hasElopageBuys(user.email),
|
await hasElopageBuys(user.emailContact.email),
|
||||||
emailConfirmationSend,
|
emailConfirmationSend,
|
||||||
)
|
)
|
||||||
return adminUser
|
return adminUser
|
||||||
@ -229,34 +216,50 @@ export class AdminResolver {
|
|||||||
@Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs,
|
@Args() { email, amount, memo, creationDate }: AdminCreateContributionArgs,
|
||||||
@Ctx() context: Context,
|
@Ctx() context: Context,
|
||||||
): Promise<Decimal[]> {
|
): Promise<Decimal[]> {
|
||||||
const user = await dbUser.findOne({ email }, { withDeleted: true })
|
logger.info(
|
||||||
if (!user) {
|
`adminCreateContribution(email=${email}, amount=${amount}, memo=${memo}, creationDate=${creationDate})`,
|
||||||
|
)
|
||||||
|
const emailContact = await UserContact.findOne({
|
||||||
|
where: { email },
|
||||||
|
withDeleted: true,
|
||||||
|
relations: ['user'],
|
||||||
|
})
|
||||||
|
if (!emailContact) {
|
||||||
|
logger.error(`Could not find user with email: ${email}`)
|
||||||
throw new Error(`Could not find user with email: ${email}`)
|
throw new Error(`Could not find user with email: ${email}`)
|
||||||
}
|
}
|
||||||
if (user.deletedAt) {
|
if (emailContact.deletedAt) {
|
||||||
|
logger.error('This emailContact was deleted. Cannot create a contribution.')
|
||||||
|
throw new Error('This emailContact was deleted. Cannot create a contribution.')
|
||||||
|
}
|
||||||
|
if (emailContact.user.deletedAt) {
|
||||||
|
logger.error('This user was deleted. Cannot create a contribution.')
|
||||||
throw new Error('This user was deleted. Cannot create a contribution.')
|
throw new Error('This user was deleted. Cannot create a contribution.')
|
||||||
}
|
}
|
||||||
if (!user.emailChecked) {
|
if (!emailContact.emailChecked) {
|
||||||
|
logger.error('Contribution could not be saved, Email is not activated')
|
||||||
throw new Error('Contribution could not be saved, Email is not activated')
|
throw new Error('Contribution could not be saved, Email is not activated')
|
||||||
}
|
}
|
||||||
const moderator = getUser(context)
|
const moderator = getUser(context)
|
||||||
logger.trace('moderator: ', moderator.id)
|
logger.trace('moderator: ', moderator.id)
|
||||||
const creations = await getUserCreation(user.id)
|
const creations = await getUserCreation(emailContact.userId)
|
||||||
logger.trace('creations', creations)
|
logger.trace('creations:', creations)
|
||||||
const creationDateObj = new Date(creationDate)
|
const creationDateObj = new Date(creationDate)
|
||||||
if (isContributionValid(creations, amount, creationDateObj)) {
|
logger.trace('creationDateObj:', creationDateObj)
|
||||||
const contribution = Contribution.create()
|
validateContribution(creations, amount, creationDateObj)
|
||||||
contribution.userId = user.id
|
const contribution = DbContribution.create()
|
||||||
contribution.amount = amount
|
contribution.userId = emailContact.userId
|
||||||
contribution.createdAt = new Date()
|
contribution.amount = amount
|
||||||
contribution.contributionDate = creationDateObj
|
contribution.createdAt = new Date()
|
||||||
contribution.memo = memo
|
contribution.contributionDate = creationDateObj
|
||||||
contribution.moderatorId = moderator.id
|
contribution.memo = memo
|
||||||
|
contribution.moderatorId = moderator.id
|
||||||
|
contribution.contributionType = ContributionType.ADMIN
|
||||||
|
contribution.contributionStatus = ContributionStatus.PENDING
|
||||||
|
|
||||||
logger.trace('contribution to save', contribution)
|
logger.trace('contribution to save', contribution)
|
||||||
await Contribution.save(contribution)
|
await DbContribution.save(contribution)
|
||||||
}
|
return getUserCreation(emailContact.userId)
|
||||||
return getUserCreation(user.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
|
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTIONS])
|
||||||
@ -292,42 +295,64 @@ export class AdminResolver {
|
|||||||
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
|
@Args() { id, email, amount, memo, creationDate }: AdminUpdateContributionArgs,
|
||||||
@Ctx() context: Context,
|
@Ctx() context: Context,
|
||||||
): Promise<AdminUpdateContribution> {
|
): Promise<AdminUpdateContribution> {
|
||||||
const user = await dbUser.findOne({ email }, { withDeleted: true })
|
const emailContact = await UserContact.findOne({
|
||||||
|
where: { email },
|
||||||
|
withDeleted: true,
|
||||||
|
relations: ['user'],
|
||||||
|
})
|
||||||
|
if (!emailContact) {
|
||||||
|
logger.error(`Could not find UserContact with email: ${email}`)
|
||||||
|
throw new Error(`Could not find UserContact with email: ${email}`)
|
||||||
|
}
|
||||||
|
const user = emailContact.user
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(`Could not find user with email: ${email}`)
|
logger.error(`Could not find User to emailContact: ${email}`)
|
||||||
|
throw new Error(`Could not find User to emailContact: ${email}`)
|
||||||
}
|
}
|
||||||
if (user.deletedAt) {
|
if (user.deletedAt) {
|
||||||
|
logger.error(`User was deleted (${email})`)
|
||||||
throw new Error(`User was deleted (${email})`)
|
throw new Error(`User was deleted (${email})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const moderator = getUser(context)
|
const moderator = getUser(context)
|
||||||
|
|
||||||
const contributionToUpdate = await Contribution.findOne({
|
const contributionToUpdate = await DbContribution.findOne({
|
||||||
where: { id, confirmedAt: IsNull() },
|
where: { id, confirmedAt: IsNull() },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!contributionToUpdate) {
|
if (!contributionToUpdate) {
|
||||||
|
logger.error('No contribution found to given id.')
|
||||||
throw new Error('No contribution found to given id.')
|
throw new Error('No contribution found to given id.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contributionToUpdate.userId !== user.id) {
|
if (contributionToUpdate.userId !== user.id) {
|
||||||
|
logger.error('user of the pending contribution and send user does not correspond')
|
||||||
throw new Error('user of the pending contribution and send user does not correspond')
|
throw new Error('user of the pending contribution and send user does not correspond')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contributionToUpdate.moderatorId === null) {
|
||||||
|
logger.error('An admin is not allowed to update a user contribution.')
|
||||||
|
throw new Error('An admin is not allowed to update a user contribution.')
|
||||||
|
}
|
||||||
|
|
||||||
const creationDateObj = new Date(creationDate)
|
const creationDateObj = new Date(creationDate)
|
||||||
let creations = await getUserCreation(user.id)
|
let creations = await getUserCreation(user.id)
|
||||||
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
|
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
|
||||||
creations = updateCreations(creations, contributionToUpdate)
|
creations = updateCreations(creations, contributionToUpdate)
|
||||||
|
} else {
|
||||||
|
logger.error('Currently the month of the contribution cannot change.')
|
||||||
|
throw new Error('Currently the month of the contribution cannot change.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// all possible cases not to be true are thrown in this function
|
// all possible cases not to be true are thrown in this function
|
||||||
isContributionValid(creations, amount, creationDateObj)
|
validateContribution(creations, amount, creationDateObj)
|
||||||
contributionToUpdate.amount = amount
|
contributionToUpdate.amount = amount
|
||||||
contributionToUpdate.memo = memo
|
contributionToUpdate.memo = memo
|
||||||
contributionToUpdate.contributionDate = new Date(creationDate)
|
contributionToUpdate.contributionDate = new Date(creationDate)
|
||||||
contributionToUpdate.moderatorId = moderator.id
|
contributionToUpdate.moderatorId = moderator.id
|
||||||
|
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
|
||||||
|
|
||||||
await Contribution.save(contributionToUpdate)
|
await DbContribution.save(contributionToUpdate)
|
||||||
const result = new AdminUpdateContribution()
|
const result = new AdminUpdateContribution()
|
||||||
result.amount = amount
|
result.amount = amount
|
||||||
result.memo = contributionToUpdate.memo
|
result.memo = contributionToUpdate.memo
|
||||||
@ -341,41 +366,48 @@ export class AdminResolver {
|
|||||||
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
|
@Authorized([RIGHTS.LIST_UNCONFIRMED_CONTRIBUTIONS])
|
||||||
@Query(() => [UnconfirmedContribution])
|
@Query(() => [UnconfirmedContribution])
|
||||||
async listUnconfirmedContributions(): Promise<UnconfirmedContribution[]> {
|
async listUnconfirmedContributions(): Promise<UnconfirmedContribution[]> {
|
||||||
const contributions = await Contribution.find({ where: { confirmedAt: IsNull() } })
|
const contributions = await getConnection()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('c')
|
||||||
|
.from(DbContribution, 'c')
|
||||||
|
.leftJoinAndSelect('c.messages', 'm')
|
||||||
|
.where({ confirmedAt: IsNull() })
|
||||||
|
.getMany()
|
||||||
|
|
||||||
if (contributions.length === 0) {
|
if (contributions.length === 0) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const userIds = contributions.map((p) => p.userId)
|
const userIds = contributions.map((p) => p.userId)
|
||||||
const userCreations = await getUserCreations(userIds)
|
const userCreations = await getUserCreations(userIds)
|
||||||
const users = await dbUser.find({ where: { id: In(userIds) }, withDeleted: true })
|
const users = await dbUser.find({
|
||||||
|
where: { id: In(userIds) },
|
||||||
|
withDeleted: true,
|
||||||
|
relations: ['emailContact'],
|
||||||
|
})
|
||||||
|
|
||||||
return contributions.map((contribution) => {
|
return contributions.map((contribution) => {
|
||||||
const user = users.find((u) => u.id === contribution.userId)
|
const user = users.find((u) => u.id === contribution.userId)
|
||||||
const creation = userCreations.find((c) => c.id === contribution.userId)
|
const creation = userCreations.find((c) => c.id === contribution.userId)
|
||||||
|
|
||||||
return {
|
return new UnconfirmedContribution(
|
||||||
id: contribution.id,
|
contribution,
|
||||||
userId: contribution.userId,
|
user,
|
||||||
date: contribution.contributionDate,
|
creation ? creation.creations : FULL_CREATION_AVAILABLE,
|
||||||
memo: contribution.memo,
|
)
|
||||||
amount: contribution.amount,
|
|
||||||
moderator: contribution.moderatorId,
|
|
||||||
firstName: user ? user.firstName : '',
|
|
||||||
lastName: user ? user.lastName : '',
|
|
||||||
email: user ? user.email : '',
|
|
||||||
creation: creation ? creation.creations : FULL_CREATION_AVAILABLE,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
|
@Authorized([RIGHTS.ADMIN_DELETE_CONTRIBUTION])
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
async adminDeleteContribution(@Arg('id', () => Int) id: number): Promise<boolean> {
|
async adminDeleteContribution(@Arg('id', () => Int) id: number): Promise<boolean> {
|
||||||
const contribution = await Contribution.findOne(id)
|
const contribution = await DbContribution.findOne(id)
|
||||||
if (!contribution) {
|
if (!contribution) {
|
||||||
|
logger.error(`Contribution not found for given id: ${id}`)
|
||||||
throw new Error('Contribution not found for given id.')
|
throw new Error('Contribution not found for given id.')
|
||||||
}
|
}
|
||||||
|
contribution.contributionStatus = ContributionStatus.DELETED
|
||||||
|
await contribution.save()
|
||||||
const res = await contribution.softRemove()
|
const res = await contribution.softRemove()
|
||||||
return !!res
|
return !!res
|
||||||
}
|
}
|
||||||
@ -386,27 +418,32 @@ export class AdminResolver {
|
|||||||
@Arg('id', () => Int) id: number,
|
@Arg('id', () => Int) id: number,
|
||||||
@Ctx() context: Context,
|
@Ctx() context: Context,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const contribution = await Contribution.findOne(id)
|
const contribution = await DbContribution.findOne(id)
|
||||||
if (!contribution) {
|
if (!contribution) {
|
||||||
|
logger.error(`Contribution not found for given id: ${id}`)
|
||||||
throw new Error('Contribution not found to given id.')
|
throw new Error('Contribution not found to given id.')
|
||||||
}
|
}
|
||||||
const moderatorUser = getUser(context)
|
const moderatorUser = getUser(context)
|
||||||
if (moderatorUser.id === contribution.userId)
|
if (moderatorUser.id === contribution.userId) {
|
||||||
|
logger.error('Moderator can not confirm own contribution')
|
||||||
throw new Error('Moderator can not confirm own contribution')
|
throw new Error('Moderator can not confirm own contribution')
|
||||||
|
|
||||||
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(contribution.userId, false)
|
|
||||||
if (!isContributionValid(creations, contribution.amount, contribution.contributionDate)) {
|
|
||||||
throw new Error('Creation is not valid!!')
|
|
||||||
}
|
}
|
||||||
|
const user = await dbUser.findOneOrFail(
|
||||||
|
{ id: contribution.userId },
|
||||||
|
{ withDeleted: true, relations: ['emailContact'] },
|
||||||
|
)
|
||||||
|
if (user.deletedAt) {
|
||||||
|
logger.error('This user was deleted. Cannot confirm a contribution.')
|
||||||
|
throw new Error('This user was deleted. Cannot confirm a contribution.')
|
||||||
|
}
|
||||||
|
const creations = await getUserCreation(contribution.userId, false)
|
||||||
|
validateContribution(creations, contribution.amount, contribution.contributionDate)
|
||||||
|
|
||||||
const receivedCallDate = new Date()
|
const receivedCallDate = new Date()
|
||||||
|
|
||||||
const queryRunner = getConnection().createQueryRunner()
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
await queryRunner.connect()
|
await queryRunner.connect()
|
||||||
await queryRunner.startTransaction('READ UNCOMMITTED')
|
await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED')
|
||||||
try {
|
try {
|
||||||
const lastTransaction = await queryRunner.manager
|
const lastTransaction = await queryRunner.manager
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
@ -445,10 +482,21 @@ export class AdminResolver {
|
|||||||
contribution.confirmedAt = receivedCallDate
|
contribution.confirmedAt = receivedCallDate
|
||||||
contribution.confirmedBy = moderatorUser.id
|
contribution.confirmedBy = moderatorUser.id
|
||||||
contribution.transactionId = transaction.id
|
contribution.transactionId = transaction.id
|
||||||
await queryRunner.manager.update(Contribution, { id: contribution.id }, contribution)
|
contribution.contributionStatus = ContributionStatus.CONFIRMED
|
||||||
|
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
|
||||||
|
|
||||||
await queryRunner.commitTransaction()
|
await queryRunner.commitTransaction()
|
||||||
logger.info('creation commited successfuly.')
|
logger.info('creation commited successfuly.')
|
||||||
|
sendContributionConfirmedEmail({
|
||||||
|
senderFirstName: moderatorUser.firstName,
|
||||||
|
senderLastName: moderatorUser.lastName,
|
||||||
|
recipientFirstName: user.firstName,
|
||||||
|
recipientLastName: user.lastName,
|
||||||
|
recipientEmail: user.emailContact.email,
|
||||||
|
contributionMemo: contribution.memo,
|
||||||
|
contributionAmount: contribution.amount,
|
||||||
|
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await queryRunner.rollbackTransaction()
|
await queryRunner.rollbackTransaction()
|
||||||
logger.error(`Creation was not successful: ${e}`)
|
logger.error(`Creation was not successful: ${e}`)
|
||||||
@ -460,56 +508,64 @@ export class AdminResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Authorized([RIGHTS.CREATION_TRANSACTION_LIST])
|
@Authorized([RIGHTS.CREATION_TRANSACTION_LIST])
|
||||||
@Query(() => [Transaction])
|
@Query(() => ContributionListResult)
|
||||||
async creationTransactionList(
|
async creationTransactionList(
|
||||||
@Args()
|
@Args()
|
||||||
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
|
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
|
||||||
@Arg('userId', () => Int) userId: number,
|
@Arg('userId', () => Int) userId: number,
|
||||||
): Promise<Transaction[]> {
|
): Promise<ContributionListResult> {
|
||||||
const offset = (currentPage - 1) * pageSize
|
const offset = (currentPage - 1) * pageSize
|
||||||
const transactionRepository = getCustomRepository(TransactionRepository)
|
const [contributionResult, count] = await getConnection()
|
||||||
const [userTransactions] = await transactionRepository.findByUserPaged(
|
.createQueryBuilder()
|
||||||
userId,
|
.select('c')
|
||||||
pageSize,
|
.from(DbContribution, 'c')
|
||||||
offset,
|
.leftJoinAndSelect('c.user', 'u')
|
||||||
order,
|
.where(`user_id = ${userId}`)
|
||||||
true,
|
.limit(pageSize)
|
||||||
)
|
.offset(offset)
|
||||||
|
.orderBy('c.created_at', order)
|
||||||
|
.getManyAndCount()
|
||||||
|
|
||||||
const user = await dbUser.findOneOrFail({ id: userId })
|
return new ContributionListResult(
|
||||||
return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
|
count,
|
||||||
|
contributionResult.map((contribution) => new Contribution(contribution, contribution.user)),
|
||||||
|
)
|
||||||
|
// return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
|
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
|
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
|
||||||
email = email.trim().toLowerCase()
|
email = email.trim().toLowerCase()
|
||||||
const user = await dbUser.findOneOrFail({ email: email })
|
// const user = await dbUser.findOne({ id: emailContact.userId })
|
||||||
|
const user = await findUserByEmail(email)
|
||||||
// can be both types: REGISTER and RESET_PASSWORD
|
if (!user) {
|
||||||
let optInCode = await LoginEmailOptIn.findOne({
|
logger.error(`Could not find User to emailContact: ${email}`)
|
||||||
where: { userId: user.id },
|
throw new Error(`Could not find User to emailContact: ${email}`)
|
||||||
order: { updatedAt: 'DESC' },
|
}
|
||||||
})
|
if (user.deletedAt) {
|
||||||
|
logger.error(`User with emailContact: ${email} is deleted.`)
|
||||||
optInCode = await checkOptInCode(optInCode, user.id)
|
throw new Error(`User with emailContact: ${email} is deleted.`)
|
||||||
|
}
|
||||||
|
const emailContact = user.emailContact
|
||||||
|
if (emailContact.deletedAt) {
|
||||||
|
logger.error(`The emailContact: ${email} of htis User is deleted.`)
|
||||||
|
throw new Error(`The emailContact: ${email} of htis User is deleted.`)
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const emailSent = await sendAccountActivationEmail({
|
const emailSent = await sendAccountActivationEmail({
|
||||||
link: activationLink(optInCode),
|
link: activationLink(emailContact.emailVerificationCode),
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
email,
|
email,
|
||||||
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
|
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
|
||||||
})
|
})
|
||||||
|
|
||||||
/* uncomment this, when you need the activation link on the console
|
|
||||||
// In case EMails are disabled log the activation link for the user
|
// In case EMails are disabled log the activation link for the user
|
||||||
if (!emailSent) {
|
if (!emailSent) {
|
||||||
// eslint-disable-next-line no-console
|
logger.info(`Account confirmation link: ${activationLink}`)
|
||||||
console.log(`Account confirmation link: ${activationLink}`)
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -586,11 +642,8 @@ export class AdminResolver {
|
|||||||
logger.error(`The memo must be initialized!`)
|
logger.error(`The memo must be initialized!`)
|
||||||
throw new Error(`The memo must be initialized!`)
|
throw new Error(`The memo must be initialized!`)
|
||||||
}
|
}
|
||||||
if (
|
if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) {
|
||||||
memo.length < CONTRIBUTIONLINK_MEMO_MIN_CHARS ||
|
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_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}`)
|
logger.error(`${msg}`)
|
||||||
throw new Error(`${msg}`)
|
throw new Error(`${msg}`)
|
||||||
}
|
}
|
||||||
@ -625,6 +678,7 @@ export class AdminResolver {
|
|||||||
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
|
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
|
||||||
): Promise<ContributionLinkList> {
|
): Promise<ContributionLinkList> {
|
||||||
const [links, count] = await DbContributionLink.findAndCount({
|
const [links, count] = await DbContributionLink.findAndCount({
|
||||||
|
where: [{ validTo: MoreThan(new Date()) }, { validTo: IsNull() }],
|
||||||
order: { createdAt: order },
|
order: { createdAt: order },
|
||||||
skip: (currentPage - 1) * pageSize,
|
skip: (currentPage - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
@ -682,127 +736,73 @@ export class AdminResolver {
|
|||||||
logger.debug(`updateContributionLink successful!`)
|
logger.debug(`updateContributionLink successful!`)
|
||||||
return new ContributionLink(dbContributionLink)
|
return new ContributionLink(dbContributionLink)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
interface CreationMap {
|
@Authorized([RIGHTS.ADMIN_CREATE_CONTRIBUTION_MESSAGE])
|
||||||
id: number
|
@Mutation(() => ContributionMessage)
|
||||||
creations: Decimal[]
|
async adminCreateContributionMessage(
|
||||||
}
|
@Args() { contributionId, message }: ContributionMessageArgs,
|
||||||
|
@Ctx() context: Context,
|
||||||
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => {
|
): Promise<ContributionMessage> {
|
||||||
logger.trace('getUserCreation', id, includePending)
|
const user = getUser(context)
|
||||||
const creations = await getUserCreations([id], includePending)
|
if (!user.emailContact) {
|
||||||
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
|
user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } })
|
||||||
}
|
|
||||||
|
|
||||||
async function getUserCreations(ids: number[], includePending = true): Promise<CreationMap[]> {
|
|
||||||
logger.trace('getUserCreations:', ids, includePending)
|
|
||||||
const months = getCreationMonths()
|
|
||||||
logger.trace('getUserCreations months', months)
|
|
||||||
|
|
||||||
const queryRunner = getConnection().createQueryRunner()
|
|
||||||
await queryRunner.connect()
|
|
||||||
|
|
||||||
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
|
|
||||||
logger.trace('getUserCreations dateFilter', dateFilter)
|
|
||||||
|
|
||||||
const unionString = includePending
|
|
||||||
? `
|
|
||||||
UNION
|
|
||||||
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions
|
|
||||||
WHERE user_id IN (${ids.toString()})
|
|
||||||
AND contribution_date >= ${dateFilter}
|
|
||||||
AND confirmed_at IS NULL AND deleted_at IS NULL`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
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)
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
})
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
}
|
await queryRunner.connect()
|
||||||
|
await queryRunner.startTransaction('REPEATABLE READ')
|
||||||
|
const contributionMessage = DbContributionMessage.create()
|
||||||
|
try {
|
||||||
|
const contribution = await DbContribution.findOne({
|
||||||
|
where: { id: contributionId },
|
||||||
|
relations: ['user'],
|
||||||
|
})
|
||||||
|
if (!contribution) {
|
||||||
|
throw new Error('Contribution not found')
|
||||||
|
}
|
||||||
|
if (contribution.userId === user.id) {
|
||||||
|
throw new Error('Admin can not answer on own contribution')
|
||||||
|
}
|
||||||
|
if (!contribution.user.emailContact) {
|
||||||
|
contribution.user.emailContact = await UserContact.findOneOrFail({
|
||||||
|
where: { id: contribution.user.emailId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
contributionMessage.contributionId = contributionId
|
||||||
|
contributionMessage.createdAt = new Date()
|
||||||
|
contributionMessage.message = message
|
||||||
|
contributionMessage.userId = user.id
|
||||||
|
contributionMessage.type = ContributionMessageType.DIALOG
|
||||||
|
contributionMessage.isModerator = true
|
||||||
|
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
|
||||||
|
|
||||||
function updateCreations(creations: Decimal[], contribution: Contribution): Decimal[] {
|
if (
|
||||||
const index = getCreationIndex(contribution.contributionDate.getMonth())
|
contribution.contributionStatus === ContributionStatus.DELETED ||
|
||||||
|
contribution.contributionStatus === ContributionStatus.DENIED ||
|
||||||
|
contribution.contributionStatus === ContributionStatus.PENDING
|
||||||
|
) {
|
||||||
|
contribution.contributionStatus = ContributionStatus.IN_PROGRESS
|
||||||
|
await queryRunner.manager.update(DbContribution, { id: contributionId }, contribution)
|
||||||
|
}
|
||||||
|
|
||||||
if (index < 0) {
|
await sendAddedContributionMessageEmail({
|
||||||
throw new Error('You cannot create GDD for a month older than the last three months.')
|
senderFirstName: user.firstName,
|
||||||
}
|
senderLastName: user.lastName,
|
||||||
creations[index] = creations[index].plus(contribution.amount.toString())
|
recipientFirstName: contribution.user.firstName,
|
||||||
return creations
|
recipientLastName: contribution.user.lastName,
|
||||||
}
|
recipientEmail: contribution.user.emailContact.email,
|
||||||
|
senderEmail: user.emailContact.email,
|
||||||
export const isContributionValid = (
|
contributionMemo: contribution.memo,
|
||||||
creations: Decimal[],
|
message,
|
||||||
amount: Decimal,
|
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
|
||||||
creationDate: Date,
|
})
|
||||||
): boolean => {
|
await queryRunner.commitTransaction()
|
||||||
logger.trace('isContributionValid', creations, amount, creationDate)
|
} catch (e) {
|
||||||
const index = getCreationIndex(creationDate.getMonth())
|
await queryRunner.rollbackTransaction()
|
||||||
|
logger.error(`ContributionMessage was not successful: ${e}`)
|
||||||
if (index < 0) {
|
throw new Error(`ContributionMessage was not successful: ${e}`)
|
||||||
throw new Error('No information for available creations for the given date')
|
} finally {
|
||||||
}
|
await queryRunner.release()
|
||||||
|
}
|
||||||
if (amount.greaterThan(creations[index].toString())) {
|
return new ContributionMessage(contributionMessage, user)
|
||||||
throw new Error(
|
|
||||||
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
const isStartEndDateValid = (
|
|
||||||
startDate: string | null | undefined,
|
|
||||||
endDate: string | null | undefined,
|
|
||||||
): void => {
|
|
||||||
if (!startDate) {
|
|
||||||
logger.error('Start-Date is not initialized. A Start-Date must be set!')
|
|
||||||
throw new Error('Start-Date is not initialized. A Start-Date must be set!')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!endDate) {
|
|
||||||
logger.error('End-Date is not initialized. An End-Date must be set!')
|
|
||||||
throw new Error('End-Date is not initialized. An End-Date must be set!')
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if endDate is before startDate
|
|
||||||
if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) {
|
|
||||||
logger.error(`The value of validFrom must before or equals the validTo!`)
|
|
||||||
throw new Error(`The value of validFrom must before or equals the validTo!`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
351
backend/src/graphql/resolver/ContributionMessageResolver.test.ts
Normal file
351
backend/src/graphql/resolver/ContributionMessageResolver.test.ts
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||||
|
|
||||||
|
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
|
||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
import {
|
||||||
|
adminCreateContributionMessage,
|
||||||
|
createContribution,
|
||||||
|
createContributionMessage,
|
||||||
|
login,
|
||||||
|
} from '@/seeds/graphql/mutations'
|
||||||
|
import { listContributionMessages } from '@/seeds/graphql/queries'
|
||||||
|
import { userFactory } from '@/seeds/factory/user'
|
||||||
|
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
||||||
|
import { peterLustig } from '@/seeds/users/peter-lustig'
|
||||||
|
import { sendAddedContributionMessageEmail } from '@/mailer/sendAddedContributionMessageEmail'
|
||||||
|
|
||||||
|
jest.mock('@/mailer/sendAddedContributionMessageEmail', () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
sendAddedContributionMessageEmail: jest.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let mutate: any, con: any
|
||||||
|
let testEnv: any
|
||||||
|
let result: any
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testEnv = await testEnvironment()
|
||||||
|
mutate = testEnv.mutate
|
||||||
|
con = testEnv.con
|
||||||
|
await cleanDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanDB()
|
||||||
|
await con.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ContributionMessageResolver', () => {
|
||||||
|
describe('adminCreateContributionMessage', () => {
|
||||||
|
describe('unauthenticated', () => {
|
||||||
|
it('returns an error', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: adminCreateContributionMessage,
|
||||||
|
variables: { contributionId: 1, message: 'This is a test message' },
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [new GraphQLError('401 Unauthorized')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('authenticated', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
|
await userFactory(testEnv, peterLustig)
|
||||||
|
await mutate({
|
||||||
|
mutation: 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(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
resetToken()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('input not valid', () => {
|
||||||
|
it('throws error when contribution does not exist', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: adminCreateContributionMessage,
|
||||||
|
variables: {
|
||||||
|
contributionId: -1,
|
||||||
|
message: 'Test',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [
|
||||||
|
new GraphQLError(
|
||||||
|
'ContributionMessage was not successful: Error: Contribution not found',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws error when contribution.userId equals user.id', async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
const result2 = await mutate({
|
||||||
|
mutation: createContribution,
|
||||||
|
variables: {
|
||||||
|
amount: 100.0,
|
||||||
|
memo: 'Test env contribution',
|
||||||
|
creationDate: new Date().toString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: adminCreateContributionMessage,
|
||||||
|
variables: {
|
||||||
|
contributionId: result2.data.createContribution.id,
|
||||||
|
message: 'Test',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [
|
||||||
|
new GraphQLError(
|
||||||
|
'ContributionMessage was not successful: Error: Admin can not answer on own contribution',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('valid input', () => {
|
||||||
|
it('creates ContributionMessage', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: adminCreateContributionMessage,
|
||||||
|
variables: {
|
||||||
|
contributionId: result.data.createContribution.id,
|
||||||
|
message: 'Admin Test',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
adminCreateContributionMessage: expect.objectContaining({
|
||||||
|
id: expect.any(Number),
|
||||||
|
message: 'Admin Test',
|
||||||
|
type: 'DIALOG',
|
||||||
|
userFirstName: 'Peter',
|
||||||
|
userLastName: 'Lustig',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls sendAddedContributionMessageEmail', async () => {
|
||||||
|
expect(sendAddedContributionMessageEmail).toBeCalledWith({
|
||||||
|
senderFirstName: 'Peter',
|
||||||
|
senderLastName: 'Lustig',
|
||||||
|
recipientFirstName: 'Bibi',
|
||||||
|
recipientLastName: 'Bloxberg',
|
||||||
|
recipientEmail: 'bibi@bloxberg.de',
|
||||||
|
senderEmail: 'peter@lustig.de',
|
||||||
|
contributionMemo: 'Test env contribution',
|
||||||
|
message: 'Admin Test',
|
||||||
|
overviewURL: 'http://localhost/overview',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createContributionMessage', () => {
|
||||||
|
describe('unauthenticated', () => {
|
||||||
|
it('returns an error', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: createContributionMessage,
|
||||||
|
variables: { contributionId: 1, message: 'This is a test message' },
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [new GraphQLError('401 Unauthorized')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('authenticated', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
resetToken()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('input not valid', () => {
|
||||||
|
it('throws error when contribution does not exist', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: createContributionMessage,
|
||||||
|
variables: {
|
||||||
|
contributionId: -1,
|
||||||
|
message: 'Test',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [
|
||||||
|
new GraphQLError(
|
||||||
|
'ContributionMessage was not successful: Error: Contribution not found',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws error when other user tries to send createContributionMessage', async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: createContributionMessage,
|
||||||
|
variables: {
|
||||||
|
contributionId: result.data.createContribution.id,
|
||||||
|
message: 'Test',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [
|
||||||
|
new GraphQLError(
|
||||||
|
'ContributionMessage was not successful: Error: Can not send message to contribution of another user',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('valid input', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates ContributionMessage', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: createContributionMessage,
|
||||||
|
variables: {
|
||||||
|
contributionId: result.data.createContribution.id,
|
||||||
|
message: 'User Test',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
createContributionMessage: expect.objectContaining({
|
||||||
|
id: expect.any(Number),
|
||||||
|
message: 'User Test',
|
||||||
|
type: 'DIALOG',
|
||||||
|
userFirstName: 'Bibi',
|
||||||
|
userLastName: 'Bloxberg',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('listContributionMessages', () => {
|
||||||
|
describe('unauthenticated', () => {
|
||||||
|
it('returns an error', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: listContributionMessages,
|
||||||
|
variables: { contributionId: 1 },
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [new GraphQLError('401 Unauthorized')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('authenticated', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
resetToken()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a list of contributionmessages', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: listContributionMessages,
|
||||||
|
variables: { contributionId: result.data.createContribution.id },
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
listContributionMessages: {
|
||||||
|
count: 2,
|
||||||
|
messages: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: expect.any(Number),
|
||||||
|
message: 'Admin Test',
|
||||||
|
type: 'DIALOG',
|
||||||
|
userFirstName: 'Peter',
|
||||||
|
userLastName: 'Lustig',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: expect.any(Number),
|
||||||
|
message: 'User Test',
|
||||||
|
type: 'DIALOG',
|
||||||
|
userFirstName: 'Bibi',
|
||||||
|
userLastName: 'Bloxberg',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
85
backend/src/graphql/resolver/ContributionMessageResolver.ts
Normal file
85
backend/src/graphql/resolver/ContributionMessageResolver.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
|
import { Context, getUser } from '@/server/context'
|
||||||
|
import { ContributionMessage as DbContributionMessage } from '@entity/ContributionMessage'
|
||||||
|
import { Arg, Args, Authorized, Ctx, Mutation, Query, Resolver } from 'type-graphql'
|
||||||
|
import ContributionMessageArgs from '@arg/ContributionMessageArgs'
|
||||||
|
import { Contribution } from '@entity/Contribution'
|
||||||
|
import { ContributionMessageType } from '@enum/MessageType'
|
||||||
|
import { ContributionStatus } from '@enum/ContributionStatus'
|
||||||
|
import { getConnection } from '@dbTools/typeorm'
|
||||||
|
import { ContributionMessage, ContributionMessageListResult } from '@model/ContributionMessage'
|
||||||
|
import Paginated from '@arg/Paginated'
|
||||||
|
import { Order } from '@enum/Order'
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class ContributionMessageResolver {
|
||||||
|
@Authorized([RIGHTS.CREATE_CONTRIBUTION_MESSAGE])
|
||||||
|
@Mutation(() => ContributionMessage)
|
||||||
|
async createContributionMessage(
|
||||||
|
@Args() { contributionId, message }: ContributionMessageArgs,
|
||||||
|
@Ctx() context: Context,
|
||||||
|
): Promise<ContributionMessage> {
|
||||||
|
const user = getUser(context)
|
||||||
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
|
await queryRunner.connect()
|
||||||
|
await queryRunner.startTransaction('REPEATABLE READ')
|
||||||
|
const contributionMessage = DbContributionMessage.create()
|
||||||
|
try {
|
||||||
|
const contribution = await Contribution.findOne({ id: contributionId })
|
||||||
|
if (!contribution) {
|
||||||
|
throw new Error('Contribution not found')
|
||||||
|
}
|
||||||
|
if (contribution.userId !== user.id) {
|
||||||
|
throw new Error('Can not send message to contribution of another user')
|
||||||
|
}
|
||||||
|
|
||||||
|
contributionMessage.contributionId = contributionId
|
||||||
|
contributionMessage.createdAt = new Date()
|
||||||
|
contributionMessage.message = message
|
||||||
|
contributionMessage.userId = user.id
|
||||||
|
contributionMessage.type = ContributionMessageType.DIALOG
|
||||||
|
contributionMessage.isModerator = false
|
||||||
|
await queryRunner.manager.insert(DbContributionMessage, contributionMessage)
|
||||||
|
|
||||||
|
if (contribution.contributionStatus === ContributionStatus.IN_PROGRESS) {
|
||||||
|
contribution.contributionStatus = ContributionStatus.PENDING
|
||||||
|
await queryRunner.manager.update(Contribution, { id: contributionId }, contribution)
|
||||||
|
}
|
||||||
|
await queryRunner.commitTransaction()
|
||||||
|
} catch (e) {
|
||||||
|
await queryRunner.rollbackTransaction()
|
||||||
|
logger.error(`ContributionMessage was not successful: ${e}`)
|
||||||
|
throw new Error(`ContributionMessage was not successful: ${e}`)
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release()
|
||||||
|
}
|
||||||
|
return new ContributionMessage(contributionMessage, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES])
|
||||||
|
@Query(() => ContributionMessageListResult)
|
||||||
|
async listContributionMessages(
|
||||||
|
@Arg('contributionId') contributionId: number,
|
||||||
|
@Args()
|
||||||
|
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
|
||||||
|
): Promise<ContributionMessageListResult> {
|
||||||
|
const [contributionMessages, count] = await getConnection()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('cm')
|
||||||
|
.from(DbContributionMessage, 'cm')
|
||||||
|
.leftJoinAndSelect('cm.user', 'u')
|
||||||
|
.where({ contributionId: contributionId })
|
||||||
|
.orderBy('cm.createdAt', order)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((currentPage - 1) * pageSize)
|
||||||
|
.getManyAndCount()
|
||||||
|
|
||||||
|
return {
|
||||||
|
count,
|
||||||
|
messages: contributionMessages.map(
|
||||||
|
(message) => new ContributionMessage(message, message.user),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
867
backend/src/graphql/resolver/ContributionResolver.test.ts
Normal file
867
backend/src/graphql/resolver/ContributionResolver.test.ts
Normal file
@ -0,0 +1,867 @@
|
|||||||
|
/* 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,
|
||||||
|
login,
|
||||||
|
} from '@/seeds/graphql/mutations'
|
||||||
|
import { listAllContributions, listContributions } from '@/seeds/graphql/queries'
|
||||||
|
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
|
||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
import { userFactory } from '@/seeds/factory/user'
|
||||||
|
import { creationFactory } from '@/seeds/factory/creation'
|
||||||
|
import { creations } from '@/seeds/creation/index'
|
||||||
|
import { peterLustig } from '@/seeds/users/peter-lustig'
|
||||||
|
import { EventProtocol } from '@entity/EventProtocol'
|
||||||
|
import { EventProtocolType } from '@/event/EventProtocolType'
|
||||||
|
import { logger } from '@test/testSetup'
|
||||||
|
|
||||||
|
let mutate: any, query: any, con: any
|
||||||
|
let testEnv: any
|
||||||
|
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', () => {
|
||||||
|
let bibi: any
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
bibi = await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanDB()
|
||||||
|
resetToken()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('input not valid', () => {
|
||||||
|
it('throws error when memo length smaller than 5 chars', async () => {
|
||||||
|
const date = new Date()
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: createContribution,
|
||||||
|
variables: {
|
||||||
|
amount: 100.0,
|
||||||
|
memo: 'Test',
|
||||||
|
creationDate: date.toString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(`memo text is too short: memo.length=4 < (5)`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws error when memo length greater than 255 chars', async () => {
|
||||||
|
const date = new Date()
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: createContribution,
|
||||||
|
variables: {
|
||||||
|
amount: 100.0,
|
||||||
|
memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test',
|
||||||
|
creationDate: date.toString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(`memo text is too long: memo.length=259 > (255)`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws error when creationDate not-valid', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
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('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(
|
||||||
|
'No information for available creations with the given creationDate=',
|
||||||
|
'Invalid Date',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws error when creationDate 3 month behind', async () => {
|
||||||
|
const date = new Date()
|
||||||
|
await expect(
|
||||||
|
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'),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(
|
||||||
|
'No information for available creations with the given creationDate=',
|
||||||
|
'Invalid Date',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('valid input', () => {
|
||||||
|
let contribution: any
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
contribution = await mutate({
|
||||||
|
mutation: createContribution,
|
||||||
|
variables: {
|
||||||
|
amount: 100.0,
|
||||||
|
memo: 'Test env contribution',
|
||||||
|
creationDate: new Date().toString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates contribution', async () => {
|
||||||
|
expect(contribution).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
createContribution: {
|
||||||
|
id: expect.any(Number),
|
||||||
|
amount: '100',
|
||||||
|
memo: 'Test env contribution',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the create contribution event in the database', async () => {
|
||||||
|
await expect(EventProtocol.find()).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: EventProtocolType.CONTRIBUTION_CREATE,
|
||||||
|
amount: expect.decimalEqual(100),
|
||||||
|
contributionId: contribution.data.createContribution.id,
|
||||||
|
userId: bibi.data.login.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 mutate({
|
||||||
|
mutation: 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 mutate({
|
||||||
|
mutation: 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.')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('No contribution found to given id')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Memo length smaller than 5 chars', () => {
|
||||||
|
it('throws error', async () => {
|
||||||
|
const date = new Date()
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: updateContribution,
|
||||||
|
variables: {
|
||||||
|
contributionId: result.data.createContribution.id,
|
||||||
|
amount: 100.0,
|
||||||
|
memo: 'Test',
|
||||||
|
creationDate: date.toString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('memo text is too short: memo.length=4 < (5)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Memo length greater than 255 chars', () => {
|
||||||
|
it('throws error', async () => {
|
||||||
|
const date = new Date()
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: updateContribution,
|
||||||
|
variables: {
|
||||||
|
contributionId: result.data.createContribution.id,
|
||||||
|
amount: 100.0,
|
||||||
|
memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test',
|
||||||
|
creationDate: date.toString(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('memo text is too long: memo.length=259 > (255)')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('wrong user tries to update the contribution', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: 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',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(
|
||||||
|
'user of the pending contribution and send user does not correspond',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('admin tries to update a user contribution', () => {
|
||||||
|
it('throws an error', async () => {
|
||||||
|
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.')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO check that the error is logged (need to modify AdminResolver, avoid conflicts)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('update too much so that the limit is exceeded', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: 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.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(
|
||||||
|
'The amount (1019 GDD) to be created exceeds the amount (1000 GDD) still available for this month.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('update creation to a date that is older than 3 months', () => {
|
||||||
|
it('throws an error', async () => {
|
||||||
|
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('Currently the month of the contribution cannot change.')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(
|
||||||
|
'No information for available creations with the given creationDate=',
|
||||||
|
'Invalid Date',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('valid input', () => {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the update contribution event in the database', async () => {
|
||||||
|
bibi = await query({
|
||||||
|
query: login,
|
||||||
|
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(EventProtocol.find()).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: EventProtocolType.CONTRIBUTION_UPDATE,
|
||||||
|
amount: expect.decimalEqual(10),
|
||||||
|
contributionId: result.data.createContribution.id,
|
||||||
|
userId: bibi.data.login.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 mutate({
|
||||||
|
mutation: 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', () => {
|
||||||
|
let peter: any
|
||||||
|
beforeAll(async () => {
|
||||||
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
|
peter = await userFactory(testEnv, peterLustig)
|
||||||
|
|
||||||
|
await mutate({
|
||||||
|
mutation: 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.')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('Contribution not found for given id')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('other user sends a deleteContribution', () => {
|
||||||
|
it('returns an error', async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: 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')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the delete contribution event in the database', async () => {
|
||||||
|
const contribution = await mutate({
|
||||||
|
mutation: createContribution,
|
||||||
|
variables: {
|
||||||
|
amount: 166.0,
|
||||||
|
memo: 'Whatever contribution',
|
||||||
|
creationDate: new Date().toString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await mutate({
|
||||||
|
mutation: deleteContribution,
|
||||||
|
variables: {
|
||||||
|
id: contribution.data.createContribution.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(EventProtocol.find()).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: EventProtocolType.CONTRIBUTION_DELETE,
|
||||||
|
contributionId: contribution.data.createContribution.id,
|
||||||
|
amount: expect.decimalEqual(166),
|
||||||
|
userId: peter.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('User deletes already confirmed contribution', () => {
|
||||||
|
it('throws an error', async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
await mutate({
|
||||||
|
mutation: confirmContribution,
|
||||||
|
variables: {
|
||||||
|
id: result.data.createContribution.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await mutate({
|
||||||
|
mutation: 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')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('A confirmed contribution can not be deleted')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
222
backend/src/graphql/resolver/ContributionResolver.ts
Normal file
222
backend/src/graphql/resolver/ContributionResolver.ts
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
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, getConnection } from '@dbTools/typeorm'
|
||||||
|
import ContributionArgs from '@arg/ContributionArgs'
|
||||||
|
import Paginated from '@arg/Paginated'
|
||||||
|
import { Order } from '@enum/Order'
|
||||||
|
import { ContributionType } from '@enum/ContributionType'
|
||||||
|
import { ContributionStatus } from '@enum/ContributionStatus'
|
||||||
|
import { Contribution, ContributionListResult } from '@model/Contribution'
|
||||||
|
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
||||||
|
import { validateContribution, getUserCreation, updateCreations } from './util/creations'
|
||||||
|
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
|
||||||
|
import {
|
||||||
|
Event,
|
||||||
|
EventContributionCreate,
|
||||||
|
EventContributionDelete,
|
||||||
|
EventContributionUpdate,
|
||||||
|
} from '@/event/Event'
|
||||||
|
import { eventProtocol } from '@/event/EventProtocolEmitter'
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class ContributionResolver {
|
||||||
|
@Authorized([RIGHTS.CREATE_CONTRIBUTION])
|
||||||
|
@Mutation(() => UnconfirmedContribution)
|
||||||
|
async createContribution(
|
||||||
|
@Args() { amount, memo, creationDate }: ContributionArgs,
|
||||||
|
@Ctx() context: Context,
|
||||||
|
): Promise<UnconfirmedContribution> {
|
||||||
|
if (memo.length > MEMO_MAX_CHARS) {
|
||||||
|
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS})`)
|
||||||
|
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)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = new Event()
|
||||||
|
|
||||||
|
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
|
||||||
|
contribution.contributionType = ContributionType.USER
|
||||||
|
contribution.contributionStatus = ContributionStatus.PENDING
|
||||||
|
|
||||||
|
logger.trace('contribution to save', contribution)
|
||||||
|
await dbContribution.save(contribution)
|
||||||
|
|
||||||
|
const eventCreateContribution = new EventContributionCreate()
|
||||||
|
eventCreateContribution.userId = user.id
|
||||||
|
eventCreateContribution.amount = amount
|
||||||
|
eventCreateContribution.contributionId = contribution.id
|
||||||
|
await eventProtocol.writeEvent(event.setEventContributionCreate(eventCreateContribution))
|
||||||
|
|
||||||
|
return new UnconfirmedContribution(contribution, user, creations)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Authorized([RIGHTS.DELETE_CONTRIBUTION])
|
||||||
|
@Mutation(() => Boolean)
|
||||||
|
async deleteContribution(
|
||||||
|
@Arg('id', () => Int) id: number,
|
||||||
|
@Ctx() context: Context,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const event = new Event()
|
||||||
|
const user = getUser(context)
|
||||||
|
const contribution = await dbContribution.findOne(id)
|
||||||
|
if (!contribution) {
|
||||||
|
logger.error('Contribution not found for given id')
|
||||||
|
throw new Error('Contribution not found for given id.')
|
||||||
|
}
|
||||||
|
if (contribution.userId !== user.id) {
|
||||||
|
logger.error('Can not delete contribution of another user')
|
||||||
|
throw new Error('Can not delete contribution of another user')
|
||||||
|
}
|
||||||
|
if (contribution.confirmedAt) {
|
||||||
|
logger.error('A confirmed contribution can not be deleted')
|
||||||
|
throw new Error('A confirmed contribution can not be deleted')
|
||||||
|
}
|
||||||
|
|
||||||
|
contribution.contributionStatus = ContributionStatus.DELETED
|
||||||
|
contribution.deletedAt = new Date()
|
||||||
|
await contribution.save()
|
||||||
|
|
||||||
|
const eventDeleteContribution = new EventContributionDelete()
|
||||||
|
eventDeleteContribution.userId = user.id
|
||||||
|
eventDeleteContribution.contributionId = contribution.id
|
||||||
|
eventDeleteContribution.amount = contribution.amount
|
||||||
|
await eventProtocol.writeEvent(event.setEventContributionDelete(eventDeleteContribution))
|
||||||
|
|
||||||
|
const res = await contribution.softRemove()
|
||||||
|
return !!res
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 getConnection()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('c')
|
||||||
|
.from(dbContribution, 'c')
|
||||||
|
.leftJoinAndSelect('c.messages', 'm')
|
||||||
|
.where(where)
|
||||||
|
.withDeleted()
|
||||||
|
.orderBy('c.createdAt', order)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((currentPage - 1) * pageSize)
|
||||||
|
.getManyAndCount()
|
||||||
|
|
||||||
|
return new ContributionListResult(
|
||||||
|
count,
|
||||||
|
contributions.map((contribution) => new Contribution(contribution, 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 getConnection()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('c')
|
||||||
|
.from(dbContribution, 'c')
|
||||||
|
.innerJoinAndSelect('c.user', 'u')
|
||||||
|
.orderBy('c.createdAt', order)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset((currentPage - 1) * pageSize)
|
||||||
|
.getManyAndCount()
|
||||||
|
return new ContributionListResult(
|
||||||
|
count,
|
||||||
|
dbContributions.map((contribution) => new Contribution(contribution, 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> {
|
||||||
|
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)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = getUser(context)
|
||||||
|
|
||||||
|
const contributionToUpdate = await dbContribution.findOne({
|
||||||
|
where: { id: contributionId, confirmedAt: IsNull() },
|
||||||
|
})
|
||||||
|
if (!contributionToUpdate) {
|
||||||
|
logger.error('No contribution found to given id')
|
||||||
|
throw new Error('No contribution found to given id.')
|
||||||
|
}
|
||||||
|
if (contributionToUpdate.userId !== user.id) {
|
||||||
|
logger.error('user of the pending contribution and send user does not correspond')
|
||||||
|
throw new Error('user of the pending contribution and send user does not correspond')
|
||||||
|
}
|
||||||
|
|
||||||
|
const creationDateObj = new Date(creationDate)
|
||||||
|
let creations = await getUserCreation(user.id)
|
||||||
|
if (contributionToUpdate.contributionDate.getMonth() === creationDateObj.getMonth()) {
|
||||||
|
creations = updateCreations(creations, contributionToUpdate)
|
||||||
|
} else {
|
||||||
|
logger.error('Currently the month of the contribution cannot change.')
|
||||||
|
throw new Error('Currently the month of the contribution cannot change.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// all possible cases not to be true are thrown in this function
|
||||||
|
validateContribution(creations, amount, creationDateObj)
|
||||||
|
contributionToUpdate.amount = amount
|
||||||
|
contributionToUpdate.memo = memo
|
||||||
|
contributionToUpdate.contributionDate = new Date(creationDate)
|
||||||
|
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
|
||||||
|
dbContribution.save(contributionToUpdate)
|
||||||
|
|
||||||
|
const event = new Event()
|
||||||
|
|
||||||
|
const eventUpdateContribution = new EventContributionUpdate()
|
||||||
|
eventUpdateContribution.userId = user.id
|
||||||
|
eventUpdateContribution.contributionId = contributionId
|
||||||
|
eventUpdateContribution.amount = amount
|
||||||
|
await eventProtocol.writeEvent(event.setEventContributionUpdate(eventUpdateContribution))
|
||||||
|
|
||||||
|
return new UnconfirmedContribution(contributionToUpdate, user, creations)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -20,7 +20,7 @@ export class GdtResolver {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const resultGDT = await apiGet(
|
const resultGDT = await apiGet(
|
||||||
`${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.email}/${currentPage}/${pageSize}/${order}`,
|
`${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.emailContact.email}/${currentPage}/${pageSize}/${order}`,
|
||||||
)
|
)
|
||||||
if (!resultGDT.success) {
|
if (!resultGDT.success) {
|
||||||
throw new Error(resultGDT.data)
|
throw new Error(resultGDT.data)
|
||||||
@ -37,7 +37,7 @@ export class GdtResolver {
|
|||||||
const user = getUser(context)
|
const user = getUser(context)
|
||||||
try {
|
try {
|
||||||
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
|
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
|
||||||
email: user.email,
|
email: user.emailContact.email,
|
||||||
})
|
})
|
||||||
if (!resultGDTSum.success) {
|
if (!resultGDTSum.success) {
|
||||||
throw new Error('Call not successful')
|
throw new Error('Call not successful')
|
||||||
|
|||||||
76
backend/src/graphql/resolver/StatisticsResolver.ts
Normal file
76
backend/src/graphql/resolver/StatisticsResolver.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { Resolver, Query, Authorized } from 'type-graphql'
|
||||||
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
|
import { CommunityStatistics } from '@model/CommunityStatistics'
|
||||||
|
import { User as DbUser } from '@entity/User'
|
||||||
|
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||||
|
import { getConnection } from '@dbTools/typeorm'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { calculateDecay } from '@/util/decay'
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||||
|
|
||||||
|
@Resolver()
|
||||||
|
export class StatisticsResolver {
|
||||||
|
@Authorized([RIGHTS.COMMUNITY_STATISTICS])
|
||||||
|
@Query(() => CommunityStatistics)
|
||||||
|
async communityStatistics(): Promise<CommunityStatistics> {
|
||||||
|
const allUsers = await DbUser.count({ withDeleted: true })
|
||||||
|
const totalUsers = await DbUser.count()
|
||||||
|
const deletedUsers = allUsers - totalUsers
|
||||||
|
|
||||||
|
let totalGradidoAvailable: Decimal = new Decimal(0)
|
||||||
|
let totalGradidoUnbookedDecayed: Decimal = new Decimal(0)
|
||||||
|
|
||||||
|
const receivedCallDate = new Date()
|
||||||
|
|
||||||
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
|
await queryRunner.connect()
|
||||||
|
|
||||||
|
const lastUserTransactions = await queryRunner.manager
|
||||||
|
.createQueryBuilder(DbUser, 'user')
|
||||||
|
.select('transaction.balance', 'balance')
|
||||||
|
.addSelect('transaction.balance_date', 'balanceDate')
|
||||||
|
.innerJoin(DbTransaction, 'transaction', 'user.id = transaction.user_id')
|
||||||
|
.where(
|
||||||
|
`transaction.balance_date = (SELECT MAX(t.balance_date) FROM transactions AS t WHERE t.user_id = user.id)`,
|
||||||
|
)
|
||||||
|
.orderBy('transaction.balance_date', 'DESC')
|
||||||
|
.addOrderBy('transaction.id', 'DESC')
|
||||||
|
.getRawMany()
|
||||||
|
|
||||||
|
const activeUsers = lastUserTransactions.length
|
||||||
|
|
||||||
|
lastUserTransactions.forEach(({ balance, balanceDate }) => {
|
||||||
|
const decay = calculateDecay(new Decimal(balance), new Date(balanceDate), receivedCallDate)
|
||||||
|
if (decay) {
|
||||||
|
totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString())
|
||||||
|
totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { totalGradidoCreated } = await queryRunner.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('SUM(transaction.amount) AS totalGradidoCreated')
|
||||||
|
.from(DbTransaction, 'transaction')
|
||||||
|
.where('transaction.typeId = 1')
|
||||||
|
.getRawOne()
|
||||||
|
|
||||||
|
const { totalGradidoDecayed } = await queryRunner.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('SUM(transaction.decay) AS totalGradidoDecayed')
|
||||||
|
.from(DbTransaction, 'transaction')
|
||||||
|
.where('transaction.decay IS NOT NULL')
|
||||||
|
.getRawOne()
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers,
|
||||||
|
activeUsers,
|
||||||
|
deletedUsers,
|
||||||
|
totalGradidoCreated,
|
||||||
|
totalGradidoDecayed,
|
||||||
|
totalGradidoAvailable,
|
||||||
|
totalGradidoUnbookedDecayed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,168 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||||
|
|
||||||
import { transactionLinkCode } from './TransactionLinkResolver'
|
import { transactionLinkCode } from './TransactionLinkResolver'
|
||||||
|
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
||||||
|
import { peterLustig } from '@/seeds/users/peter-lustig'
|
||||||
|
import { cleanDB, testEnvironment } from '@test/helpers'
|
||||||
|
import { userFactory } from '@/seeds/factory/user'
|
||||||
|
import { login, createContributionLink, redeemTransactionLink } from '@/seeds/graphql/mutations'
|
||||||
|
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { GraphQLError } from 'graphql'
|
||||||
|
|
||||||
|
let mutate: any, con: any
|
||||||
|
let testEnv: any
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testEnv = await testEnvironment()
|
||||||
|
mutate = testEnv.mutate
|
||||||
|
con = testEnv.con
|
||||||
|
await cleanDB()
|
||||||
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
|
await userFactory(testEnv, peterLustig)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanDB()
|
||||||
|
await con.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TransactionLinkResolver', () => {
|
||||||
|
describe('redeem daily Contribution Link', () => {
|
||||||
|
const now = new Date()
|
||||||
|
let contributionLink: DbContributionLink | undefined
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
await mutate({
|
||||||
|
mutation: createContributionLink,
|
||||||
|
variables: {
|
||||||
|
amount: new Decimal(5),
|
||||||
|
name: 'Daily Contribution Link',
|
||||||
|
memo: 'Thank you for contribute daily to the community',
|
||||||
|
cycle: 'DAILY',
|
||||||
|
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
|
||||||
|
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
|
||||||
|
maxAmountPerMonth: new Decimal(200),
|
||||||
|
maxPerCycle: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has a daily contribution link in the database', async () => {
|
||||||
|
const cls = await DbContributionLink.find()
|
||||||
|
expect(cls).toHaveLength(1)
|
||||||
|
contributionLink = cls[0]
|
||||||
|
expect(contributionLink).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: expect.any(Number),
|
||||||
|
name: 'Daily Contribution Link',
|
||||||
|
memo: 'Thank you for contribute daily to the community',
|
||||||
|
validFrom: new Date(now.getFullYear(), 0, 1),
|
||||||
|
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0),
|
||||||
|
cycle: 'DAILY',
|
||||||
|
maxPerCycle: 1,
|
||||||
|
totalMaxCountOfContribution: null,
|
||||||
|
maxAccountBalance: null,
|
||||||
|
minGapHours: null,
|
||||||
|
createdAt: expect.any(Date),
|
||||||
|
deletedAt: null,
|
||||||
|
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
|
||||||
|
linkEnabled: true,
|
||||||
|
amount: expect.decimalEqual(5),
|
||||||
|
maxAmountPerMonth: expect.decimalEqual(200),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows the user to redeem the contribution link', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: redeemTransactionLink,
|
||||||
|
variables: {
|
||||||
|
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
data: {
|
||||||
|
redeemTransactionLink: true,
|
||||||
|
},
|
||||||
|
errors: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: redeemTransactionLink,
|
||||||
|
variables: {
|
||||||
|
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
errors: [
|
||||||
|
new GraphQLError(
|
||||||
|
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('after one day', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/no-empty-function */
|
||||||
|
setTimeout(() => {}, 1000 * 60 * 60 * 24)
|
||||||
|
jest.runAllTimers()
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows the user to redeem the contribution link again', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: redeemTransactionLink,
|
||||||
|
variables: {
|
||||||
|
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
data: {
|
||||||
|
redeemTransactionLink: true,
|
||||||
|
},
|
||||||
|
errors: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
|
||||||
|
await expect(
|
||||||
|
mutate({
|
||||||
|
mutation: redeemTransactionLink,
|
||||||
|
variables: {
|
||||||
|
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
errors: [
|
||||||
|
new GraphQLError(
|
||||||
|
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('transactionLinkCode', () => {
|
describe('transactionLinkCode', () => {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
|
|||||||
@ -26,12 +26,15 @@ import { User } from '@model/User'
|
|||||||
import { calculateDecay } from '@/util/decay'
|
import { calculateDecay } from '@/util/decay'
|
||||||
import { executeTransaction } from './TransactionResolver'
|
import { executeTransaction } from './TransactionResolver'
|
||||||
import { Order } from '@enum/Order'
|
import { Order } from '@enum/Order'
|
||||||
|
import { ContributionType } from '@enum/ContributionType'
|
||||||
|
import { ContributionStatus } from '@enum/ContributionStatus'
|
||||||
import { Contribution as DbContribution } from '@entity/Contribution'
|
import { Contribution as DbContribution } from '@entity/Contribution'
|
||||||
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
|
||||||
import { getUserCreation, isContributionValid } from './AdminResolver'
|
import { getUserCreation, validateContribution } from './util/creations'
|
||||||
import { Decay } from '@model/Decay'
|
import { Decay } from '@model/Decay'
|
||||||
import Decimal from 'decimal.js-light'
|
import Decimal from 'decimal.js-light'
|
||||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||||
|
import { ContributionCycleType } from '@enum/ContributionCycleType'
|
||||||
|
|
||||||
const QueryLinkResult = createUnionType({
|
const QueryLinkResult = createUnionType({
|
||||||
name: 'QueryLinkResult', // the name of the GraphQL union
|
name: 'QueryLinkResult', // the name of the GraphQL union
|
||||||
@ -176,7 +179,7 @@ export class TransactionLinkResolver {
|
|||||||
logger.info('redeem contribution link...')
|
logger.info('redeem contribution link...')
|
||||||
const queryRunner = getConnection().createQueryRunner()
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
await queryRunner.connect()
|
await queryRunner.connect()
|
||||||
await queryRunner.startTransaction('SERIALIZABLE')
|
await queryRunner.startTransaction('REPEATABLE READ')
|
||||||
try {
|
try {
|
||||||
const contributionLink = await queryRunner.manager
|
const contributionLink = await queryRunner.manager
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
@ -202,34 +205,65 @@ export class TransactionLinkResolver {
|
|||||||
throw new Error('Contribution link is depricated')
|
throw new Error('Contribution link is depricated')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (contributionLink.cycle !== 'ONCE') {
|
let alreadyRedeemed: DbContribution | undefined
|
||||||
logger.error('contribution link has unknown cycle', contributionLink.cycle)
|
switch (contributionLink.cycle) {
|
||||||
throw new Error('Contribution link has unknown cycle')
|
case ContributionCycleType.ONCE: {
|
||||||
}
|
alreadyRedeemed = await queryRunner.manager
|
||||||
// Test ONCE rule
|
.createQueryBuilder()
|
||||||
const alreadyRedeemed = await queryRunner.manager
|
.select('contribution')
|
||||||
.createQueryBuilder()
|
.from(DbContribution, 'contribution')
|
||||||
.select('contribution')
|
.where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', {
|
||||||
.from(DbContribution, 'contribution')
|
linkId: contributionLink.id,
|
||||||
.where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', {
|
id: user.id,
|
||||||
linkId: contributionLink.id,
|
})
|
||||||
id: user.id,
|
.getOne()
|
||||||
})
|
if (alreadyRedeemed) {
|
||||||
.getOne()
|
logger.error(
|
||||||
if (alreadyRedeemed) {
|
'contribution link with rule ONCE already redeemed by user with id',
|
||||||
logger.error('contribution link with rule ONCE already redeemed by user with id', user.id)
|
user.id,
|
||||||
throw new Error('Contribution link already redeemed')
|
)
|
||||||
|
throw new Error('Contribution link already redeemed')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case ContributionCycleType.DAILY: {
|
||||||
|
const start = new Date()
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
const end = new Date()
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
alreadyRedeemed = await queryRunner.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('contribution')
|
||||||
|
.from(DbContribution, 'contribution')
|
||||||
|
.where(
|
||||||
|
`contribution.contributionLinkId = :linkId AND contribution.userId = :id
|
||||||
|
AND Date(contribution.confirmedAt) BETWEEN :start AND :end`,
|
||||||
|
{
|
||||||
|
linkId: contributionLink.id,
|
||||||
|
id: user.id,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.getOne()
|
||||||
|
if (alreadyRedeemed) {
|
||||||
|
logger.error(
|
||||||
|
'contribution link with rule DAILY already redeemed by user with id',
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
throw new Error('Contribution link already redeemed today')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
logger.error('contribution link has unknown cycle', contributionLink.cycle)
|
||||||
|
throw new Error('Contribution link has unknown cycle')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const creations = await getUserCreation(user.id, false)
|
const creations = await getUserCreation(user.id, false)
|
||||||
logger.info('open creations', creations)
|
logger.info('open creations', creations)
|
||||||
if (!isContributionValid(creations, contributionLink.amount, now)) {
|
validateContribution(creations, contributionLink.amount, now)
|
||||||
logger.error(
|
|
||||||
'Amount of Contribution link exceeds available amount for this month',
|
|
||||||
contributionLink.amount,
|
|
||||||
)
|
|
||||||
throw new Error('Amount of Contribution link exceeds available amount')
|
|
||||||
}
|
|
||||||
const contribution = new DbContribution()
|
const contribution = new DbContribution()
|
||||||
contribution.userId = user.id
|
contribution.userId = user.id
|
||||||
contribution.createdAt = now
|
contribution.createdAt = now
|
||||||
@ -237,6 +271,9 @@ export class TransactionLinkResolver {
|
|||||||
contribution.memo = contributionLink.memo
|
contribution.memo = contributionLink.memo
|
||||||
contribution.amount = contributionLink.amount
|
contribution.amount = contributionLink.amount
|
||||||
contribution.contributionLinkId = contributionLink.id
|
contribution.contributionLinkId = contributionLink.id
|
||||||
|
contribution.contributionType = ContributionType.LINK
|
||||||
|
contribution.contributionStatus = ContributionStatus.CONFIRMED
|
||||||
|
|
||||||
await queryRunner.manager.insert(DbContribution, contribution)
|
await queryRunner.manager.insert(DbContribution, contribution)
|
||||||
|
|
||||||
const lastTransaction = await queryRunner.manager
|
const lastTransaction = await queryRunner.manager
|
||||||
@ -284,7 +321,10 @@ export class TransactionLinkResolver {
|
|||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
const transactionLink = await dbTransactionLink.findOneOrFail({ code })
|
const transactionLink = await dbTransactionLink.findOneOrFail({ code })
|
||||||
const linkedUser = await dbUser.findOneOrFail({ id: transactionLink.userId })
|
const linkedUser = await dbUser.findOneOrFail(
|
||||||
|
{ id: transactionLink.userId },
|
||||||
|
{ relations: ['emailContact'] },
|
||||||
|
)
|
||||||
|
|
||||||
if (user.id === linkedUser.id) {
|
if (user.id === linkedUser.id) {
|
||||||
throw new Error('Cannot redeem own transaction link.')
|
throw new Error('Cannot redeem own transaction link.')
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import CONFIG from '@/config'
|
|||||||
|
|
||||||
import { Context, getUser } from '@/server/context'
|
import { Context, getUser } from '@/server/context'
|
||||||
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
|
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
|
||||||
import { getCustomRepository, getConnection } from '@dbTools/typeorm'
|
import { getCustomRepository, getConnection, In } from '@dbTools/typeorm'
|
||||||
|
|
||||||
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
|
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
|
||||||
|
|
||||||
@ -34,9 +34,9 @@ import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualT
|
|||||||
import Decimal from 'decimal.js-light'
|
import Decimal from 'decimal.js-light'
|
||||||
|
|
||||||
import { BalanceResolver } from './BalanceResolver'
|
import { BalanceResolver } from './BalanceResolver'
|
||||||
|
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
|
||||||
const MEMO_MAX_CHARS = 255
|
import { findUserByEmail } from './UserResolver'
|
||||||
const MEMO_MIN_CHARS = 5
|
import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
|
||||||
|
|
||||||
export const executeTransaction = async (
|
export const executeTransaction = async (
|
||||||
amount: Decimal,
|
amount: Decimal,
|
||||||
@ -80,7 +80,7 @@ export const executeTransaction = async (
|
|||||||
|
|
||||||
const queryRunner = getConnection().createQueryRunner()
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
await queryRunner.connect()
|
await queryRunner.connect()
|
||||||
await queryRunner.startTransaction('READ UNCOMMITTED')
|
await queryRunner.startTransaction('REPEATABLE READ')
|
||||||
logger.debug(`open Transaction to write...`)
|
logger.debug(`open Transaction to write...`)
|
||||||
try {
|
try {
|
||||||
// transaction
|
// transaction
|
||||||
@ -150,12 +150,24 @@ export const executeTransaction = async (
|
|||||||
senderLastName: sender.lastName,
|
senderLastName: sender.lastName,
|
||||||
recipientFirstName: recipient.firstName,
|
recipientFirstName: recipient.firstName,
|
||||||
recipientLastName: recipient.lastName,
|
recipientLastName: recipient.lastName,
|
||||||
email: recipient.email,
|
email: recipient.emailContact.email,
|
||||||
senderEmail: sender.email,
|
senderEmail: sender.emailContact.email,
|
||||||
amount,
|
amount,
|
||||||
memo,
|
|
||||||
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
|
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
|
||||||
})
|
})
|
||||||
|
if (transactionLink) {
|
||||||
|
await sendTransactionLinkRedeemedEmail({
|
||||||
|
senderFirstName: recipient.firstName,
|
||||||
|
senderLastName: recipient.lastName,
|
||||||
|
recipientFirstName: sender.firstName,
|
||||||
|
recipientLastName: sender.lastName,
|
||||||
|
email: sender.emailContact.email,
|
||||||
|
senderEmail: recipient.emailContact.email,
|
||||||
|
amount,
|
||||||
|
memo,
|
||||||
|
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
|
||||||
|
})
|
||||||
|
}
|
||||||
logger.info(`finished executeTransaction successfully`)
|
logger.info(`finished executeTransaction successfully`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@ -173,7 +185,7 @@ export class TransactionResolver {
|
|||||||
const user = getUser(context)
|
const user = getUser(context)
|
||||||
|
|
||||||
logger.addContext('user', user.id)
|
logger.addContext('user', user.id)
|
||||||
logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.email})`)
|
logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.emailId})`)
|
||||||
|
|
||||||
// find current balance
|
// find current balance
|
||||||
const lastTransaction = await dbTransaction.findOne(
|
const lastTransaction = await dbTransaction.findOne(
|
||||||
@ -212,11 +224,11 @@ export class TransactionResolver {
|
|||||||
logger.debug(`involvedUserIds=${involvedUserIds}`)
|
logger.debug(`involvedUserIds=${involvedUserIds}`)
|
||||||
|
|
||||||
// We need to show the name for deleted users for old transactions
|
// We need to show the name for deleted users for old transactions
|
||||||
const involvedDbUsers = await dbUser
|
const involvedDbUsers = await dbUser.find({
|
||||||
.createQueryBuilder()
|
where: { id: In(involvedUserIds) },
|
||||||
.withDeleted()
|
withDeleted: true,
|
||||||
.where('id IN (:...userIds)', { userIds: involvedUserIds })
|
relations: ['emailContact'],
|
||||||
.getMany()
|
})
|
||||||
const involvedUsers = involvedDbUsers.map((u) => new User(u))
|
const involvedUsers = involvedDbUsers.map((u) => new User(u))
|
||||||
logger.debug(`involvedUsers=${involvedUsers}`)
|
logger.debug(`involvedUsers=${involvedUsers}`)
|
||||||
|
|
||||||
@ -295,16 +307,25 @@ export class TransactionResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validate recipient user
|
// validate recipient user
|
||||||
const recipientUser = await dbUser.findOne({ email: email }, { withDeleted: true })
|
const recipientUser = await findUserByEmail(email)
|
||||||
|
/*
|
||||||
|
const emailContact = await UserContact.findOne({ email }, { withDeleted: true })
|
||||||
|
if (!emailContact) {
|
||||||
|
logger.error(`Could not find UserContact with email: ${email}`)
|
||||||
|
throw new Error(`Could not find UserContact with email: ${email}`)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
// const recipientUser = await dbUser.findOne({ id: emailContact.userId })
|
||||||
if (!recipientUser) {
|
if (!recipientUser) {
|
||||||
logger.error(`recipient not known: email=${email}`)
|
logger.error(`unknown recipient to UserContact: email=${email}`)
|
||||||
throw new Error('recipient not known')
|
throw new Error('unknown recipient')
|
||||||
}
|
}
|
||||||
if (recipientUser.deletedAt) {
|
if (recipientUser.deletedAt) {
|
||||||
logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`)
|
logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`)
|
||||||
throw new Error('The recipient account was deleted')
|
throw new Error('The recipient account was deleted')
|
||||||
}
|
}
|
||||||
if (!recipientUser.emailChecked) {
|
const emailContact = recipientUser.emailContact
|
||||||
|
if (!emailContact.emailChecked) {
|
||||||
logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`)
|
logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`)
|
||||||
throw new Error('The recipient account is not activated')
|
throw new Error('The recipient account is not activated')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,21 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||||
|
|
||||||
import { testEnvironment, headerPushMock, resetToken, cleanDB, resetEntity } from '@test/helpers'
|
import { testEnvironment, headerPushMock, resetToken, cleanDB } from '@test/helpers'
|
||||||
import { userFactory } from '@/seeds/factory/user'
|
import { userFactory } from '@/seeds/factory/user'
|
||||||
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
||||||
import { createUser, setPassword, forgotPassword, updateUserInfos } from '@/seeds/graphql/mutations'
|
import {
|
||||||
import { login, logout, verifyLogin, queryOptIn } from '@/seeds/graphql/queries'
|
login,
|
||||||
|
logout,
|
||||||
|
createUser,
|
||||||
|
setPassword,
|
||||||
|
forgotPassword,
|
||||||
|
updateUserInfos,
|
||||||
|
createContribution,
|
||||||
|
confirmContribution,
|
||||||
|
} from '@/seeds/graphql/mutations'
|
||||||
|
import { verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries'
|
||||||
import { GraphQLError } from 'graphql'
|
import { GraphQLError } from 'graphql'
|
||||||
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
|
|
||||||
import { User } from '@entity/User'
|
import { User } from '@entity/User'
|
||||||
import CONFIG from '@/config'
|
import CONFIG from '@/config'
|
||||||
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
|
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
|
||||||
@ -15,11 +23,19 @@ import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegi
|
|||||||
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
|
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
|
||||||
import { printTimeDuration, activationLink } from './UserResolver'
|
import { printTimeDuration, activationLink } from './UserResolver'
|
||||||
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
|
import { contributionLinkFactory } from '@/seeds/factory/contributionLink'
|
||||||
// import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
|
import { transactionLinkFactory } from '@/seeds/factory/transactionLink'
|
||||||
import { ContributionLink } from '@model/ContributionLink'
|
import { ContributionLink } from '@model/ContributionLink'
|
||||||
// import { TransactionLink } from '@entity/TransactionLink'
|
import { TransactionLink } from '@entity/TransactionLink'
|
||||||
|
|
||||||
|
import { EventProtocolType } from '@/event/EventProtocolType'
|
||||||
|
import { EventProtocol } from '@entity/EventProtocol'
|
||||||
import { logger } from '@test/testSetup'
|
import { logger } from '@test/testSetup'
|
||||||
|
import { validate as validateUUID, version as versionUUID } from 'uuid'
|
||||||
|
import { peterLustig } from '@/seeds/users/peter-lustig'
|
||||||
|
import { UserContact } from '@entity/UserContact'
|
||||||
|
import { OptInType } from '../enum/OptInType'
|
||||||
|
import { UserContactType } from '../enum/UserContactType'
|
||||||
|
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
|
||||||
|
|
||||||
// import { klicktippSignIn } from '@/apis/KlicktippController'
|
// import { klicktippSignIn } from '@/apis/KlicktippController'
|
||||||
|
|
||||||
@ -80,7 +96,7 @@ describe('UserResolver', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result: any
|
let result: any
|
||||||
let emailOptIn: string
|
let emailVerificationCode: string
|
||||||
let user: User[]
|
let user: User[]
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@ -99,11 +115,11 @@ describe('UserResolver', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('valid input data', () => {
|
describe('valid input data', () => {
|
||||||
let loginEmailOptIn: LoginEmailOptIn[]
|
// let loginEmailOptIn: LoginEmailOptIn[]
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
user = await User.find()
|
user = await User.find({ relations: ['emailContact'] })
|
||||||
loginEmailOptIn = await LoginEmailOptIn.find()
|
// loginEmailOptIn = await LoginEmailOptIn.find()
|
||||||
emailOptIn = loginEmailOptIn[0].verificationCode.toString()
|
emailVerificationCode = user[0].emailContact.emailVerificationCode.toString()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('filling all tables', () => {
|
describe('filling all tables', () => {
|
||||||
@ -111,15 +127,18 @@ describe('UserResolver', () => {
|
|||||||
expect(user).toEqual([
|
expect(user).toEqual([
|
||||||
{
|
{
|
||||||
id: expect.any(Number),
|
id: expect.any(Number),
|
||||||
email: 'peter@lustig.de',
|
gradidoID: expect.any(String),
|
||||||
|
alias: null,
|
||||||
|
emailContact: expect.any(UserContact), // 'peter@lustig.de',
|
||||||
|
emailId: expect.any(Number),
|
||||||
firstName: 'Peter',
|
firstName: 'Peter',
|
||||||
lastName: 'Lustig',
|
lastName: 'Lustig',
|
||||||
password: '0',
|
password: '0',
|
||||||
pubKey: null,
|
pubKey: null,
|
||||||
privKey: null,
|
privKey: null,
|
||||||
emailHash: expect.any(Buffer),
|
// emailHash: expect.any(Buffer),
|
||||||
createdAt: expect.any(Date),
|
createdAt: expect.any(Date),
|
||||||
emailChecked: false,
|
// emailChecked: false,
|
||||||
passphrase: expect.any(String),
|
passphrase: expect.any(String),
|
||||||
language: 'de',
|
language: 'de',
|
||||||
isAdmin: null,
|
isAdmin: null,
|
||||||
@ -129,20 +148,27 @@ describe('UserResolver', () => {
|
|||||||
contributionLinkId: null,
|
contributionLinkId: null,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
const valUUID = validateUUID(user[0].gradidoID)
|
||||||
|
const verUUID = versionUUID(user[0].gradidoID)
|
||||||
|
expect(valUUID).toEqual(true)
|
||||||
|
expect(verUUID).toEqual(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('creates an email optin', () => {
|
it('creates an email contact', () => {
|
||||||
expect(loginEmailOptIn).toEqual([
|
expect(user[0].emailContact).toEqual({
|
||||||
{
|
id: expect.any(Number),
|
||||||
id: expect.any(Number),
|
type: UserContactType.USER_CONTACT_EMAIL,
|
||||||
userId: user[0].id,
|
userId: user[0].id,
|
||||||
verificationCode: expect.any(String),
|
email: 'peter@lustig.de',
|
||||||
emailOptInTypeId: 1,
|
emailChecked: false,
|
||||||
createdAt: expect.any(Date),
|
emailVerificationCode: expect.any(String),
|
||||||
resendCount: 0,
|
emailOptInTypeId: OptInType.EMAIL_OPT_IN_REGISTER,
|
||||||
updatedAt: expect.any(Date),
|
emailResendCount: 0,
|
||||||
},
|
phone: null,
|
||||||
])
|
createdAt: expect.any(Date),
|
||||||
|
deletedAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -151,7 +177,7 @@ describe('UserResolver', () => {
|
|||||||
it('sends an account activation email', () => {
|
it('sends an account activation email', () => {
|
||||||
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
|
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
|
||||||
/{optin}/g,
|
/{optin}/g,
|
||||||
emailOptIn,
|
emailVerificationCode,
|
||||||
).replace(/{code}/g, '')
|
).replace(/{code}/g, '')
|
||||||
expect(sendAccountActivationEmail).toBeCalledWith({
|
expect(sendAccountActivationEmail).toBeCalledWith({
|
||||||
link: activationLink,
|
link: activationLink,
|
||||||
@ -161,6 +187,15 @@ describe('UserResolver', () => {
|
|||||||
duration: expect.any(String),
|
duration: expect.any(String),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stores the send confirmation event in the database', () => {
|
||||||
|
expect(EventProtocol.find()).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: EventProtocolType.SEND_CONFIRMATION_EMAIL,
|
||||||
|
userId: user[0].id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('email already exists', () => {
|
describe('email already exists', () => {
|
||||||
@ -198,15 +233,15 @@ describe('UserResolver', () => {
|
|||||||
it('sets "de" as default language', async () => {
|
it('sets "de" as default language', async () => {
|
||||||
await mutate({
|
await mutate({
|
||||||
mutation: createUser,
|
mutation: createUser,
|
||||||
variables: { ...variables, email: 'bibi@bloxberg.de', language: 'es' },
|
variables: { ...variables, email: 'bibi@bloxberg.de', language: 'it' },
|
||||||
})
|
})
|
||||||
await expect(User.find()).resolves.toEqual(
|
await expect(
|
||||||
expect.arrayContaining([
|
UserContact.findOne({ email: 'bibi@bloxberg.de' }, { relations: ['user'] }),
|
||||||
expect.objectContaining({
|
).resolves.toEqual(
|
||||||
email: 'bibi@bloxberg.de',
|
expect.objectContaining({
|
||||||
language: 'de',
|
email: 'bibi@bloxberg.de',
|
||||||
}),
|
user: expect.objectContaining({ language: 'de' }),
|
||||||
]),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -217,10 +252,12 @@ describe('UserResolver', () => {
|
|||||||
mutation: createUser,
|
mutation: createUser,
|
||||||
variables: { ...variables, email: 'raeuber@hotzenplotz.de', publisherId: undefined },
|
variables: { ...variables, email: 'raeuber@hotzenplotz.de', publisherId: undefined },
|
||||||
})
|
})
|
||||||
await expect(User.find()).resolves.toEqual(
|
await expect(User.find({ relations: ['emailContact'] })).resolves.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
email: 'raeuber@hotzenplotz.de',
|
emailContact: expect.objectContaining({
|
||||||
|
email: 'raeuber@hotzenplotz.de',
|
||||||
|
}),
|
||||||
publisherId: null,
|
publisherId: null,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
@ -229,37 +266,157 @@ describe('UserResolver', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('redeem codes', () => {
|
describe('redeem codes', () => {
|
||||||
|
let result: any
|
||||||
|
let link: ContributionLink
|
||||||
|
|
||||||
describe('contribution link', () => {
|
describe('contribution link', () => {
|
||||||
let link: ContributionLink
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// activate account of admin Peter Lustig
|
// activate account of admin Peter Lustig
|
||||||
await mutate({
|
await mutate({
|
||||||
mutation: setPassword,
|
mutation: setPassword,
|
||||||
variables: { code: emailOptIn, password: 'Aa12345_' },
|
variables: { code: emailVerificationCode, password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// make Peter Lustig Admin
|
// make Peter Lustig Admin
|
||||||
const peter = await User.findOneOrFail({ id: user[0].id })
|
const peter = await User.findOneOrFail({ id: user[0].id })
|
||||||
peter.isAdmin = new Date()
|
peter.isAdmin = new Date()
|
||||||
await peter.save()
|
await peter.save()
|
||||||
|
|
||||||
|
// date statement
|
||||||
|
const actualDate = new Date()
|
||||||
|
const futureDate = new Date() // Create a future day from the executed day
|
||||||
|
futureDate.setDate(futureDate.getDate() + 1)
|
||||||
|
|
||||||
// factory logs in as Peter Lustig
|
// factory logs in as Peter Lustig
|
||||||
link = await contributionLinkFactory(testEnv, {
|
link = await contributionLinkFactory(testEnv, {
|
||||||
name: 'Dokumenta 2022',
|
name: 'Dokumenta 2022',
|
||||||
memo: 'Vielen Dank für deinen Besuch bei der Dokumenta 2022',
|
memo: 'Vielen Dank für deinen Besuch bei der Dokumenta 2022',
|
||||||
amount: 200,
|
amount: 200,
|
||||||
validFrom: new Date(2022, 5, 18),
|
validFrom: actualDate,
|
||||||
validTo: new Date(2022, 8, 25),
|
validTo: futureDate,
|
||||||
})
|
})
|
||||||
|
|
||||||
resetToken()
|
resetToken()
|
||||||
await mutate({
|
result = await mutate({
|
||||||
mutation: createUser,
|
mutation: createUser,
|
||||||
variables: { ...variables, email: 'ein@besucher.de', redeemCode: 'CL-' + link.code },
|
variables: { ...variables, email: 'ein@besucher.de', redeemCode: 'CL-' + link.code },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanDB()
|
||||||
|
})
|
||||||
|
|
||||||
it('sets the contribution link id', async () => {
|
it('sets the contribution link id', async () => {
|
||||||
await expect(User.findOne({ email: 'ein@besucher.de' })).resolves.toEqual(
|
await expect(
|
||||||
|
UserContact.findOne({ email: 'ein@besucher.de' }, { relations: ['user'] }),
|
||||||
|
).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
contributionLinkId: link.id,
|
user: expect.objectContaining({
|
||||||
|
contributionLinkId: link.id,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the account activated event in the database', () => {
|
||||||
|
expect(EventProtocol.find()).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: EventProtocolType.ACTIVATE_ACCOUNT,
|
||||||
|
userId: user[0].id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the redeem register event in the database', () => {
|
||||||
|
expect(EventProtocol.find()).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: EventProtocolType.REDEEM_REGISTER,
|
||||||
|
userId: result.data.createUser.id,
|
||||||
|
contributionId: link.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transaction link', () => {
|
||||||
|
let contribution: any
|
||||||
|
let bob: any
|
||||||
|
let transactionLink: TransactionLink
|
||||||
|
let newUser: any
|
||||||
|
|
||||||
|
const bobData = {
|
||||||
|
email: 'bob@baumeister.de',
|
||||||
|
password: 'Aa12345_',
|
||||||
|
publisherId: 1234,
|
||||||
|
}
|
||||||
|
|
||||||
|
const peterData = {
|
||||||
|
email: 'peter@lustig.de',
|
||||||
|
password: 'Aa12345_',
|
||||||
|
publisherId: 1234,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await userFactory(testEnv, peterLustig)
|
||||||
|
await userFactory(testEnv, bobBaumeister)
|
||||||
|
await mutate({ mutation: login, variables: bobData })
|
||||||
|
|
||||||
|
// create contribution as user bob
|
||||||
|
contribution = await mutate({
|
||||||
|
mutation: createContribution,
|
||||||
|
variables: { amount: 1000, memo: 'testing', creationDate: new Date().toISOString() },
|
||||||
|
})
|
||||||
|
|
||||||
|
// login as admin
|
||||||
|
await mutate({ mutation: login, variables: peterData })
|
||||||
|
|
||||||
|
// confirm the contribution
|
||||||
|
contribution = await mutate({
|
||||||
|
mutation: confirmContribution,
|
||||||
|
variables: { id: contribution.data.createContribution.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
// login as user bob
|
||||||
|
bob = await mutate({ mutation: login, variables: bobData })
|
||||||
|
|
||||||
|
// create transaction link
|
||||||
|
await transactionLinkFactory(testEnv, {
|
||||||
|
email: 'bob@baumeister.de',
|
||||||
|
amount: 19.99,
|
||||||
|
memo: `testing transaction link`,
|
||||||
|
})
|
||||||
|
|
||||||
|
transactionLink = await TransactionLink.findOneOrFail()
|
||||||
|
|
||||||
|
resetToken()
|
||||||
|
|
||||||
|
// create new user using transaction link of bob
|
||||||
|
newUser = await mutate({
|
||||||
|
mutation: createUser,
|
||||||
|
variables: {
|
||||||
|
...variables,
|
||||||
|
email: 'which@ever.de',
|
||||||
|
redeemCode: transactionLink.code,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets the referrer id to bob baumeister id', async () => {
|
||||||
|
await expect(
|
||||||
|
UserContact.findOne({ email: 'which@ever.de' }, { relations: ['user'] }),
|
||||||
|
).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
user: expect.objectContaining({ referrerId: bob.data.login.id }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores the redeem register event in the database', async () => {
|
||||||
|
await expect(EventProtocol.find()).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: EventProtocolType.REDEEM_REGISTER,
|
||||||
|
userId: newUser.data.createUser.id,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@ -274,7 +431,7 @@ describe('UserResolver', () => {
|
|||||||
email: 'peter@lustig.de',
|
email: 'peter@lustig.de',
|
||||||
amount: 19.99,
|
amount: 19.99,
|
||||||
memo: `Kein Trick, keine Zauberrei,
|
memo: `Kein Trick, keine Zauberrei,
|
||||||
bei Gradidio sei dabei!`,
|
bei Gradidio sei dabei!`,
|
||||||
})
|
})
|
||||||
const transactionLink = await TransactionLink.findOneOrFail()
|
const transactionLink = await TransactionLink.findOneOrFail()
|
||||||
resetToken()
|
resetToken()
|
||||||
@ -283,14 +440,14 @@ bei Gradidio sei dabei!`,
|
|||||||
variables: { ...variables, email: 'neuer@user.de', redeemCode: transactionLink.code },
|
variables: { ...variables, email: 'neuer@user.de', redeemCode: transactionLink.code },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sets the referrer id to Peter Lustigs id', async () => {
|
it('sets the referrer id to Peter Lustigs id', async () => {
|
||||||
await expect(User.findOne({ email: 'neuer@user.de' })).resolves.toEqual(expect.objectContaining({
|
await expect(User.findOne({ email: 'neuer@user.de' })).resolves.toEqual(expect.objectContaining({
|
||||||
referrerId: user[0].id,
|
referrerId: user[0].id,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
*/
|
*/
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -305,20 +462,23 @@ bei Gradidio sei dabei!`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result: any
|
let result: any
|
||||||
let emailOptIn: string
|
let emailVerificationCode: string
|
||||||
|
|
||||||
describe('valid optin code and valid password', () => {
|
describe('valid optin code and valid password', () => {
|
||||||
let newUser: any
|
let newUser: User
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await mutate({ mutation: createUser, variables: createUserVariables })
|
await mutate({ mutation: createUser, variables: createUserVariables })
|
||||||
const loginEmailOptIn = await LoginEmailOptIn.find()
|
const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email })
|
||||||
emailOptIn = loginEmailOptIn[0].verificationCode.toString()
|
emailVerificationCode = emailContact.emailVerificationCode.toString()
|
||||||
result = await mutate({
|
result = await mutate({
|
||||||
mutation: setPassword,
|
mutation: setPassword,
|
||||||
variables: { code: emailOptIn, password: 'Aa12345_' },
|
variables: { code: emailVerificationCode, password: 'Aa12345_' },
|
||||||
})
|
})
|
||||||
newUser = await User.find()
|
newUser = await User.findOneOrFail(
|
||||||
|
{ id: emailContact.userId },
|
||||||
|
{ relations: ['emailContact'] },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -326,11 +486,11 @@ bei Gradidio sei dabei!`,
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('sets email checked to true', () => {
|
it('sets email checked to true', () => {
|
||||||
expect(newUser[0].emailChecked).toBeTruthy()
|
expect(newUser.emailContact.emailChecked).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates the password', () => {
|
it('updates the password', () => {
|
||||||
expect(newUser[0].password).toEqual('3917921995996627700')
|
expect(newUser.password).toEqual('3917921995996627700')
|
||||||
})
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@ -352,11 +512,11 @@ bei Gradidio sei dabei!`,
|
|||||||
describe('no valid password', () => {
|
describe('no valid password', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await mutate({ mutation: createUser, variables: createUserVariables })
|
await mutate({ mutation: createUser, variables: createUserVariables })
|
||||||
const loginEmailOptIn = await LoginEmailOptIn.find()
|
const emailContact = await UserContact.findOneOrFail({ email: createUserVariables.email })
|
||||||
emailOptIn = loginEmailOptIn[0].verificationCode.toString()
|
emailVerificationCode = emailContact.emailVerificationCode.toString()
|
||||||
result = await mutate({
|
result = await mutate({
|
||||||
mutation: setPassword,
|
mutation: setPassword,
|
||||||
variables: { code: emailOptIn, password: 'not-valid' },
|
variables: { code: emailVerificationCode, password: 'not-valid' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -375,6 +535,10 @@ bei Gradidio sei dabei!`,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error thrown', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('Password entered is lexically invalid')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('no valid optin code', () => {
|
describe('no valid optin code', () => {
|
||||||
@ -397,6 +561,10 @@ bei Gradidio sei dabei!`,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('Could not login with emailVerificationCode')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -415,7 +583,8 @@ bei Gradidio sei dabei!`,
|
|||||||
|
|
||||||
describe('no users in database', () => {
|
describe('no users in database', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
result = await query({ query: login, variables })
|
jest.clearAllMocks()
|
||||||
|
result = await mutate({ mutation: login, variables })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws an error', () => {
|
it('throws an error', () => {
|
||||||
@ -425,12 +594,18 @@ bei Gradidio sei dabei!`,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(
|
||||||
|
'UserContact with email=bibi@bloxberg.de does not exists',
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('user is in database and correct login data', () => {
|
describe('user is in database and correct login data', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await userFactory(testEnv, bibiBloxberg)
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
result = await query({ query: login, variables })
|
result = await mutate({ mutation: login, variables })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -467,6 +642,7 @@ bei Gradidio sei dabei!`,
|
|||||||
describe('user is in database and wrong password', () => {
|
describe('user is in database and wrong password', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await userFactory(testEnv, bibiBloxberg)
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
|
result = await mutate({ mutation: login, variables: { ...variables, password: 'wrong' } })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -474,14 +650,16 @@ bei Gradidio sei dabei!`,
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns an error', () => {
|
it('returns an error', () => {
|
||||||
expect(
|
expect(result).toEqual(
|
||||||
query({ query: login, variables: { ...variables, password: 'wrong' } }),
|
|
||||||
).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
errors: [new GraphQLError('No user with this credentials')],
|
errors: [new GraphQLError('No user with this credentials')],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error thrown', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('The User has no valid credentials.')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -489,7 +667,7 @@ bei Gradidio sei dabei!`,
|
|||||||
describe('unauthenticated', () => {
|
describe('unauthenticated', () => {
|
||||||
it('throws an error', async () => {
|
it('throws an error', async () => {
|
||||||
resetToken()
|
resetToken()
|
||||||
await expect(query({ query: logout })).resolves.toEqual(
|
await expect(mutate({ mutation: logout })).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
errors: [new GraphQLError('401 Unauthorized')],
|
errors: [new GraphQLError('401 Unauthorized')],
|
||||||
}),
|
}),
|
||||||
@ -505,7 +683,7 @@ bei Gradidio sei dabei!`,
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await userFactory(testEnv, bibiBloxberg)
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({ query: login, variables })
|
await mutate({ mutation: login, variables })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -513,7 +691,7 @@ bei Gradidio sei dabei!`,
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('returns true', async () => {
|
it('returns true', async () => {
|
||||||
await expect(query({ query: logout })).resolves.toEqual(
|
await expect(mutate({ mutation: logout })).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
data: { logout: 'true' },
|
data: { logout: 'true' },
|
||||||
errors: undefined,
|
errors: undefined,
|
||||||
@ -554,13 +732,16 @@ bei Gradidio sei dabei!`,
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('authenticated', () => {
|
describe('authenticated', () => {
|
||||||
|
let user: User[]
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
email: 'bibi@bloxberg.de',
|
email: 'bibi@bloxberg.de',
|
||||||
password: 'Aa12345_',
|
password: 'Aa12345_',
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await query({ query: login, variables })
|
await mutate({ mutation: login, variables })
|
||||||
|
user = await User.find()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@ -587,52 +768,83 @@ bei Gradidio sei dabei!`,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('stores the login event in the database', () => {
|
||||||
|
expect(EventProtocol.find()).resolves.toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: EventProtocolType.LOGIN,
|
||||||
|
userId: user[0].id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('forgotPassword', () => {
|
describe('forgotPassword', () => {
|
||||||
const variables = { email: 'bibi@bloxberg.de' }
|
const variables = { email: 'bibi@bloxberg.de' }
|
||||||
|
const emailCodeRequestTime = CONFIG.EMAIL_CODE_REQUEST_TIME
|
||||||
|
|
||||||
describe('user is not in DB', () => {
|
describe('user is not in DB', () => {
|
||||||
it('returns true', async () => {
|
describe('duration not expired', () => {
|
||||||
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
it('returns true', async () => {
|
||||||
expect.objectContaining({
|
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
||||||
data: {
|
expect.objectContaining({
|
||||||
forgotPassword: true,
|
data: {
|
||||||
},
|
forgotPassword: true,
|
||||||
}),
|
},
|
||||||
)
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('user exists in DB', () => {
|
describe('user exists in DB', () => {
|
||||||
let result: any
|
let emailContact: UserContact
|
||||||
let loginEmailOptIn: LoginEmailOptIn[]
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await userFactory(testEnv, bibiBloxberg)
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
await resetEntity(LoginEmailOptIn)
|
// await resetEntity(LoginEmailOptIn)
|
||||||
result = await mutate({ mutation: forgotPassword, variables })
|
emailContact = await UserContact.findOneOrFail(variables)
|
||||||
loginEmailOptIn = await LoginEmailOptIn.find()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await cleanDB()
|
await cleanDB()
|
||||||
|
CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns true', async () => {
|
describe('duration not expired', () => {
|
||||||
await expect(result).toEqual(
|
it('returns true', async () => {
|
||||||
expect.objectContaining({
|
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
||||||
data: {
|
expect.objectContaining({
|
||||||
forgotPassword: true,
|
errors: [
|
||||||
},
|
new GraphQLError(
|
||||||
}),
|
`email already sent less than ${printTimeDuration(
|
||||||
)
|
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||||
|
)} minutes ago`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('duration reset to 0', () => {
|
||||||
|
it('returns true', async () => {
|
||||||
|
CONFIG.EMAIL_CODE_REQUEST_TIME = 0
|
||||||
|
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
forgotPassword: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sends reset password email', () => {
|
it('sends reset password email', () => {
|
||||||
expect(sendResetPasswordEmail).toBeCalledWith({
|
expect(sendResetPasswordEmail).toBeCalledWith({
|
||||||
link: activationLink(loginEmailOptIn[0]),
|
link: activationLink(emailContact.emailVerificationCode),
|
||||||
firstName: 'Bibi',
|
firstName: 'Bibi',
|
||||||
lastName: 'Bloxberg',
|
lastName: 'Bloxberg',
|
||||||
email: 'bibi@bloxberg.de',
|
email: 'bibi@bloxberg.de',
|
||||||
@ -642,22 +854,27 @@ bei Gradidio sei dabei!`,
|
|||||||
|
|
||||||
describe('request reset password again', () => {
|
describe('request reset password again', () => {
|
||||||
it('thows an error', async () => {
|
it('thows an error', async () => {
|
||||||
|
CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime
|
||||||
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')],
|
errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')],
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(`email already sent less than 10 minutes minutes ago`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('queryOptIn', () => {
|
describe('queryOptIn', () => {
|
||||||
let loginEmailOptIn: LoginEmailOptIn[]
|
let emailContact: UserContact
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await userFactory(testEnv, bibiBloxberg)
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
loginEmailOptIn = await LoginEmailOptIn.find()
|
emailContact = await UserContact.findOneOrFail({ email: bibiBloxberg.email })
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -672,8 +889,8 @@ bei Gradidio sei dabei!`,
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
errors: [
|
errors: [
|
||||||
// keep Whitspace in error message!
|
// keep Whitspace in error message!
|
||||||
new GraphQLError(`Could not find any entity of type "LoginEmailOptIn" matching: {
|
new GraphQLError(`Could not find any entity of type "UserContact" matching: {
|
||||||
"verificationCode": "not-valid"
|
"emailVerificationCode": "not-valid"
|
||||||
}`),
|
}`),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@ -686,7 +903,7 @@ bei Gradidio sei dabei!`,
|
|||||||
await expect(
|
await expect(
|
||||||
query({
|
query({
|
||||||
query: queryOptIn,
|
query: queryOptIn,
|
||||||
variables: { optIn: loginEmailOptIn[0].verificationCode.toString() },
|
variables: { optIn: emailContact.emailVerificationCode.toString() },
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual(
|
).resolves.toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -714,8 +931,8 @@ bei Gradidio sei dabei!`,
|
|||||||
describe('authenticated', () => {
|
describe('authenticated', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await userFactory(testEnv, bibiBloxberg)
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
await query({
|
await mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: {
|
variables: {
|
||||||
email: 'bibi@bloxberg.de',
|
email: 'bibi@bloxberg.de',
|
||||||
password: 'Aa12345_',
|
password: 'Aa12345_',
|
||||||
@ -758,7 +975,7 @@ bei Gradidio sei dabei!`,
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('language is not valid', () => {
|
describe('language is not valid', () => {
|
||||||
it('thows an error', async () => {
|
it('throws an error', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
mutate({
|
mutate({
|
||||||
mutation: updateUserInfos,
|
mutation: updateUserInfos,
|
||||||
@ -772,6 +989,10 @@ bei Gradidio sei dabei!`,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(`"not-valid" isn't a valid language`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('password', () => {
|
describe('password', () => {
|
||||||
@ -791,6 +1012,10 @@ bei Gradidio sei dabei!`,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith(`Old password is invalid`)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('invalid new password', () => {
|
describe('invalid new password', () => {
|
||||||
@ -813,6 +1038,10 @@ bei Gradidio sei dabei!`,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error found', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('newPassword does not fullfil the rules')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('correct old and new password', () => {
|
describe('correct old and new password', () => {
|
||||||
@ -832,10 +1061,10 @@ bei Gradidio sei dabei!`,
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can login wtih new password', async () => {
|
it('can login with new password', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
query({
|
mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: {
|
variables: {
|
||||||
email: 'bibi@bloxberg.de',
|
email: 'bibi@bloxberg.de',
|
||||||
password: 'Bb12345_',
|
password: 'Bb12345_',
|
||||||
@ -852,10 +1081,10 @@ bei Gradidio sei dabei!`,
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('cannot login wtih old password', async () => {
|
it('cannot login with old password', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
query({
|
mutate({
|
||||||
query: login,
|
mutation: login,
|
||||||
variables: {
|
variables: {
|
||||||
email: 'bibi@bloxberg.de',
|
email: 'bibi@bloxberg.de',
|
||||||
password: 'Aa12345_',
|
password: 'Aa12345_',
|
||||||
@ -867,10 +1096,59 @@ bei Gradidio sei dabei!`,
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('logs the error thrown', () => {
|
||||||
|
expect(logger.error).toBeCalledWith('The User has no valid credentials.')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('searchAdminUsers', () => {
|
||||||
|
describe('unauthenticated', () => {
|
||||||
|
it('throws an error', async () => {
|
||||||
|
resetToken()
|
||||||
|
await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
errors: [new GraphQLError('401 Unauthorized')],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('authenticated', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await userFactory(testEnv, bibiBloxberg)
|
||||||
|
await userFactory(testEnv, peterLustig)
|
||||||
|
await mutate({
|
||||||
|
mutation: login,
|
||||||
|
variables: {
|
||||||
|
email: 'bibi@bloxberg.de',
|
||||||
|
password: 'Aa12345_',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds peter@lustig.de', async () => {
|
||||||
|
await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: {
|
||||||
|
searchAdminUsers: {
|
||||||
|
userCount: 1,
|
||||||
|
userList: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
firstName: 'Peter',
|
||||||
|
lastName: 'Lustig',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('printTimeDuration', () => {
|
describe('printTimeDuration', () => {
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { backendLogger as logger } from '@/server/logger'
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
|
|
||||||
import { Context, getUser } from '@/server/context'
|
import { Context, getUser } from '@/server/context'
|
||||||
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
|
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
|
||||||
import { getConnection } from '@dbTools/typeorm'
|
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
|
||||||
import CONFIG from '@/config'
|
import CONFIG from '@/config'
|
||||||
import { User } from '@model/User'
|
import { User } from '@model/User'
|
||||||
import { User as DbUser } from '@entity/User'
|
import { User as DbUser } from '@entity/User'
|
||||||
|
import { UserContact as DbUserContact } from '@entity/UserContact'
|
||||||
import { communityDbUser } from '@/util/communityUser'
|
import { communityDbUser } from '@/util/communityUser'
|
||||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||||
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
|
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
|
||||||
@ -16,13 +16,29 @@ import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
|
|||||||
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
|
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
|
||||||
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
|
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
|
||||||
import { OptInType } from '@enum/OptInType'
|
import { OptInType } from '@enum/OptInType'
|
||||||
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
|
|
||||||
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
|
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
|
||||||
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
|
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
|
||||||
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail'
|
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail'
|
||||||
import { klicktippSignIn } from '@/apis/KlicktippController'
|
import { klicktippSignIn } from '@/apis/KlicktippController'
|
||||||
import { RIGHTS } from '@/auth/RIGHTS'
|
import { RIGHTS } from '@/auth/RIGHTS'
|
||||||
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
||||||
|
import { eventProtocol } from '@/event/EventProtocolEmitter'
|
||||||
|
import {
|
||||||
|
Event,
|
||||||
|
EventLogin,
|
||||||
|
EventRedeemRegister,
|
||||||
|
EventRegister,
|
||||||
|
EventSendAccountMultiRegistrationEmail,
|
||||||
|
EventSendConfirmationEmail,
|
||||||
|
EventActivateAccount,
|
||||||
|
} from '@/event/Event'
|
||||||
|
import { getUserCreation } from './util/creations'
|
||||||
|
import { UserContactType } from '../enum/UserContactType'
|
||||||
|
import { UserRepository } from '@/typeorm/repository/User'
|
||||||
|
import { SearchAdminUsersResult } from '@model/AdminUser'
|
||||||
|
import Paginated from '@arg/Paginated'
|
||||||
|
import { Order } from '@enum/Order'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const sodium = require('sodium-native')
|
const sodium = require('sodium-native')
|
||||||
@ -34,7 +50,7 @@ const isPassword = (password: string): boolean => {
|
|||||||
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
|
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANGUAGES = ['de', 'en']
|
const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl']
|
||||||
const DEFAULT_LANGUAGE = 'de'
|
const DEFAULT_LANGUAGE = 'de'
|
||||||
const isLanguage = (language: string): boolean => {
|
const isLanguage = (language: string): boolean => {
|
||||||
return LANGUAGES.includes(language)
|
return LANGUAGES.includes(language)
|
||||||
@ -132,6 +148,7 @@ const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[
|
|||||||
return [encryptionKeyHash, encryptionKey]
|
return [encryptionKeyHash, encryptionKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
const getEmailHash = (email: string): Buffer => {
|
const getEmailHash = (email: string): Buffer => {
|
||||||
logger.trace('getEmailHash...')
|
logger.trace('getEmailHash...')
|
||||||
const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES)
|
const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES)
|
||||||
@ -139,6 +156,7 @@ const getEmailHash = (email: string): Buffer => {
|
|||||||
logger.debug(`getEmailHash...successful: ${emailHash}`)
|
logger.debug(`getEmailHash...successful: ${emailHash}`)
|
||||||
return emailHash
|
return emailHash
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => {
|
const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => {
|
||||||
logger.trace('SecretKeyCryptographyEncrypt...')
|
logger.trace('SecretKeyCryptographyEncrypt...')
|
||||||
@ -163,6 +181,19 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
|
|||||||
return message
|
return message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newEmailContact = (email: string, userId: number): DbUserContact => {
|
||||||
|
logger.trace(`newEmailContact...`)
|
||||||
|
const emailContact = new DbUserContact()
|
||||||
|
emailContact.email = email
|
||||||
|
emailContact.userId = userId
|
||||||
|
emailContact.type = UserContactType.USER_CONTACT_EMAIL
|
||||||
|
emailContact.emailChecked = false
|
||||||
|
emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER
|
||||||
|
emailContact.emailVerificationCode = random(64)
|
||||||
|
logger.debug(`newEmailContact...successful: ${emailContact}`)
|
||||||
|
return emailContact
|
||||||
|
}
|
||||||
|
/*
|
||||||
const newEmailOptIn = (userId: number): LoginEmailOptIn => {
|
const newEmailOptIn = (userId: number): LoginEmailOptIn => {
|
||||||
logger.trace('newEmailOptIn...')
|
logger.trace('newEmailOptIn...')
|
||||||
const emailOptIn = new LoginEmailOptIn()
|
const emailOptIn = new LoginEmailOptIn()
|
||||||
@ -172,13 +203,14 @@ const newEmailOptIn = (userId: number): LoginEmailOptIn => {
|
|||||||
logger.debug(`newEmailOptIn...successful: ${emailOptIn}`)
|
logger.debug(`newEmailOptIn...successful: ${emailOptIn}`)
|
||||||
return emailOptIn
|
return emailOptIn
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
/*
|
||||||
// needed by AdminResolver
|
// needed by AdminResolver
|
||||||
// checks if given code exists and can be resent
|
// checks if given code exists and can be resent
|
||||||
// if optIn does not exits, it is created
|
// if optIn does not exits, it is created
|
||||||
export const checkOptInCode = async (
|
export const checkOptInCode = async (
|
||||||
optInCode: LoginEmailOptIn | undefined,
|
optInCode: LoginEmailOptIn | undefined,
|
||||||
userId: number,
|
user: DbUser,
|
||||||
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
|
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
|
||||||
): Promise<LoginEmailOptIn> => {
|
): Promise<LoginEmailOptIn> => {
|
||||||
logger.info(`checkOptInCode... ${optInCode}`)
|
logger.info(`checkOptInCode... ${optInCode}`)
|
||||||
@ -198,21 +230,71 @@ export const checkOptInCode = async (
|
|||||||
optInCode.updatedAt = new Date()
|
optInCode.updatedAt = new Date()
|
||||||
optInCode.resendCount++
|
optInCode.resendCount++
|
||||||
} else {
|
} else {
|
||||||
logger.trace('create new OptIn for userId=' + userId)
|
logger.trace('create new OptIn for userId=' + user.id)
|
||||||
optInCode = newEmailOptIn(userId)
|
optInCode = newEmailOptIn(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.emailChecked) {
|
||||||
|
optInCode.emailOptInTypeId = optInType
|
||||||
}
|
}
|
||||||
optInCode.emailOptInTypeId = optInType
|
|
||||||
await LoginEmailOptIn.save(optInCode).catch(() => {
|
await LoginEmailOptIn.save(optInCode).catch(() => {
|
||||||
logger.error('Unable to save optin code= ' + optInCode)
|
logger.error('Unable to save optin code= ' + optInCode)
|
||||||
throw new Error('Unable to save optin code.')
|
throw new Error('Unable to save optin code.')
|
||||||
})
|
})
|
||||||
logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${userId}`)
|
logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${user.id}`)
|
||||||
return optInCode
|
return optInCode
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
export const checkEmailVerificationCode = async (
|
||||||
|
emailContact: DbUserContact,
|
||||||
|
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
|
||||||
|
): Promise<DbUserContact> => {
|
||||||
|
logger.info(`checkEmailVerificationCode... ${emailContact}`)
|
||||||
|
if (emailContact.updatedAt) {
|
||||||
|
if (!canEmailResend(emailContact.updatedAt)) {
|
||||||
|
logger.error(
|
||||||
|
`email already sent less than ${printTimeDuration(
|
||||||
|
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||||
|
)} minutes ago`,
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`email already sent less than ${printTimeDuration(
|
||||||
|
CONFIG.EMAIL_CODE_REQUEST_TIME,
|
||||||
|
)} minutes ago`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
emailContact.updatedAt = new Date()
|
||||||
|
emailContact.emailResendCount++
|
||||||
|
} else {
|
||||||
|
logger.trace('create new EmailVerificationCode for userId=' + emailContact.userId)
|
||||||
|
emailContact.emailChecked = false
|
||||||
|
emailContact.emailVerificationCode = random(64)
|
||||||
|
}
|
||||||
|
emailContact.emailOptInTypeId = optInType
|
||||||
|
await DbUserContact.save(emailContact).catch(() => {
|
||||||
|
logger.error('Unable to save email verification code= ' + emailContact)
|
||||||
|
throw new Error('Unable to save email verification code.')
|
||||||
|
})
|
||||||
|
logger.debug(`checkEmailVerificationCode...successful: ${emailContact}`)
|
||||||
|
return emailContact
|
||||||
|
}
|
||||||
|
|
||||||
export const activationLink = (optInCode: LoginEmailOptIn): string => {
|
export const activationLink = (verificationCode: BigInt): string => {
|
||||||
logger.debug(`activationLink(${LoginEmailOptIn})...`)
|
logger.debug(`activationLink(${verificationCode})...`)
|
||||||
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString())
|
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, verificationCode.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGradidoID = async (): Promise<string> => {
|
||||||
|
let gradidoId: string
|
||||||
|
let countIds: number
|
||||||
|
do {
|
||||||
|
gradidoId = uuidv4()
|
||||||
|
countIds = await DbUser.count({ where: { gradidoID: gradidoId } })
|
||||||
|
if (countIds > 0) {
|
||||||
|
logger.info('Gradido-ID creation conflict...')
|
||||||
|
}
|
||||||
|
} while (countIds > 0)
|
||||||
|
return gradidoId
|
||||||
}
|
}
|
||||||
|
|
||||||
@Resolver()
|
@Resolver()
|
||||||
@ -224,7 +306,7 @@ export class UserResolver {
|
|||||||
logger.info('verifyLogin...')
|
logger.info('verifyLogin...')
|
||||||
// TODO refactor and do not have duplicate code with login(see below)
|
// TODO refactor and do not have duplicate code with login(see below)
|
||||||
const userEntity = getUser(context)
|
const userEntity = getUser(context)
|
||||||
const user = new User(userEntity)
|
const user = new User(userEntity, await getUserCreation(userEntity.id))
|
||||||
// user.pubkey = userEntity.pubKey.toString('hex')
|
// user.pubkey = userEntity.pubKey.toString('hex')
|
||||||
// Elopage Status & Stored PublisherId
|
// Elopage Status & Stored PublisherId
|
||||||
user.hasElopage = await this.hasElopage(context)
|
user.hasElopage = await this.hasElopage(context)
|
||||||
@ -234,7 +316,7 @@ export class UserResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Authorized([RIGHTS.LOGIN])
|
@Authorized([RIGHTS.LOGIN])
|
||||||
@Query(() => User)
|
@Mutation(() => User)
|
||||||
@UseMiddleware(klicktippNewsletterStateMiddleware)
|
@UseMiddleware(klicktippNewsletterStateMiddleware)
|
||||||
async login(
|
async login(
|
||||||
@Args() { email, password, publisherId }: UnsecureLoginArgs,
|
@Args() { email, password, publisherId }: UnsecureLoginArgs,
|
||||||
@ -242,15 +324,12 @@ export class UserResolver {
|
|||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
logger.info(`login with ${email}, ***, ${publisherId} ...`)
|
logger.info(`login with ${email}, ***, ${publisherId} ...`)
|
||||||
email = email.trim().toLowerCase()
|
email = email.trim().toLowerCase()
|
||||||
const dbUser = await DbUser.findOneOrFail({ email }, { withDeleted: true }).catch(() => {
|
const dbUser = await findUserByEmail(email)
|
||||||
logger.error(`User with email=${email} does not exists`)
|
|
||||||
throw new Error('No user with this credentials')
|
|
||||||
})
|
|
||||||
if (dbUser.deletedAt) {
|
if (dbUser.deletedAt) {
|
||||||
logger.error('The User was permanently deleted in database.')
|
logger.error('The User was permanently deleted in database.')
|
||||||
throw new Error('This user was permanently deleted. Contact support for questions.')
|
throw new Error('This user was permanently deleted. Contact support for questions.')
|
||||||
}
|
}
|
||||||
if (!dbUser.emailChecked) {
|
if (!dbUser.emailContact.emailChecked) {
|
||||||
logger.error('The Users email is not validate yet.')
|
logger.error('The Users email is not validate yet.')
|
||||||
throw new Error('User email not validated')
|
throw new Error('User email not validated')
|
||||||
}
|
}
|
||||||
@ -272,10 +351,10 @@ export class UserResolver {
|
|||||||
}
|
}
|
||||||
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
|
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
|
||||||
logger.addContext('user', dbUser.id)
|
logger.addContext('user', dbUser.id)
|
||||||
logger.debug('login credentials valid...')
|
logger.debug('validation of login credentials successful...')
|
||||||
|
|
||||||
const user = new User(dbUser)
|
const user = new User(dbUser, await getUserCreation(dbUser.id))
|
||||||
logger.debug('user=' + user)
|
logger.debug(`user= ${JSON.stringify(user, null, 2)}`)
|
||||||
|
|
||||||
// Elopage Status & Stored PublisherId
|
// Elopage Status & Stored PublisherId
|
||||||
user.hasElopage = await this.hasElopage({ ...context, user: dbUser })
|
user.hasElopage = await this.hasElopage({ ...context, user: dbUser })
|
||||||
@ -290,12 +369,15 @@ export class UserResolver {
|
|||||||
key: 'token',
|
key: 'token',
|
||||||
value: encode(dbUser.pubKey),
|
value: encode(dbUser.pubKey),
|
||||||
})
|
})
|
||||||
logger.info('successful Login:' + user)
|
const ev = new EventLogin()
|
||||||
|
ev.userId = user.id
|
||||||
|
eventProtocol.writeEvent(new Event().setEventLogin(ev))
|
||||||
|
logger.info(`successful Login: ${JSON.stringify(user, null, 2)}`)
|
||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
@Authorized([RIGHTS.LOGOUT])
|
@Authorized([RIGHTS.LOGOUT])
|
||||||
@Query(() => String)
|
@Mutation(() => String)
|
||||||
async logout(): Promise<boolean> {
|
async logout(): Promise<boolean> {
|
||||||
// TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token.
|
// TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token.
|
||||||
// Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login)
|
// Furthermore this hook can be useful for tracking user behaviour (did he logout or not? Warn him if he didn't on next login)
|
||||||
@ -314,64 +396,78 @@ export class UserResolver {
|
|||||||
@Args()
|
@Args()
|
||||||
{ email, firstName, lastName, language, publisherId, redeemCode = null }: CreateUserArgs,
|
{ email, firstName, lastName, language, publisherId, redeemCode = null }: CreateUserArgs,
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
|
logger.addContext('user', 'unknown')
|
||||||
logger.info(
|
logger.info(
|
||||||
`createUser(email=${email}, firstName=${firstName}, lastName=${lastName}, language=${language}, publisherId=${publisherId}, redeemCode =${redeemCode})`,
|
`createUser(email=${email}, firstName=${firstName}, lastName=${lastName}, language=${language}, publisherId=${publisherId}, redeemCode =${redeemCode})`,
|
||||||
)
|
)
|
||||||
// TODO: wrong default value (should be null), how does graphql work here? Is it an required field?
|
// TODO: wrong default value (should be null), how does graphql work here? Is it an required field?
|
||||||
// default int publisher_id = 0;
|
// default int publisher_id = 0;
|
||||||
|
const event = new Event()
|
||||||
|
|
||||||
// Validate Language (no throw)
|
// Validate Language (no throw)
|
||||||
if (!language || !isLanguage(language)) {
|
if (!language || !isLanguage(language)) {
|
||||||
language = DEFAULT_LANGUAGE
|
language = DEFAULT_LANGUAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email unique
|
// check if user with email still exists?
|
||||||
email = email.trim().toLowerCase()
|
email = email.trim().toLowerCase()
|
||||||
// TODO we cannot use repository.count(), since it does not allow to specify if you want to include the soft deletes
|
if (await checkEmailExists(email)) {
|
||||||
const userFound = await DbUser.findOne({ email }, { withDeleted: true })
|
const foundUser = await findUserByEmail(email)
|
||||||
logger.info(`DbUser.findOne(email=${email}) = ${userFound}`)
|
logger.info(`DbUser.findOne(email=${email}) = ${foundUser}`)
|
||||||
|
|
||||||
if (userFound) {
|
if (foundUser) {
|
||||||
logger.info('User already exists with this email=' + email)
|
// ATTENTION: this logger-message will be exactly expected during tests
|
||||||
// TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent.
|
logger.info(`User already exists with this email=${email}`)
|
||||||
|
// TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent.
|
||||||
|
|
||||||
const user = new User(communityDbUser)
|
const user = new User(communityDbUser)
|
||||||
user.id = sodium.randombytes_random() % (2048 * 16) // TODO: for a better faking derive id from email so that it will be always the same id when the same email comes in?
|
user.id = sodium.randombytes_random() % (2048 * 16) // TODO: for a better faking derive id from email so that it will be always the same id when the same email comes in?
|
||||||
user.email = email
|
user.gradidoID = uuidv4()
|
||||||
user.firstName = firstName
|
user.email = email
|
||||||
user.lastName = lastName
|
user.firstName = firstName
|
||||||
user.language = language
|
user.lastName = lastName
|
||||||
user.publisherId = publisherId
|
user.language = language
|
||||||
logger.debug('partly faked user=' + user)
|
user.publisherId = publisherId
|
||||||
|
logger.debug('partly faked user=' + user)
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const emailSent = await sendAccountMultiRegistrationEmail({
|
const emailSent = await sendAccountMultiRegistrationEmail({
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
})
|
})
|
||||||
logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`)
|
const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail()
|
||||||
/* uncomment this, when you need the activation link on the console */
|
eventSendAccountMultiRegistrationEmail.userId = foundUser.id
|
||||||
// In case EMails are disabled log the activation link for the user
|
eventProtocol.writeEvent(
|
||||||
if (!emailSent) {
|
event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail),
|
||||||
logger.debug(`Email not send!`)
|
)
|
||||||
|
logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`)
|
||||||
|
/* uncomment this, when you need the activation link on the console */
|
||||||
|
// In case EMails are disabled log the activation link for the user
|
||||||
|
if (!emailSent) {
|
||||||
|
logger.debug(`Email not send!`)
|
||||||
|
}
|
||||||
|
logger.info('createUser() faked and send multi registration mail...')
|
||||||
|
|
||||||
|
return user
|
||||||
}
|
}
|
||||||
logger.info('createUser() faked and send multi registration mail...')
|
|
||||||
|
|
||||||
return user
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const passphrase = PassphraseGenerate()
|
const passphrase = PassphraseGenerate()
|
||||||
// const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
|
// const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
|
||||||
// const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
|
// const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
|
||||||
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
|
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
|
||||||
const emailHash = getEmailHash(email)
|
// const emailHash = getEmailHash(email)
|
||||||
|
const gradidoID = await newGradidoID()
|
||||||
|
|
||||||
const dbUser = new DbUser()
|
const eventRegister = new EventRegister()
|
||||||
dbUser.email = email
|
const eventRedeemRegister = new EventRedeemRegister()
|
||||||
|
const eventSendConfirmEmail = new EventSendConfirmationEmail()
|
||||||
|
|
||||||
|
let dbUser = new DbUser()
|
||||||
|
dbUser.gradidoID = gradidoID
|
||||||
dbUser.firstName = firstName
|
dbUser.firstName = firstName
|
||||||
dbUser.lastName = lastName
|
dbUser.lastName = lastName
|
||||||
dbUser.emailHash = emailHash
|
|
||||||
dbUser.language = language
|
dbUser.language = language
|
||||||
dbUser.publisherId = publisherId
|
dbUser.publisherId = publisherId
|
||||||
dbUser.passphrase = passphrase.join(' ')
|
dbUser.passphrase = passphrase.join(' ')
|
||||||
@ -384,12 +480,14 @@ export class UserResolver {
|
|||||||
logger.info('redeemCode found contributionLink=' + contributionLink)
|
logger.info('redeemCode found contributionLink=' + contributionLink)
|
||||||
if (contributionLink) {
|
if (contributionLink) {
|
||||||
dbUser.contributionLinkId = contributionLink.id
|
dbUser.contributionLinkId = contributionLink.id
|
||||||
|
eventRedeemRegister.contributionId = contributionLink.id
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const transactionLink = await dbTransactionLink.findOne({ code: redeemCode })
|
const transactionLink = await dbTransactionLink.findOne({ code: redeemCode })
|
||||||
logger.info('redeemCode found transactionLink=' + transactionLink)
|
logger.info('redeemCode found transactionLink=' + transactionLink)
|
||||||
if (transactionLink) {
|
if (transactionLink) {
|
||||||
dbUser.referrerId = transactionLink.userId
|
dbUser.referrerId = transactionLink.userId
|
||||||
|
eventRedeemRegister.transactionId = transactionLink.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -402,22 +500,36 @@ export class UserResolver {
|
|||||||
|
|
||||||
const queryRunner = getConnection().createQueryRunner()
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
await queryRunner.connect()
|
await queryRunner.connect()
|
||||||
await queryRunner.startTransaction('READ UNCOMMITTED')
|
await queryRunner.startTransaction('REPEATABLE READ')
|
||||||
try {
|
try {
|
||||||
await queryRunner.manager.save(dbUser).catch((error) => {
|
dbUser = await queryRunner.manager.save(dbUser).catch((error) => {
|
||||||
logger.error('Error while saving dbUser', error)
|
logger.error('Error while saving dbUser', error)
|
||||||
throw new Error('error saving user')
|
throw new Error('error saving user')
|
||||||
})
|
})
|
||||||
|
let emailContact = newEmailContact(email, dbUser.id)
|
||||||
|
emailContact = await queryRunner.manager.save(emailContact).catch((error) => {
|
||||||
|
logger.error('Error while saving emailContact', error)
|
||||||
|
throw new Error('error saving email user contact')
|
||||||
|
})
|
||||||
|
|
||||||
|
dbUser.emailContact = emailContact
|
||||||
|
dbUser.emailId = emailContact.id
|
||||||
|
await queryRunner.manager.save(dbUser).catch((error) => {
|
||||||
|
logger.error('Error while updating dbUser', error)
|
||||||
|
throw new Error('error updating user')
|
||||||
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
const emailOptIn = newEmailOptIn(dbUser.id)
|
const emailOptIn = newEmailOptIn(dbUser.id)
|
||||||
await queryRunner.manager.save(emailOptIn).catch((error) => {
|
await queryRunner.manager.save(emailOptIn).catch((error) => {
|
||||||
logger.error('Error while saving emailOptIn', error)
|
logger.error('Error while saving emailOptIn', error)
|
||||||
throw new Error('error saving email opt in')
|
throw new Error('error saving email opt in')
|
||||||
})
|
})
|
||||||
|
*/
|
||||||
|
|
||||||
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
|
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
|
||||||
/{optin}/g,
|
/{optin}/g,
|
||||||
emailOptIn.verificationCode.toString(),
|
emailContact.emailVerificationCode.toString(),
|
||||||
).replace(/{code}/g, redeemCode ? '/' + redeemCode : '')
|
).replace(/{code}/g, redeemCode ? '/' + redeemCode : '')
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
@ -429,13 +541,15 @@ export class UserResolver {
|
|||||||
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
|
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
|
||||||
})
|
})
|
||||||
logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`)
|
logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`)
|
||||||
/* uncomment this, when you need the activation link on the console */
|
eventSendConfirmEmail.userId = dbUser.id
|
||||||
// In case EMails are disabled log the activation link for the user
|
eventProtocol.writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmEmail))
|
||||||
|
|
||||||
if (!emailSent) {
|
if (!emailSent) {
|
||||||
logger.debug(`Account confirmation link: ${activationLink}`)
|
logger.debug(`Account confirmation link: ${activationLink}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await queryRunner.commitTransaction()
|
await queryRunner.commitTransaction()
|
||||||
|
logger.addContext('user', dbUser.id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(`error during create user with ${e}`)
|
logger.error(`error during create user with ${e}`)
|
||||||
await queryRunner.rollbackTransaction()
|
await queryRunner.rollbackTransaction()
|
||||||
@ -445,30 +559,46 @@ export class UserResolver {
|
|||||||
}
|
}
|
||||||
logger.info('createUser() successful...')
|
logger.info('createUser() successful...')
|
||||||
|
|
||||||
|
if (redeemCode) {
|
||||||
|
eventRedeemRegister.userId = dbUser.id
|
||||||
|
await eventProtocol.writeEvent(event.setEventRedeemRegister(eventRedeemRegister))
|
||||||
|
} else {
|
||||||
|
eventRegister.userId = dbUser.id
|
||||||
|
await eventProtocol.writeEvent(event.setEventRegister(eventRegister))
|
||||||
|
}
|
||||||
|
|
||||||
return new User(dbUser)
|
return new User(dbUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL])
|
@Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL])
|
||||||
@Mutation(() => Boolean)
|
@Mutation(() => Boolean)
|
||||||
async forgotPassword(@Arg('email') email: string): Promise<boolean> {
|
async forgotPassword(@Arg('email') email: string): Promise<boolean> {
|
||||||
|
logger.addContext('user', 'unknown')
|
||||||
logger.info(`forgotPassword(${email})...`)
|
logger.info(`forgotPassword(${email})...`)
|
||||||
email = email.trim().toLowerCase()
|
email = email.trim().toLowerCase()
|
||||||
const user = await DbUser.findOne({ email })
|
const user = await findUserByEmail(email).catch(() => {
|
||||||
|
logger.warn(`fail on find UserContact per ${email}`)
|
||||||
|
})
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.warn(`no user found with ${email}`)
|
logger.warn(`no user found with ${email}`)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// can be both types: REGISTER and RESET_PASSWORD
|
// can be both types: REGISTER and RESET_PASSWORD
|
||||||
let optInCode = await LoginEmailOptIn.findOne({
|
// let optInCode = await LoginEmailOptIn.findOne({
|
||||||
userId: user.id,
|
// userId: user.id,
|
||||||
})
|
// })
|
||||||
|
// let optInCode = user.emailContact.emailVerificationCode
|
||||||
|
const dbUserContact = await checkEmailVerificationCode(
|
||||||
|
user.emailContact,
|
||||||
|
OptInType.EMAIL_OPT_IN_RESET_PASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
optInCode = await checkOptInCode(optInCode, user.id, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
|
// optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
|
||||||
logger.info(`optInCode for ${email}=${optInCode}`)
|
logger.info(`optInCode for ${email}=${dbUserContact}`)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const emailSent = await sendResetPasswordEmailMailer({
|
const emailSent = await sendResetPasswordEmailMailer({
|
||||||
link: activationLink(optInCode),
|
link: activationLink(dbUserContact.emailVerificationCode),
|
||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
email,
|
email,
|
||||||
@ -478,7 +608,7 @@ export class UserResolver {
|
|||||||
/* uncomment this, when you need the activation link on the console */
|
/* uncomment this, when you need the activation link on the console */
|
||||||
// In case EMails are disabled log the activation link for the user
|
// In case EMails are disabled log the activation link for the user
|
||||||
if (!emailSent) {
|
if (!emailSent) {
|
||||||
logger.debug(`Reset password link: ${activationLink(optInCode)}`)
|
logger.debug(`Reset password link: ${activationLink(dbUserContact.emailVerificationCode)}`)
|
||||||
}
|
}
|
||||||
logger.info(`forgotPassword(${email}) successful...`)
|
logger.info(`forgotPassword(${email}) successful...`)
|
||||||
|
|
||||||
@ -494,19 +624,29 @@ export class UserResolver {
|
|||||||
logger.info(`setPassword(${code}, ***)...`)
|
logger.info(`setPassword(${code}, ***)...`)
|
||||||
// Validate Password
|
// Validate Password
|
||||||
if (!isPassword(password)) {
|
if (!isPassword(password)) {
|
||||||
|
logger.error('Password entered is lexically invalid')
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
|
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load code
|
// Load code
|
||||||
|
/*
|
||||||
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => {
|
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => {
|
||||||
logger.error('Could not login with emailVerificationCode')
|
logger.error('Could not login with emailVerificationCode')
|
||||||
throw new Error('Could not login with emailVerificationCode')
|
throw new Error('Could not login with emailVerificationCode')
|
||||||
})
|
})
|
||||||
logger.debug('optInCode loaded...')
|
*/
|
||||||
|
const userContact = await DbUserContact.findOneOrFail(
|
||||||
|
{ emailVerificationCode: code },
|
||||||
|
{ relations: ['user'] },
|
||||||
|
).catch(() => {
|
||||||
|
logger.error('Could not login with emailVerificationCode')
|
||||||
|
throw new Error('Could not login with emailVerificationCode')
|
||||||
|
})
|
||||||
|
logger.debug('userContact loaded...')
|
||||||
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
||||||
if (!isOptInValid(optInCode)) {
|
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
||||||
)
|
)
|
||||||
@ -514,14 +654,11 @@ export class UserResolver {
|
|||||||
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
logger.debug('optInCode is valid...')
|
logger.debug('EmailVerificationCode is valid...')
|
||||||
|
|
||||||
// load user
|
// load user
|
||||||
const user = await DbUser.findOneOrFail({ id: optInCode.userId }).catch(() => {
|
const user = userContact.user
|
||||||
logger.error('Could not find corresponding Login User')
|
logger.debug('user with EmailVerificationCode found...')
|
||||||
throw new Error('Could not find corresponding Login User')
|
|
||||||
})
|
|
||||||
logger.debug('user with optInCode found...')
|
|
||||||
|
|
||||||
// Generate Passphrase if needed
|
// Generate Passphrase if needed
|
||||||
if (!user.passphrase) {
|
if (!user.passphrase) {
|
||||||
@ -541,10 +678,10 @@ export class UserResolver {
|
|||||||
logger.debug('Passphrase is valid...')
|
logger.debug('Passphrase is valid...')
|
||||||
|
|
||||||
// Activate EMail
|
// Activate EMail
|
||||||
user.emailChecked = true
|
userContact.emailChecked = true
|
||||||
|
|
||||||
// Update Password
|
// Update Password
|
||||||
const passwordHash = SecretKeyCryptographyCreateKey(user.email, password) // return short and long hash
|
const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash
|
||||||
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
|
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
|
||||||
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
|
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
|
||||||
user.password = passwordHash[0].readBigUInt64LE() // using the shorthash
|
user.password = passwordHash[0].readBigUInt64LE() // using the shorthash
|
||||||
@ -554,7 +691,9 @@ export class UserResolver {
|
|||||||
|
|
||||||
const queryRunner = getConnection().createQueryRunner()
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
await queryRunner.connect()
|
await queryRunner.connect()
|
||||||
await queryRunner.startTransaction('READ UNCOMMITTED')
|
await queryRunner.startTransaction('REPEATABLE READ')
|
||||||
|
|
||||||
|
const event = new Event()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save user
|
// Save user
|
||||||
@ -562,12 +701,21 @@ export class UserResolver {
|
|||||||
logger.error('error saving user: ' + error)
|
logger.error('error saving user: ' + error)
|
||||||
throw new Error('error saving user: ' + error)
|
throw new Error('error saving user: ' + error)
|
||||||
})
|
})
|
||||||
|
// Save userContact
|
||||||
|
await queryRunner.manager.save(userContact).catch((error) => {
|
||||||
|
logger.error('error saving userContact: ' + error)
|
||||||
|
throw new Error('error saving userContact: ' + error)
|
||||||
|
})
|
||||||
|
|
||||||
await queryRunner.commitTransaction()
|
await queryRunner.commitTransaction()
|
||||||
logger.info('User data written successfully...')
|
logger.info('User and UserContact data written successfully...')
|
||||||
|
|
||||||
|
const eventActivateAccount = new EventActivateAccount()
|
||||||
|
eventActivateAccount.userId = user.id
|
||||||
|
eventProtocol.writeEvent(event.setEventActivateAccount(eventActivateAccount))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await queryRunner.rollbackTransaction()
|
await queryRunner.rollbackTransaction()
|
||||||
logger.error('Error on writing User data:' + e)
|
logger.error('Error on writing User and UserContact data:' + e)
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
await queryRunner.release()
|
await queryRunner.release()
|
||||||
@ -575,11 +723,11 @@ export class UserResolver {
|
|||||||
|
|
||||||
// Sign into Klicktipp
|
// Sign into Klicktipp
|
||||||
// TODO do we always signUp the user? How to handle things with old users?
|
// TODO do we always signUp the user? How to handle things with old users?
|
||||||
if (optInCode.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) {
|
if (userContact.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) {
|
||||||
try {
|
try {
|
||||||
await klicktippSignIn(user.email, user.language, user.firstName, user.lastName)
|
await klicktippSignIn(userContact.email, user.language, user.firstName, user.lastName)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`klicktippSignIn(${user.email}, ${user.language}, ${user.firstName}, ${user.lastName})`,
|
`klicktippSignIn(${userContact.email}, ${user.language}, ${user.firstName}, ${user.lastName})`,
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('Error subscribe to klicktipp:' + e)
|
logger.error('Error subscribe to klicktipp:' + e)
|
||||||
@ -598,10 +746,10 @@ export class UserResolver {
|
|||||||
@Query(() => Boolean)
|
@Query(() => Boolean)
|
||||||
async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> {
|
async queryOptIn(@Arg('optIn') optIn: string): Promise<boolean> {
|
||||||
logger.info(`queryOptIn(${optIn})...`)
|
logger.info(`queryOptIn(${optIn})...`)
|
||||||
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn })
|
const userContact = await DbUserContact.findOneOrFail({ emailVerificationCode: optIn })
|
||||||
logger.debug(`found optInCode=${optInCode}`)
|
logger.debug(`found optInCode=${userContact}`)
|
||||||
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
|
||||||
if (!isOptInValid(optInCode)) {
|
if (!isEmailVerificationCodeValid(userContact.updatedAt)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
|
||||||
)
|
)
|
||||||
@ -649,7 +797,10 @@ export class UserResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: This had some error cases defined - like missing private key. This is no longer checked.
|
// TODO: This had some error cases defined - like missing private key. This is no longer checked.
|
||||||
const oldPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, password)
|
const oldPasswordHash = SecretKeyCryptographyCreateKey(
|
||||||
|
userEntity.emailContact.email,
|
||||||
|
password,
|
||||||
|
)
|
||||||
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) {
|
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) {
|
||||||
logger.error(`Old password is invalid`)
|
logger.error(`Old password is invalid`)
|
||||||
throw new Error(`Old password is invalid`)
|
throw new Error(`Old password is invalid`)
|
||||||
@ -657,7 +808,10 @@ export class UserResolver {
|
|||||||
|
|
||||||
const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1])
|
const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1])
|
||||||
logger.debug('oldPassword decrypted...')
|
logger.debug('oldPassword decrypted...')
|
||||||
const newPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, passwordNew) // return short and long hash
|
const newPasswordHash = SecretKeyCryptographyCreateKey(
|
||||||
|
userEntity.emailContact.email,
|
||||||
|
passwordNew,
|
||||||
|
) // return short and long hash
|
||||||
logger.debug('newPasswordHash created...')
|
logger.debug('newPasswordHash created...')
|
||||||
const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1])
|
const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1])
|
||||||
logger.debug('PrivateKey encrypted...')
|
logger.debug('PrivateKey encrypted...')
|
||||||
@ -669,10 +823,11 @@ export class UserResolver {
|
|||||||
|
|
||||||
const queryRunner = getConnection().createQueryRunner()
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
await queryRunner.connect()
|
await queryRunner.connect()
|
||||||
await queryRunner.startTransaction('READ UNCOMMITTED')
|
await queryRunner.startTransaction('REPEATABLE READ')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await queryRunner.manager.save(userEntity).catch((error) => {
|
await queryRunner.manager.save(userEntity).catch((error) => {
|
||||||
|
logger.error('error saving user: ' + error)
|
||||||
throw new Error('error saving user: ' + error)
|
throw new Error('error saving user: ' + error)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -693,30 +848,95 @@ export class UserResolver {
|
|||||||
@Query(() => Boolean)
|
@Query(() => Boolean)
|
||||||
async hasElopage(@Ctx() context: Context): Promise<boolean> {
|
async hasElopage(@Ctx() context: Context): Promise<boolean> {
|
||||||
logger.info(`hasElopage()...`)
|
logger.info(`hasElopage()...`)
|
||||||
const userEntity = context.user
|
const userEntity = getUser(context)
|
||||||
if (!userEntity) {
|
const elopageBuys = hasElopageBuys(userEntity.emailContact.email)
|
||||||
logger.info('missing context.user for EloPage-check')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const elopageBuys = hasElopageBuys(userEntity.email)
|
|
||||||
logger.debug(`has ElopageBuys = ${elopageBuys}`)
|
logger.debug(`has ElopageBuys = ${elopageBuys}`)
|
||||||
return elopageBuys
|
return elopageBuys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Authorized([RIGHTS.SEARCH_ADMIN_USERS])
|
||||||
|
@Query(() => SearchAdminUsersResult)
|
||||||
|
async searchAdminUsers(
|
||||||
|
@Args()
|
||||||
|
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
|
||||||
|
): Promise<SearchAdminUsersResult> {
|
||||||
|
const userRepository = getCustomRepository(UserRepository)
|
||||||
|
|
||||||
|
const [users, count] = await userRepository.findAndCount({
|
||||||
|
where: {
|
||||||
|
isAdmin: Not(IsNull()),
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
createdAt: order,
|
||||||
|
},
|
||||||
|
skip: (currentPage - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
userCount: count,
|
||||||
|
userList: users.map((user) => {
|
||||||
|
return {
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function findUserByEmail(email: string): Promise<DbUser> {
|
||||||
|
const dbUserContact = await DbUserContact.findOneOrFail(
|
||||||
|
{ email: email },
|
||||||
|
{ withDeleted: true, relations: ['user'] },
|
||||||
|
).catch(() => {
|
||||||
|
logger.error(`UserContact with email=${email} does not exists`)
|
||||||
|
throw new Error('No user with this credentials')
|
||||||
|
})
|
||||||
|
const dbUser = dbUserContact.user
|
||||||
|
dbUser.emailContact = dbUserContact
|
||||||
|
return dbUser
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkEmailExists(email: string): Promise<boolean> {
|
||||||
|
const userContact = await DbUserContact.findOne({ email: email }, { withDeleted: true })
|
||||||
|
if (userContact) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {
|
const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {
|
||||||
const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime()
|
const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime()
|
||||||
// time is given in minutes
|
// time is given in minutes
|
||||||
return timeElapsed <= duration * 60 * 1000
|
return timeElapsed <= duration * 60 * 1000
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
const isTimeExpired = (updatedAt: Date, duration: number): boolean => {
|
||||||
|
const timeElapsed = Date.now() - new Date(updatedAt).getTime()
|
||||||
|
// time is given in minutes
|
||||||
|
return timeElapsed <= duration * 60 * 1000
|
||||||
|
}
|
||||||
|
/*
|
||||||
const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
|
const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
|
||||||
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
|
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
const isEmailVerificationCodeValid = (updatedAt: Date | null): boolean => {
|
||||||
|
if (updatedAt == null) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
|
||||||
|
}
|
||||||
|
/*
|
||||||
const canResendOptIn = (optIn: LoginEmailOptIn): boolean => {
|
const canResendOptIn = (optIn: LoginEmailOptIn): boolean => {
|
||||||
return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME)
|
return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
const canEmailResend = (updatedAt: Date): boolean => {
|
||||||
|
return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME)
|
||||||
|
}
|
||||||
|
|
||||||
const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => {
|
const getTimeDurationObject = (time: number): { hours?: number; minutes: number } => {
|
||||||
if (time > 60) {
|
if (time > 60) {
|
||||||
|
|||||||
12
backend/src/graphql/resolver/const/const.ts
Normal file
12
backend/src/graphql/resolver/const/const.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
|
||||||
|
export const MAX_CREATION_AMOUNT = new Decimal(1000)
|
||||||
|
export const FULL_CREATION_AVAILABLE = [
|
||||||
|
MAX_CREATION_AMOUNT,
|
||||||
|
MAX_CREATION_AMOUNT,
|
||||||
|
MAX_CREATION_AMOUNT,
|
||||||
|
]
|
||||||
|
export const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100
|
||||||
|
export const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5
|
||||||
|
export const MEMO_MAX_CHARS = 255
|
||||||
|
export const MEMO_MIN_CHARS = 5
|
||||||
140
backend/src/graphql/resolver/util/creations.ts
Normal file
140
backend/src/graphql/resolver/util/creations.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
|
||||||
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
|
import { getConnection } from '@dbTools/typeorm'
|
||||||
|
import { Contribution } from '@entity/Contribution'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { FULL_CREATION_AVAILABLE, MAX_CREATION_AMOUNT } from '../const/const'
|
||||||
|
|
||||||
|
interface CreationMap {
|
||||||
|
id: number
|
||||||
|
creations: Decimal[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateContribution = (
|
||||||
|
creations: Decimal[],
|
||||||
|
amount: Decimal,
|
||||||
|
creationDate: Date,
|
||||||
|
): void => {
|
||||||
|
logger.trace('isContributionValid: ', creations, amount, creationDate)
|
||||||
|
const index = getCreationIndex(creationDate.getMonth())
|
||||||
|
|
||||||
|
if (index < 0) {
|
||||||
|
logger.error(
|
||||||
|
'No information for available creations with the given creationDate=',
|
||||||
|
creationDate.toString(),
|
||||||
|
)
|
||||||
|
throw new Error('No information for available creations for the given date')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount.greaterThan(creations[index].toString())) {
|
||||||
|
logger.error(
|
||||||
|
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUserCreations = async (
|
||||||
|
ids: number[],
|
||||||
|
includePending = true,
|
||||||
|
): Promise<CreationMap[]> => {
|
||||||
|
logger.trace('getUserCreations:', ids, includePending)
|
||||||
|
const months = getCreationMonths()
|
||||||
|
logger.trace('getUserCreations months', months)
|
||||||
|
|
||||||
|
const queryRunner = getConnection().createQueryRunner()
|
||||||
|
await queryRunner.connect()
|
||||||
|
|
||||||
|
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
|
||||||
|
logger.trace('getUserCreations dateFilter=', dateFilter)
|
||||||
|
|
||||||
|
const unionString = includePending
|
||||||
|
? `
|
||||||
|
UNION
|
||||||
|
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions
|
||||||
|
WHERE user_id IN (${ids.toString()})
|
||||||
|
AND contribution_date >= ${dateFilter}
|
||||||
|
AND confirmed_at IS NULL AND deleted_at IS NULL`
|
||||||
|
: ''
|
||||||
|
logger.trace('getUserCreations unionString=', unionString)
|
||||||
|
|
||||||
|
const unionQuery = await queryRunner.manager.query(`
|
||||||
|
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM
|
||||||
|
(SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions
|
||||||
|
WHERE user_id IN (${ids.toString()})
|
||||||
|
AND type_id = ${TransactionTypeId.CREATION}
|
||||||
|
AND creation_date >= ${dateFilter}
|
||||||
|
${unionString}) AS result
|
||||||
|
GROUP BY month, userId
|
||||||
|
ORDER BY date DESC
|
||||||
|
`)
|
||||||
|
logger.trace('getUserCreations unionQuery=', unionQuery)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => {
|
||||||
|
logger.trace('getUserCreation', id, includePending)
|
||||||
|
const creations = await getUserCreations([id], includePending)
|
||||||
|
logger.trace('getUserCreation creations=', creations)
|
||||||
|
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCreationMonths = (): number[] => {
|
||||||
|
const now = new Date(Date.now())
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCreationIndex = (month: number): number => {
|
||||||
|
return getCreationMonths().findIndex((el) => el === month + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isStartEndDateValid = (
|
||||||
|
startDate: string | null | undefined,
|
||||||
|
endDate: string | null | undefined,
|
||||||
|
): void => {
|
||||||
|
if (!startDate) {
|
||||||
|
logger.error('Start-Date is not initialized. A Start-Date must be set!')
|
||||||
|
throw new Error('Start-Date is not initialized. A Start-Date must be set!')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endDate) {
|
||||||
|
logger.error('End-Date is not initialized. An End-Date must be set!')
|
||||||
|
throw new Error('End-Date is not initialized. An End-Date must be set!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if endDate is before startDate
|
||||||
|
if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) {
|
||||||
|
logger.error(`The value of validFrom must before or equals the validTo!`)
|
||||||
|
throw new Error(`The value of validFrom must before or equals the validTo!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => {
|
||||||
|
const index = getCreationIndex(contribution.contributionDate.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(contribution.amount.toString())
|
||||||
|
return creations
|
||||||
|
}
|
||||||
40
backend/src/mailer/sendAddedContributionMessageEmail.test.ts
Normal file
40
backend/src/mailer/sendAddedContributionMessageEmail.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { sendAddedContributionMessageEmail } from './sendAddedContributionMessageEmail'
|
||||||
|
import { sendEMail } from './sendEMail'
|
||||||
|
|
||||||
|
jest.mock('./sendEMail', () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
sendEMail: jest.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendAddedContributionMessageEmail', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sendAddedContributionMessageEmail({
|
||||||
|
senderFirstName: 'Peter',
|
||||||
|
senderLastName: 'Lustig',
|
||||||
|
recipientFirstName: 'Bibi',
|
||||||
|
recipientLastName: 'Bloxberg',
|
||||||
|
recipientEmail: 'bibi@bloxberg.de',
|
||||||
|
senderEmail: 'peter@lustig.de',
|
||||||
|
contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
|
||||||
|
message: 'Was für ein Besen ist es geworden?',
|
||||||
|
overviewURL: 'http://localhost/overview',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls sendEMail', () => {
|
||||||
|
expect(sendEMail).toBeCalledWith({
|
||||||
|
to: `Bibi Bloxberg <bibi@bloxberg.de>`,
|
||||||
|
subject: 'Gradido Frage zur Schöpfung',
|
||||||
|
text:
|
||||||
|
expect.stringContaining('Hallo Bibi Bloxberg') &&
|
||||||
|
expect.stringContaining('Peter Lustig') &&
|
||||||
|
expect.stringContaining(
|
||||||
|
'Du hast soeben zu deinem eingereichten Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" eine Rückfrage von Peter Lustig erhalten.',
|
||||||
|
) &&
|
||||||
|
expect.stringContaining('Was für ein Besen ist es geworden?') &&
|
||||||
|
expect.stringContaining('http://localhost/overview'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
26
backend/src/mailer/sendAddedContributionMessageEmail.ts
Normal file
26
backend/src/mailer/sendAddedContributionMessageEmail.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
|
import { sendEMail } from './sendEMail'
|
||||||
|
import { contributionMessageReceived } from './text/contributionMessageReceived'
|
||||||
|
|
||||||
|
export const sendAddedContributionMessageEmail = (data: {
|
||||||
|
senderFirstName: string
|
||||||
|
senderLastName: string
|
||||||
|
recipientFirstName: string
|
||||||
|
recipientLastName: string
|
||||||
|
recipientEmail: string
|
||||||
|
senderEmail: string
|
||||||
|
contributionMemo: string
|
||||||
|
message: string
|
||||||
|
overviewURL: string
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
logger.info(
|
||||||
|
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>,
|
||||||
|
subject=${contributionMessageReceived.de.subject},
|
||||||
|
text=${contributionMessageReceived.de.text(data)}`,
|
||||||
|
)
|
||||||
|
return sendEMail({
|
||||||
|
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`,
|
||||||
|
subject: contributionMessageReceived.de.subject,
|
||||||
|
text: contributionMessageReceived.de.text(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
39
backend/src/mailer/sendContributionConfirmedEmail.test.ts
Normal file
39
backend/src/mailer/sendContributionConfirmedEmail.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { sendContributionConfirmedEmail } from './sendContributionConfirmedEmail'
|
||||||
|
import { sendEMail } from './sendEMail'
|
||||||
|
|
||||||
|
jest.mock('./sendEMail', () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
sendEMail: jest.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendContributionConfirmedEmail', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sendContributionConfirmedEmail({
|
||||||
|
senderFirstName: 'Peter',
|
||||||
|
senderLastName: 'Lustig',
|
||||||
|
recipientFirstName: 'Bibi',
|
||||||
|
recipientLastName: 'Bloxberg',
|
||||||
|
recipientEmail: 'bibi@bloxberg.de',
|
||||||
|
contributionMemo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
|
||||||
|
contributionAmount: new Decimal(200.0),
|
||||||
|
overviewURL: 'http://localhost/overview',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls sendEMail', () => {
|
||||||
|
expect(sendEMail).toBeCalledWith({
|
||||||
|
to: 'Bibi Bloxberg <bibi@bloxberg.de>',
|
||||||
|
subject: 'Schöpfung wurde bestätigt',
|
||||||
|
text:
|
||||||
|
expect.stringContaining('Hallo Bibi Bloxberg') &&
|
||||||
|
expect.stringContaining(
|
||||||
|
'Dein Gradido Schöpfungsantrag "Vielen herzlichen Dank für den neuen Hexenbesen!" wurde soeben bestätigt.',
|
||||||
|
) &&
|
||||||
|
expect.stringContaining('Betrag: 200,00 GDD') &&
|
||||||
|
expect.stringContaining('Link zu deinem Konto: http://localhost/overview'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
26
backend/src/mailer/sendContributionConfirmedEmail.ts
Normal file
26
backend/src/mailer/sendContributionConfirmedEmail.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { sendEMail } from './sendEMail'
|
||||||
|
import { contributionConfirmed } from './text/contributionConfirmed'
|
||||||
|
|
||||||
|
export const sendContributionConfirmedEmail = (data: {
|
||||||
|
senderFirstName: string
|
||||||
|
senderLastName: string
|
||||||
|
recipientFirstName: string
|
||||||
|
recipientLastName: string
|
||||||
|
recipientEmail: string
|
||||||
|
contributionMemo: string
|
||||||
|
contributionAmount: Decimal
|
||||||
|
overviewURL: string
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
logger.info(
|
||||||
|
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>,
|
||||||
|
subject=${contributionConfirmed.de.subject},
|
||||||
|
text=${contributionConfirmed.de.text(data)}`,
|
||||||
|
)
|
||||||
|
return sendEMail({
|
||||||
|
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.recipientEmail}>`,
|
||||||
|
subject: contributionConfirmed.de.subject,
|
||||||
|
text: contributionConfirmed.de.text(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@ describe('sendEMail', () => {
|
|||||||
let result: boolean
|
let result: boolean
|
||||||
describe('config email is false', () => {
|
describe('config email is false', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
result = await sendEMail({
|
result = await sendEMail({
|
||||||
to: 'receiver@mail.org',
|
to: 'receiver@mail.org',
|
||||||
cc: 'support@gradido.net',
|
cc: 'support@gradido.net',
|
||||||
@ -48,6 +49,7 @@ describe('sendEMail', () => {
|
|||||||
|
|
||||||
describe('config email is true', () => {
|
describe('config email is true', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
CONFIG.EMAIL = true
|
CONFIG.EMAIL = true
|
||||||
result = await sendEMail({
|
result = await sendEMail({
|
||||||
to: 'receiver@mail.org',
|
to: 'receiver@mail.org',
|
||||||
@ -84,4 +86,28 @@ describe('sendEMail', () => {
|
|||||||
expect(result).toBeTruthy()
|
expect(result).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('with email EMAIL_TEST_MODUS true', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
jest.clearAllMocks()
|
||||||
|
CONFIG.EMAIL = true
|
||||||
|
CONFIG.EMAIL_TEST_MODUS = true
|
||||||
|
result = await sendEMail({
|
||||||
|
to: 'receiver@mail.org',
|
||||||
|
cc: 'support@gradido.net',
|
||||||
|
subject: 'Subject',
|
||||||
|
text: 'Text text text',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls sendMail of transporter with faked to', () => {
|
||||||
|
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
|
||||||
|
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
|
||||||
|
to: CONFIG.EMAIL_TEST_RECEIVER,
|
||||||
|
cc: 'support@gradido.net',
|
||||||
|
subject: 'Subject',
|
||||||
|
text: 'Text text text',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -19,6 +19,12 @@ export const sendEMail = async (emailDef: {
|
|||||||
logger.info(`Emails are disabled via config...`)
|
logger.info(`Emails are disabled via config...`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (CONFIG.EMAIL_TEST_MODUS) {
|
||||||
|
logger.info(
|
||||||
|
`Testmodus=ON: change receiver from ${emailDef.to} to ${CONFIG.EMAIL_TEST_RECEIVER}`,
|
||||||
|
)
|
||||||
|
emailDef.to = CONFIG.EMAIL_TEST_RECEIVER
|
||||||
|
}
|
||||||
const transporter = createTransport({
|
const transporter = createTransport({
|
||||||
host: CONFIG.EMAIL_SMTP_URL,
|
host: CONFIG.EMAIL_SMTP_URL,
|
||||||
port: Number(CONFIG.EMAIL_SMTP_PORT),
|
port: Number(CONFIG.EMAIL_SMTP_PORT),
|
||||||
|
|||||||
44
backend/src/mailer/sendTransactionLinkRedeemed.test.ts
Normal file
44
backend/src/mailer/sendTransactionLinkRedeemed.test.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { sendEMail } from './sendEMail'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { sendTransactionLinkRedeemedEmail } from './sendTransactionLinkRedeemed'
|
||||||
|
|
||||||
|
jest.mock('./sendEMail', () => {
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
sendEMail: jest.fn(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendTransactionLinkRedeemedEmail', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sendTransactionLinkRedeemedEmail({
|
||||||
|
email: 'bibi@bloxberg.de',
|
||||||
|
senderFirstName: 'Peter',
|
||||||
|
senderLastName: 'Lustig',
|
||||||
|
recipientFirstName: 'Bibi',
|
||||||
|
recipientLastName: 'Bloxberg',
|
||||||
|
senderEmail: 'peter@lustig.de',
|
||||||
|
amount: new Decimal(42.0),
|
||||||
|
memo: 'Vielen Dank dass Du dabei bist',
|
||||||
|
overviewURL: 'http://localhost/overview',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls sendEMail', () => {
|
||||||
|
expect(sendEMail).toBeCalledWith({
|
||||||
|
to: `Bibi Bloxberg <bibi@bloxberg.de>`,
|
||||||
|
subject: 'Gradido-Link wurde eingelöst',
|
||||||
|
text:
|
||||||
|
expect.stringContaining('Hallo Bibi Bloxberg') &&
|
||||||
|
expect.stringContaining(
|
||||||
|
'Peter Lustig (peter@lustig.de) hat soeben deinen Link eingelöst.',
|
||||||
|
) &&
|
||||||
|
expect.stringContaining('Betrag: 42,00 GDD,') &&
|
||||||
|
expect.stringContaining('Memo: Vielen Dank dass Du dabei bist') &&
|
||||||
|
expect.stringContaining(
|
||||||
|
'Details zur Transaktion findest du in deinem Gradido-Konto: http://localhost/overview',
|
||||||
|
) &&
|
||||||
|
expect.stringContaining('Bitte antworte nicht auf diese E-Mail!'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
28
backend/src/mailer/sendTransactionLinkRedeemed.ts
Normal file
28
backend/src/mailer/sendTransactionLinkRedeemed.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { backendLogger as logger } from '@/server/logger'
|
||||||
|
import Decimal from 'decimal.js-light'
|
||||||
|
import { sendEMail } from './sendEMail'
|
||||||
|
import { transactionLinkRedeemed } from './text/transactionLinkRedeemed'
|
||||||
|
|
||||||
|
export const sendTransactionLinkRedeemedEmail = (data: {
|
||||||
|
email: string
|
||||||
|
senderFirstName: string
|
||||||
|
senderLastName: string
|
||||||
|
recipientFirstName: string
|
||||||
|
recipientLastName: string
|
||||||
|
senderEmail: string
|
||||||
|
amount: Decimal
|
||||||
|
memo: string
|
||||||
|
overviewURL: string
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
logger.info(
|
||||||
|
`sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName},
|
||||||
|
<${data.email}>,
|
||||||
|
subject=${transactionLinkRedeemed.de.subject},
|
||||||
|
text=${transactionLinkRedeemed.de.text(data)}`,
|
||||||
|
)
|
||||||
|
return sendEMail({
|
||||||
|
to: `${data.recipientFirstName} ${data.recipientLastName} <${data.email}>`,
|
||||||
|
subject: transactionLinkRedeemed.de.subject,
|
||||||
|
text: transactionLinkRedeemed.de.text(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -19,7 +19,6 @@ describe('sendTransactionReceivedEmail', () => {
|
|||||||
email: 'peter@lustig.de',
|
email: 'peter@lustig.de',
|
||||||
senderEmail: 'bibi@bloxberg.de',
|
senderEmail: 'bibi@bloxberg.de',
|
||||||
amount: new Decimal(42.0),
|
amount: new Decimal(42.0),
|
||||||
memo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
|
|
||||||
overviewURL: 'http://localhost/overview',
|
overviewURL: 'http://localhost/overview',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -33,7 +32,6 @@ describe('sendTransactionReceivedEmail', () => {
|
|||||||
expect.stringContaining('42,00 GDD') &&
|
expect.stringContaining('42,00 GDD') &&
|
||||||
expect.stringContaining('Bibi Bloxberg') &&
|
expect.stringContaining('Bibi Bloxberg') &&
|
||||||
expect.stringContaining('(bibi@bloxberg.de)') &&
|
expect.stringContaining('(bibi@bloxberg.de)') &&
|
||||||
expect.stringContaining('Vielen herzlichen Dank für den neuen Hexenbesen!') &&
|
|
||||||
expect.stringContaining('http://localhost/overview'),
|
expect.stringContaining('http://localhost/overview'),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -11,7 +11,6 @@ export const sendTransactionReceivedEmail = (data: {
|
|||||||
email: string
|
email: string
|
||||||
senderEmail: string
|
senderEmail: string
|
||||||
amount: Decimal
|
amount: Decimal
|
||||||
memo: string
|
|
||||||
overviewURL: string
|
overviewURL: string
|
||||||
}): Promise<boolean> => {
|
}): Promise<boolean> => {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user