diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3d046fcda..bb2441701 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -528,7 +528,7 @@ jobs: report_name: Coverage Backend type: lcov result_path: ./backend/coverage/lcov.info - min_coverage: 58 + min_coverage: 66 token: ${{ github.token }} ########################################################################## diff --git a/.gitignore b/.gitignore index 32e11f545..08ccd2b30 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,9 @@ package-lock.json /deployment/bare_metal/nginx/update-page/updating.html /deployment/bare_metal/log /deployment/bare_metal/backup + +# Node Version Manager configuration file +.nvmrc + +# Apple macOS folder attribute file +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8b71dac..48eeff9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,44 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [1.8.3](https://github.com/gradido/gradido/compare/1.8.2...1.8.3) + +- Checkbox [`#1894`](https://github.com/gradido/gradido/pull/1894) +- fix: Count Deprecated Links as Well [`#1892`](https://github.com/gradido/gradido/pull/1892) + +#### [1.8.2](https://github.com/gradido/gradido/compare/1.8.1...1.8.2) + +> 12 May 2022 + +- Release 1.8.2 [`#1890`](https://github.com/gradido/gradido/pull/1890) +- Update README.md [`#1878`](https://github.com/gradido/gradido/pull/1878) +- fix: Unique Previous Column in Transactions Table [`#1879`](https://github.com/gradido/gradido/pull/1879) +- fix: Up and Down Migrations for Older SQL Versions [`#1861`](https://github.com/gradido/gradido/pull/1861) +- 🍰 Refactor THX Page – 1. Step [`#1856`](https://github.com/gradido/gradido/pull/1856) +- Create LICENSE [`#1803`](https://github.com/gradido/gradido/pull/1803) +- docu: Update Deployment Documentation [`#1864`](https://github.com/gradido/gradido/pull/1864) +- fix: Loading Transaction Links after Reopening Link List [`#1863`](https://github.com/gradido/gradido/pull/1863) +- 🍰 Add NVM Config Files To '.gitignore' [`#1846`](https://github.com/gradido/gradido/pull/1846) + +#### [1.8.1](https://github.com/gradido/gradido/compare/1.8.0...1.8.1) + +> 28 April 2022 + +- v1.8.1 [`#1855`](https://github.com/gradido/gradido/pull/1855) +- 1851 integrate and test the behaviour of clipboard polyfill [`#1853`](https://github.com/gradido/gradido/pull/1853) +- fix: Deprecated Warning from Faker on Seeding [`#1854`](https://github.com/gradido/gradido/pull/1854) +- feat: Test Admin Resolver [`#1848`](https://github.com/gradido/gradido/pull/1848) +- devops: Disable DB Reset on Stage 1 [`#1852`](https://github.com/gradido/gradido/pull/1852) +- 🍰 Refactor notActivated and isDeleted [`#1791`](https://github.com/gradido/gradido/pull/1791) +- devops: Disable Send Email on Seeding [`#1849`](https://github.com/gradido/gradido/pull/1849) +- fix: Confirm Creation with Decimal [`#1838`](https://github.com/gradido/gradido/pull/1838) +- error message by no navigator.clipbord function [`#1841`](https://github.com/gradido/gradido/pull/1841) + #### [1.8.0](https://github.com/gradido/gradido/compare/1.7.1...1.8.0) +> 25 April 2022 + +- v1.8.0 [`#1836`](https://github.com/gradido/gradido/pull/1836) - Fix: database version requirement for backend corrected [`#1835`](https://github.com/gradido/gradido/pull/1835) - feat: More User Resolver Tests [`#1827`](https://github.com/gradido/gradido/pull/1827) - fix: Round Decay with Tranasction Links [`#1834`](https://github.com/gradido/gradido/pull/1834) diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 8bfff0f25..289a39109 100644 --- a/README.md +++ b/README.md @@ -131,3 +131,15 @@ Note: The Changelog will be regenerated with all tags on release on the external ## Useful Links - [Gradido.net](https://gradido.net/) + + +## Attributions + +![browserstack_logo-freelogovectors net_](https://user-images.githubusercontent.com/1324583/167782608-0e4db0d4-3d34-45fb-ab06-344aa5e5ef4b.png) + +Browser compatibility testing with [BrowserStack](https://www.browserstack.com/). + + +## License +See the [LICENSE](LICENSE.md) file for license rights and limitations (Apache-2.0 license). + diff --git a/admin/.gitignore b/admin/.gitignore index 6bb62f667..a67d270bc 100644 --- a/admin/.gitignore +++ b/admin/.gitignore @@ -9,4 +9,4 @@ dist/ coverage/ # emacs -*~ \ No newline at end of file +*~ diff --git a/admin/.prettierrc.js b/admin/.prettierrc.js index e88113754..bc1d767d7 100644 --- a/admin/.prettierrc.js +++ b/admin/.prettierrc.js @@ -4,5 +4,6 @@ module.exports = { singleQuote: true, trailingComma: "all", tabWidth: 2, - bracketSpacing: true + bracketSpacing: true, + endOfLine: "auto", }; diff --git a/admin/package.json b/admin/package.json index c5b2e60f5..57711b8be 100644 --- a/admin/package.json +++ b/admin/package.json @@ -3,7 +3,7 @@ "description": "Administraion Interface for Gradido", "main": "index.js", "author": "Moriz Wahl", - "version": "1.8.0", + "version": "1.8.3", "license": "MIT", "private": false, "scripts": { diff --git a/admin/src/components/CreationFormular.spec.js b/admin/src/components/CreationFormular.spec.js index 083b7ca67..08ec71bdc 100644 --- a/admin/src/components/CreationFormular.spec.js +++ b/admin/src/components/CreationFormular.spec.js @@ -24,12 +24,6 @@ const mocks = { }, $store: { commit: stateCommitMock, - state: { - moderator: { - id: 0, - name: 'test moderator', - }, - }, }, } @@ -122,7 +116,6 @@ describe('CreationFormular', () => { creationDate: getCreationDate(2), amount: 90, memo: 'Test create coins', - moderator: 0, }, }), ) @@ -370,14 +363,12 @@ describe('CreationFormular', () => { creationDate: getCreationDate(1), amount: 200, memo: 'Test mass create coins', - moderator: 0, }, { email: 'bibi@bloxberg.de', creationDate: getCreationDate(1), amount: 200, memo: 'Test mass create coins', - moderator: 0, }, ], }, diff --git a/admin/src/components/CreationFormular.vue b/admin/src/components/CreationFormular.vue index cd4de5fd6..cdcd6ef1d 100644 --- a/admin/src/components/CreationFormular.vue +++ b/admin/src/components/CreationFormular.vue @@ -154,7 +154,6 @@ export default { creationDate: this.selected.date, amount: Number(this.value), memo: this.text, - moderator: Number(this.$store.state.moderator.id), }) }) this.$apollo @@ -188,7 +187,6 @@ export default { creationDate: this.selected.date, amount: Number(this.value), memo: this.text, - moderator: Number(this.$store.state.moderator.id), } this.$apollo .mutate({ diff --git a/admin/src/components/EditCreationFormular.spec.js b/admin/src/components/EditCreationFormular.spec.js index f5c7fb0fe..f39edad52 100644 --- a/admin/src/components/EditCreationFormular.spec.js +++ b/admin/src/components/EditCreationFormular.spec.js @@ -11,7 +11,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({ amount: 500, date: new Date(), memo: 'Test Schöpfung 2', - moderator: 0, }, }, }) @@ -28,12 +27,6 @@ const mocks = { mutate: apolloMutateMock, }, $store: { - state: { - moderator: { - id: 0, - name: 'test moderator', - }, - }, commit: stateCommitMock, }, } @@ -104,7 +97,6 @@ describe('EditCreationFormular', () => { creationDate: getCreationDate(0), amount: 500, memo: 'Test Schöpfung 2', - moderator: 0, }, }), ) @@ -129,7 +121,6 @@ describe('EditCreationFormular', () => { amount: 500, date: expect.any(Date), memo: 'Test Schöpfung 2', - moderator: 0, row: expect.any(Object), }, ], diff --git a/admin/src/components/EditCreationFormular.vue b/admin/src/components/EditCreationFormular.vue index 82b444154..fb30f2b77 100644 --- a/admin/src/components/EditCreationFormular.vue +++ b/admin/src/components/EditCreationFormular.vue @@ -120,7 +120,6 @@ export default { creationDate: this.selected.date, amount: Number(this.value), memo: this.text, - moderator: Number(this.$store.state.moderator.id), }, }) .then((result) => { @@ -129,7 +128,6 @@ export default { amount: Number(result.data.updatePendingCreation.amount), date: result.data.updatePendingCreation.date, memo: result.data.updatePendingCreation.memo, - moderator: Number(result.data.updatePendingCreation.moderator), row: this.row, }) this.toastSuccess( diff --git a/admin/src/graphql/createPendingCreation.js b/admin/src/graphql/createPendingCreation.js index 05402ed9f..9301ea489 100644 --- a/admin/src/graphql/createPendingCreation.js +++ b/admin/src/graphql/createPendingCreation.js @@ -1,19 +1,7 @@ import gql from 'graphql-tag' export const createPendingCreation = gql` - mutation ( - $email: String! - $amount: Decimal! - $memo: String! - $creationDate: String! - $moderator: Int! - ) { - createPendingCreation( - email: $email - amount: $amount - memo: $memo - creationDate: $creationDate - moderator: $moderator - ) + mutation ($email: String!, $amount: Decimal!, $memo: String!, $creationDate: String!) { + createPendingCreation(email: $email, amount: $amount, memo: $memo, creationDate: $creationDate) } ` diff --git a/admin/src/graphql/searchUsers.js b/admin/src/graphql/searchUsers.js index e28508d1b..5740e24cc 100644 --- a/admin/src/graphql/searchUsers.js +++ b/admin/src/graphql/searchUsers.js @@ -5,15 +5,13 @@ export const searchUsers = gql` $searchText: String! $currentPage: Int $pageSize: Int - $notActivated: Boolean - $isDeleted: Boolean + $filters: SearchUsersFiltersInput ) { searchUsers( searchText: $searchText currentPage: $currentPage pageSize: $pageSize - notActivated: $notActivated - isDeleted: $isDeleted + filters: $filters ) { userCount userList { diff --git a/admin/src/graphql/updatePendingCreation.js b/admin/src/graphql/updatePendingCreation.js index cd0ae6c8e..f0775e68b 100644 --- a/admin/src/graphql/updatePendingCreation.js +++ b/admin/src/graphql/updatePendingCreation.js @@ -1,27 +1,18 @@ import gql from 'graphql-tag' export const updatePendingCreation = gql` - mutation ( - $id: Int! - $email: String! - $amount: Decimal! - $memo: String! - $creationDate: String! - $moderator: Int! - ) { + mutation ($id: Int!, $email: String!, $amount: Decimal!, $memo: String!, $creationDate: String!) { updatePendingCreation( id: $id email: $email amount: $amount memo: $memo creationDate: $creationDate - moderator: $moderator ) { amount date memo creation - moderator } } ` diff --git a/admin/src/pages/Creation.spec.js b/admin/src/pages/Creation.spec.js index 204c35817..432cbe19b 100644 --- a/admin/src/pages/Creation.spec.js +++ b/admin/src/pages/Creation.spec.js @@ -71,8 +71,10 @@ describe('Creation', () => { searchText: '', currentPage: 1, pageSize: 25, - isDeleted: false, - notActivated: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, }), ) @@ -271,8 +273,10 @@ describe('Creation', () => { searchText: 'XX', currentPage: 1, pageSize: 25, - isDeleted: false, - notActivated: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, }), ) @@ -288,8 +292,10 @@ describe('Creation', () => { searchText: '', currentPage: 1, pageSize: 25, - isDeleted: false, - notActivated: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, }), ) @@ -305,8 +311,10 @@ describe('Creation', () => { searchText: '', currentPage: 2, pageSize: 25, - isDeleted: false, - notActivated: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, }), ) diff --git a/admin/src/pages/Creation.vue b/admin/src/pages/Creation.vue index e5b93350f..17962bfff 100644 --- a/admin/src/pages/Creation.vue +++ b/admin/src/pages/Creation.vue @@ -102,8 +102,10 @@ export default { searchText: this.criteria, currentPage: this.currentPage, pageSize: this.perPage, - notActivated: false, - isDeleted: false, + filters: { + filterByActivated: true, + filterByDeleted: false, + }, }, fetchPolicy: 'network-only', }) diff --git a/admin/src/pages/UserSearch.spec.js b/admin/src/pages/UserSearch.spec.js index 0b98d4d11..a1d809a66 100644 --- a/admin/src/pages/UserSearch.spec.js +++ b/admin/src/pages/UserSearch.spec.js @@ -7,7 +7,7 @@ const localVue = global.localVue const apolloQueryMock = jest.fn().mockResolvedValue({ data: { searchUsers: { - userCount: 1, + userCount: 4, userList: [ { userId: 1, @@ -82,8 +82,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 1, pageSize: 25, - notActivated: null, - isDeleted: null, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, }, }), ) @@ -101,8 +103,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 1, pageSize: 25, - notActivated: true, - isDeleted: null, + filters: { + filterByActivated: false, + filterByDeleted: null, + }, }, }), ) @@ -121,8 +125,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 1, pageSize: 25, - notActivated: null, - isDeleted: true, + filters: { + filterByActivated: null, + filterByDeleted: true, + }, }, }), ) @@ -141,8 +147,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 2, pageSize: 25, - notActivated: null, - isDeleted: null, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, }, }), ) @@ -161,8 +169,10 @@ describe('UserSearch', () => { searchText: 'search string', currentPage: 1, pageSize: 25, - notActivated: null, - isDeleted: null, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, }, }), ) @@ -178,8 +188,10 @@ describe('UserSearch', () => { searchText: '', currentPage: 1, pageSize: 25, - notActivated: null, - isDeleted: null, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, }, }), ) diff --git a/admin/src/pages/UserSearch.vue b/admin/src/pages/UserSearch.vue index b2737bae6..7b638c316 100644 --- a/admin/src/pages/UserSearch.vue +++ b/admin/src/pages/UserSearch.vue @@ -3,11 +3,23 @@
- {{ filterCheckedEmails ? $t('unregistered_emails') : $t('all_emails') }} + {{ + filterByActivated === null + ? $t('all_emails') + : filterByActivated === false + ? $t('unregistered_emails') + : '' + }} - {{ filterDeletedUser ? $t('deleted_user') : $t('all_emails') }} + {{ + filterByDeleted === null + ? $t('all_emails') + : filterByDeleted === true + ? $t('deleted_user') + : '' + }}
@@ -60,8 +72,8 @@ export default { searchResult: [], massCreation: [], criteria: '', - filterCheckedEmails: null, - filterDeletedUser: null, + filterByActivated: null, + filterByDeleted: null, rows: 0, currentPage: 1, perPage: 25, @@ -70,11 +82,11 @@ export default { }, methods: { unconfirmedRegisterMails() { - this.filterCheckedEmails = this.filterCheckedEmails ? null : true + this.filterByActivated = this.filterByActivated === null ? false : null this.getUsers() }, deletedUserSearch() { - this.filterDeletedUser = this.filterDeletedUser ? null : true + this.filterByDeleted = this.filterByDeleted === null ? true : null this.getUsers() }, getUsers() { @@ -85,8 +97,10 @@ export default { searchText: this.criteria, currentPage: this.currentPage, pageSize: this.perPage, - notActivated: this.filterCheckedEmails, - isDeleted: this.filterDeletedUser, + filters: { + filterByActivated: this.filterByActivated, + filterByDeleted: this.filterByDeleted, + }, }, fetchPolicy: 'no-cache', }) diff --git a/backend/.env.dist b/backend/.env.dist index de33a7272..62b786456 100644 --- a/backend/.env.dist +++ b/backend/.env.dist @@ -49,4 +49,8 @@ EMAIL_CODE_VALID_TIME=1440 EMAIL_CODE_REQUEST_TIME=10 # Webhook -WEBHOOK_ELOPAGE_SECRET=secret \ No newline at end of file +WEBHOOK_ELOPAGE_SECRET=secret + +# SET LOG LEVEL AS NEEDED IN YOUR .ENV +# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal +# LOG_LEVEL=info diff --git a/backend/.env.template b/backend/.env.template index 8ce8fca4e..140ec67e9 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -47,4 +47,4 @@ EMAIL_CODE_VALID_TIME=$EMAIL_CODE_VALID_TIME EMAIL_CODE_REQUEST_TIME=$EMAIL_CODE_REQUEST_TIME # Webhook -WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET \ No newline at end of file +WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET diff --git a/backend/.gitignore b/backend/.gitignore index 147e82849..6eadcc884 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -5,4 +5,4 @@ package-json.lock coverage # emacs -*~ \ No newline at end of file +*~ diff --git a/backend/.prettierrc.js b/backend/.prettierrc.js index 8495e3f20..bc1d767d7 100644 --- a/backend/.prettierrc.js +++ b/backend/.prettierrc.js @@ -5,4 +5,5 @@ module.exports = { trailingComma: "all", tabWidth: 2, bracketSpacing: true, + endOfLine: "auto", }; diff --git a/backend/log4js-config.json b/backend/log4js-config.json new file mode 100644 index 000000000..1c4b3fb6d --- /dev/null +++ b/backend/log4js-config.json @@ -0,0 +1,66 @@ +{ + "appenders": + { + "access": + { + "type": "dateFile", + "filename": "../logs/backend/access.log", + "pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m", + "keepFileExt" : true, + "fileNameSep" : "_" + }, + "apollo": + { + "type": "dateFile", + "filename": "../logs/backend/apollo.log", + "pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m", + "keepFileExt" : true, + "fileNameSep" : "_" + }, + "errorFile": + { + "type": "dateFile", + "filename": "../logs/backend/errors.log", + "pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m", + "keepFileExt" : true, + "fileNameSep" : "_" + }, + "errors": + { + "type": "logLevelFilter", + "level": "error", + "appender": "errorFile" + }, + "out": + { + "type": "stdout", + "layout": + { + "type": "pattern", "pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m" + } + + } + }, + "categories": + { + "default": + { + "appenders": + [ + "out", + "apollo", + "errors" + ], + "level": "debug", + "enableCallStack": true + }, + "http": + { + "appenders": + [ + "access" + ], + "level": "info" + } + } +} diff --git a/backend/package.json b/backend/package.json index 641a24601..678e3578a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "gradido-backend", - "version": "1.8.0", + "version": "1.8.3", "description": "Gradido unified backend providing an API-Service for Gradido Transactions", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/backend", @@ -19,7 +19,6 @@ "dependencies": { "@types/jest": "^27.0.2", "@types/lodash.clonedeep": "^4.5.6", - "apollo-log": "^1.1.0", "apollo-server-express": "^2.25.2", "apollo-server-testing": "^2.25.2", "axios": "^0.21.1", @@ -33,6 +32,7 @@ "jest": "^27.2.4", "jsonwebtoken": "^8.5.1", "lodash.clonedeep": "^4.5.0", + "log4js": "^6.4.6", "mysql2": "^2.3.0", "nodemailer": "^6.6.5", "random-bigint": "^0.0.1", diff --git a/backend/src/apis/HttpRequest.ts b/backend/src/apis/HttpRequest.ts index c1f99dc46..4039e3a98 100644 --- a/backend/src/apis/HttpRequest.ts +++ b/backend/src/apis/HttpRequest.ts @@ -1,10 +1,14 @@ import axios from 'axios' +import { backendLogger as logger } from '@/server/logger' + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const apiPost = async (url: string, payload: unknown): Promise => { + logger.trace('POST: url=' + url + ' payload=' + payload) return axios .post(url, payload) .then((result) => { + logger.trace('POST-Response: result=' + result) if (result.status !== 200) { throw new Error('HTTP Status Error ' + result.status) } @@ -20,9 +24,11 @@ export const apiPost = async (url: string, payload: unknown): Promise => { // eslint-disable-next-line @typescript-eslint/no-explicit-any export const apiGet = async (url: string): Promise => { + logger.trace('GET: url=' + url) return axios .get(url) .then((result) => { + logger.trace('GET-Response: result=' + result) if (result.status !== 200) { throw new Error('HTTP Status Error ' + result.status) } diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 1eee1b9a4..559b8e9c5 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,8 +10,11 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0035-admin_pending_creations_decimal', + DB_VERSION: '0036-unique_previous_in_transactions', DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0 + LOG4JS_CONFIG: 'log4js-config.json', + // default log level on production should be info + LOG_LEVEL: process.env.LOG_LEVEL || 'info', CONFIG_VERSION: { DEFAULT: 'DEFAULT', EXPECTED: 'v6.2022-04-21', diff --git a/backend/src/graphql/arg/CreatePendingCreationArgs.ts b/backend/src/graphql/arg/CreatePendingCreationArgs.ts index 0cadf5e62..11c345465 100644 --- a/backend/src/graphql/arg/CreatePendingCreationArgs.ts +++ b/backend/src/graphql/arg/CreatePendingCreationArgs.ts @@ -1,4 +1,4 @@ -import { ArgsType, Field, InputType, Int } from 'type-graphql' +import { ArgsType, Field, InputType } from 'type-graphql' import Decimal from 'decimal.js-light' @InputType() @@ -15,7 +15,4 @@ export default class CreatePendingCreationArgs { @Field(() => String) creationDate: string - - @Field(() => Int) - moderator: number } diff --git a/backend/src/graphql/arg/SearchUsersArgs.ts b/backend/src/graphql/arg/SearchUsersArgs.ts index 2a94d8998..8db6bfc06 100644 --- a/backend/src/graphql/arg/SearchUsersArgs.ts +++ b/backend/src/graphql/arg/SearchUsersArgs.ts @@ -1,4 +1,5 @@ import { ArgsType, Field, Int } from 'type-graphql' +import SearchUsersFilters from '@arg/SearchUsersFilters' @ArgsType() export default class SearchUsersArgs { @@ -11,9 +12,6 @@ export default class SearchUsersArgs { @Field(() => Int, { nullable: true }) pageSize?: number - @Field(() => Boolean, { nullable: true }) - notActivated?: boolean | null - - @Field(() => Boolean, { nullable: true }) - isDeleted?: boolean | null + @Field(() => SearchUsersFilters, { nullable: true }) + filters: SearchUsersFilters } diff --git a/backend/src/graphql/arg/SearchUsersFilters.ts b/backend/src/graphql/arg/SearchUsersFilters.ts new file mode 100644 index 000000000..de7c7c20a --- /dev/null +++ b/backend/src/graphql/arg/SearchUsersFilters.ts @@ -0,0 +1,11 @@ +import { Field, InputType, ObjectType } from 'type-graphql' + +@ObjectType() +@InputType('SearchUsersFiltersInput') +export default class SearchUsersFilters { + @Field(() => Boolean, { nullable: true, defaultValue: null }) + filterByActivated?: boolean | null + + @Field(() => Boolean, { nullable: true, defaultValue: null }) + filterByDeleted?: boolean | null +} diff --git a/backend/src/graphql/arg/TransactionLinkFilters.ts b/backend/src/graphql/arg/TransactionLinkFilters.ts index e2f752d3f..b009a3180 100644 --- a/backend/src/graphql/arg/TransactionLinkFilters.ts +++ b/backend/src/graphql/arg/TransactionLinkFilters.ts @@ -3,11 +3,11 @@ import { ArgsType, Field } from 'type-graphql' @ArgsType() export default class TransactionLinkFilters { @Field(() => Boolean, { nullable: true, defaultValue: true }) - withDeleted?: boolean + filterByDeleted?: boolean @Field(() => Boolean, { nullable: true, defaultValue: true }) - withExpired?: boolean + filterByExpired?: boolean @Field(() => Boolean, { nullable: true, defaultValue: true }) - withRedeemed?: boolean + filterByRedeemed?: boolean } diff --git a/backend/src/graphql/arg/UpdatePendingCreationArgs.ts b/backend/src/graphql/arg/UpdatePendingCreationArgs.ts index 3cd85e84b..691d73154 100644 --- a/backend/src/graphql/arg/UpdatePendingCreationArgs.ts +++ b/backend/src/graphql/arg/UpdatePendingCreationArgs.ts @@ -17,7 +17,4 @@ export default class UpdatePendingCreationArgs { @Field(() => String) creationDate: string - - @Field(() => Int) - moderator: number } diff --git a/backend/src/graphql/model/UpdatePendingCreation.ts b/backend/src/graphql/model/UpdatePendingCreation.ts index 85d3af2cc..e19e1e064 100644 --- a/backend/src/graphql/model/UpdatePendingCreation.ts +++ b/backend/src/graphql/model/UpdatePendingCreation.ts @@ -12,9 +12,6 @@ export class UpdatePendingCreation { @Field(() => Decimal) amount: Decimal - @Field(() => Number) - moderator: number - @Field(() => [Decimal]) creation: Decimal[] } diff --git a/backend/src/graphql/resolver/AdminResolver.test.ts b/backend/src/graphql/resolver/AdminResolver.test.ts new file mode 100644 index 000000000..4771232ea --- /dev/null +++ b/backend/src/graphql/resolver/AdminResolver.test.ts @@ -0,0 +1,1327 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { convertObjValuesToArray } from '@/util/utilities' +import { testEnvironment, resetToken, cleanDB } from '@test/helpers' +import { userFactory } from '@/seeds/factory/user' +import { creationFactory } from '@/seeds/factory/creation' +import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' +import { peterLustig } from '@/seeds/users/peter-lustig' +import { stephenHawking } from '@/seeds/users/stephen-hawking' +import { garrickOllivander } from '@/seeds/users/garrick-ollivander' +import { + deleteUser, + unDeleteUser, + searchUsers, + createPendingCreation, + createPendingCreations, + updatePendingCreation, + deletePendingCreation, + confirmPendingCreation, +} from '@/seeds/graphql/mutations' +import { getPendingCreations, login } from '@/seeds/graphql/queries' +import { GraphQLError } from 'graphql' +import { User } from '@entity/User' +/* eslint-disable-next-line @typescript-eslint/no-unused-vars */ +import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' +import Decimal from 'decimal.js-light' +import { AdminPendingCreation } from '@entity/AdminPendingCreation' +import { Transaction as DbTransaction } from '@entity/Transaction' + +// mock account activation email to avoid console spam +jest.mock('@/mailer/sendAccountActivationEmail', () => { + return { + __esModule: true, + sendAccountActivationEmail: jest.fn(), + } +}) + +let mutate: any, query: any, con: any +let testEnv: any + +beforeAll(async () => { + testEnv = await testEnvironment() + mutate = testEnv.mutate + query = testEnv.query + con = testEnv.con + await cleanDB() +}) + +afterAll(async () => { + await cleanDB() + await con.close() +}) + +let admin: User +let user: User +let creation: AdminPendingCreation | void + +describe('AdminResolver', () => { + describe('delete user', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: deleteUser, variables: { userId: 1 } })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + mutate({ mutation: deleteUser, variables: { userId: user.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('user to be deleted does not exist', () => { + it('throws an error', async () => { + await expect( + mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], + }), + ) + }) + }) + + describe('delete self', () => { + it('throws an error', async () => { + await expect( + mutate({ mutation: deleteUser, variables: { userId: admin.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Moderator can not delete his own account!')], + }), + ) + }) + }) + + describe('delete with success', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + }) + + it('returns date string', async () => { + const result = await mutate({ mutation: deleteUser, variables: { userId: user.id } }) + expect(result).toEqual( + expect.objectContaining({ + data: { + deleteUser: expect.any(String), + }, + }), + ) + expect(new Date(result.data.deleteUser)).toEqual(expect.any(Date)) + }) + + describe('delete deleted user', () => { + it('throws an error', async () => { + await expect( + mutate({ mutation: deleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)], + }), + ) + }) + }) + }) + }) + }) + }) + + describe('unDelete user', () => { + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: unDeleteUser, variables: { userId: 1 } })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('user to be undelete does not exist', () => { + it('throws an error', async () => { + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)], + }), + ) + }) + }) + + describe('user to undelete is not deleted', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + }) + + it('throws an error', async () => { + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User is not deleted')], + }), + ) + }) + + describe('undelete deleted user', () => { + beforeAll(async () => { + await mutate({ mutation: deleteUser, variables: { userId: user.id } }) + }) + + it('returns null', async () => { + await expect( + mutate({ mutation: unDeleteUser, variables: { userId: user.id } }), + ).resolves.toEqual( + expect.objectContaining({ + data: { unDeleteUser: null }, + }), + ) + }) + }) + }) + }) + }) + }) + + describe('search users', () => { + const variablesWithoutTextAndFilters = { + searchText: '', + currentPage: 1, + pageSize: 25, + filters: null, + } + + describe('unauthenticated', () => { + it('returns an error', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + it('returns an error', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('with admin rights', () => { + const allUsers = { + bibi: expect.objectContaining({ + email: 'bibi@bloxberg.de', + }), + garrick: expect.objectContaining({ + email: 'garrick@ollivander.com', + }), + peter: expect.objectContaining({ + email: 'peter@lustig.de', + }), + stephen: expect.objectContaining({ + email: 'stephen@hawking.uk', + }), + } + + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + + await userFactory(testEnv, bibiBloxberg) + await userFactory(testEnv, stephenHawking) + await userFactory(testEnv, garrickOllivander) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('without any filters', () => { + it('finds all users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 4, + userList: expect.arrayContaining(convertObjValuesToArray(allUsers)), + }, + }, + }), + ) + }) + }) + + describe('all filters are null', () => { + it('finds all users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + filterByActivated: null, + filterByDeleted: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 4, + userList: expect.arrayContaining(convertObjValuesToArray(allUsers)), + }, + }, + }), + ) + }) + }) + + describe('filter by unchecked email', () => { + it('finds only users with unchecked email', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + filterByActivated: false, + filterByDeleted: null, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 1, + userList: expect.arrayContaining([allUsers.garrick]), + }, + }, + }), + ) + }) + }) + + describe('filter by deleted users', () => { + it('finds only users with deleted account', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + filterByActivated: null, + filterByDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 1, + userList: expect.arrayContaining([allUsers.stephen]), + }, + }, + }), + ) + }) + }) + + describe('filter by deleted account and unchecked email', () => { + it('finds no users', async () => { + await expect( + query({ + query: searchUsers, + variables: { + ...variablesWithoutTextAndFilters, + filters: { + filterByActivated: false, + filterByDeleted: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + searchUsers: { + userCount: 0, + userList: [], + }, + }, + }), + ) + }) + }) + }) + }) + }) + + describe('creations', () => { + const variables = { + email: 'bibi@bloxberg.de', + amount: new Decimal(2000), + memo: 'Aktives Grundeinkommen', + creationDate: 'not-valid', + } + + describe('unauthenticated', () => { + describe('createPendingCreation', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createPendingCreation, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('createPendingCreations', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: createPendingCreations, + variables: { pendingCreations: [variables] }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('updatePendingCreation', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: 1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('getPendingCreations', () => { + it('returns an error', async () => { + await expect( + query({ + query: getPendingCreations, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deletePendingCreation', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: deletePendingCreation, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('confirmPendingCreation', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: confirmPendingCreation, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('authenticated', () => { + describe('without admin rights', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + await query({ + query: login, + variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createPendingCreation', () => { + it('returns an error', async () => { + await expect(mutate({ mutation: createPendingCreation, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('createPendingCreations', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: createPendingCreations, + variables: { pendingCreations: [variables] }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('updatePendingCreation', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: 1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('getPendingCreations', () => { + it('returns an error', async () => { + await expect( + query({ + query: getPendingCreations, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('deletePendingCreation', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: deletePendingCreation, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + + describe('confirmPendingCreation', () => { + it('returns an error', async () => { + await expect( + mutate({ + mutation: confirmPendingCreation, + variables: { + id: 1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('401 Unauthorized')], + }), + ) + }) + }) + }) + + describe('with admin rights', () => { + beforeAll(async () => { + admin = await userFactory(testEnv, peterLustig) + await query({ + query: login, + variables: { email: 'peter@lustig.de', password: 'Aa12345_' }, + }) + }) + + afterAll(async () => { + await cleanDB() + resetToken() + }) + + describe('createPendingCreation', () => { + beforeAll(async () => { + const now = new Date() + creation = await creationFactory(testEnv, { + email: 'peter@lustig.de', + amount: 400, + memo: 'Herzlich Willkommen bei Gradido!', + creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), + }) + }) + + describe('user to create for does not exist', () => { + it('throws an error', async () => { + await expect(mutate({ mutation: createPendingCreation, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Could not find user with email: bibi@bloxberg.de')], + }), + ) + }) + }) + + describe('user to create for is deleted', () => { + beforeAll(async () => { + user = await userFactory(testEnv, stephenHawking) + variables.email = 'stephen@hawking.uk' + }) + + it('throws an error', async () => { + await expect(mutate({ mutation: createPendingCreation, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('This user was deleted. Cannot make a creation.')], + }), + ) + }) + }) + + describe('user to create for has email not confirmed', () => { + beforeAll(async () => { + user = await userFactory(testEnv, garrickOllivander) + variables.email = 'garrick@ollivander.com' + }) + + it('throws an error', async () => { + await expect(mutate({ mutation: createPendingCreation, variables })).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Creation could not be saved, Email is not activated')], + }), + ) + }) + }) + + describe('valid user to create for', () => { + beforeAll(async () => { + user = await userFactory(testEnv, bibiBloxberg) + variables.email = 'bibi@bloxberg.de' + }) + + describe('date of creation is not a date string', () => { + it('throws an error', async () => { + await expect( + mutate({ mutation: createPendingCreation, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + }) + + describe('date of creation is four months ago', () => { + it('throws an error', async () => { + const now = new Date() + variables.creationDate = new Date( + now.getFullYear(), + now.getMonth() - 4, + 1, + ).toString() + await expect( + mutate({ mutation: createPendingCreation, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + }) + + describe('date of creation is in the future', () => { + it('throws an error', async () => { + const now = new Date() + variables.creationDate = new Date( + now.getFullYear(), + now.getMonth() + 4, + 1, + ).toString() + await expect( + mutate({ mutation: createPendingCreation, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError('No information for available creations for the given date'), + ], + }), + ) + }) + }) + + describe('amount of creation is too high', () => { + it('throws an error', async () => { + variables.creationDate = new Date().toString() + await expect( + mutate({ mutation: createPendingCreation, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (2000 GDD) to be created exceeds the amount (1000 GDD) still available for this month.', + ), + ], + }), + ) + }) + }) + + describe('creation is valid', () => { + it('returns an array of the open creations for the last three months', async () => { + variables.amount = new Decimal(200) + await expect( + mutate({ mutation: createPendingCreation, variables }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + createPendingCreation: [1000, 1000, 800], + }, + }), + ) + }) + }) + + describe('second creation surpasses the available amount ', () => { + it('returns an array of the open creations for the last three months', async () => { + variables.amount = new Decimal(1000) + await expect( + mutate({ mutation: createPendingCreation, variables }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (1000 GDD) to be created exceeds the amount (800 GDD) still available for this month.', + ), + ], + }), + ) + }) + }) + }) + }) + + describe('createPendingCreations', () => { + // at this point we have this data in DB: + // bibi@bloxberg.de: [1000, 1000, 800] + // peter@lustig.de: [1000, 600, 1000] + // stephen@hawking.uk: [1000, 1000, 1000] - deleted + // garrick@ollivander.com: [1000, 1000, 1000] - not activated + + const massCreationVariables = [ + 'bibi@bloxberg.de', + 'peter@lustig.de', + 'stephen@hawking.uk', + 'garrick@ollivander.com', + 'bob@baumeister.de', + ].map((email) => { + return { + email, + amount: new Decimal(500), + memo: 'Grundeinkommen', + creationDate: new Date().toString(), + } + }) + + it('returns success, two successful creation and three failed creations', async () => { + await expect( + mutate({ + mutation: createPendingCreations, + variables: { pendingCreations: massCreationVariables }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + createPendingCreations: { + success: true, + successfulCreation: ['bibi@bloxberg.de', 'peter@lustig.de'], + failedCreation: [ + 'stephen@hawking.uk', + 'garrick@ollivander.com', + 'bob@baumeister.de', + ], + }, + }, + }), + ) + }) + }) + + describe('updatePendingCreation', () => { + // at this I expect to have this data in DB: + // bibi@bloxberg.de: [1000, 1000, 300] + // peter@lustig.de: [1000, 600, 500] + // stephen@hawking.uk: [1000, 1000, 1000] - deleted + // garrick@ollivander.com: [1000, 1000, 1000] - not activated + + describe('user for creation to update does not exist', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: 1, + email: 'bob@baumeister.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Could not find user with email: bob@baumeister.de')], + }), + ) + }) + }) + + describe('user for creation to update is deleted', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: 1, + email: 'stephen@hawking.uk', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('User was deleted (stephen@hawking.uk)')], + }), + ) + }) + }) + + describe('creation does not exist', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: -1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('No creation found to given id.')], + }), + ) + }) + }) + + describe('user email does not match creation user', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: creation ? creation.id : -1, + email: 'bibi@bloxberg.de', + amount: new Decimal(300), + memo: 'Danke Bibi!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'user of the pending creation and send user does not correspond', + ), + ], + }), + ) + }) + }) + + describe('creation update is not valid', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(1900), + memo: 'Danke Peter!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [ + new GraphQLError( + 'The amount (1900 GDD) to be created exceeds the amount (500 GDD) still available for this month.', + ), + ], + }), + ) + }) + }) + + describe('creation update is successful changing month', () => { + it('returns update creation object', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(300), + memo: 'Danke Peter!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + updatePendingCreation: { + date: expect.any(String), + memo: 'Danke Peter!', + amount: '300', + creation: ['1000', '1000', '200'], + }, + }, + }), + ) + }) + }) + + describe('creation update is successful without changing month', () => { + it('returns update creation object', async () => { + await expect( + mutate({ + mutation: updatePendingCreation, + variables: { + id: creation ? creation.id : -1, + email: 'peter@lustig.de', + amount: new Decimal(200), + memo: 'Das war leider zu Viel!', + creationDate: new Date().toString(), + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + updatePendingCreation: { + date: expect.any(String), + memo: 'Das war leider zu Viel!', + amount: '200', + creation: ['1000', '1000', '300'], + }, + }, + }), + ) + }) + }) + }) + + describe('getPendingCreations', () => { + it('returns four pending creations', async () => { + await expect( + query({ + query: getPendingCreations, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { + getPendingCreations: expect.arrayContaining([ + { + id: expect.any(Number), + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + date: expect.any(String), + memo: 'Das war leider zu Viel!', + amount: '200', + moderator: admin.id, + creation: ['1000', '1000', '300'], + }, + { + id: expect.any(Number), + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + date: expect.any(String), + memo: 'Grundeinkommen', + amount: '500', + moderator: admin.id, + creation: ['1000', '1000', '300'], + }, + { + id: expect.any(Number), + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + date: expect.any(String), + memo: 'Grundeinkommen', + amount: '500', + moderator: admin.id, + creation: ['1000', '1000', '300'], + }, + { + id: expect.any(Number), + firstName: 'Bibi', + lastName: 'Bloxberg', + email: 'bibi@bloxberg.de', + date: expect.any(String), + memo: 'Aktives Grundeinkommen', + amount: '200', + moderator: admin.id, + creation: ['1000', '1000', '300'], + }, + ]), + }, + }), + ) + }) + }) + + describe('deletePendingCreation', () => { + describe('creation id does not exist', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: deletePendingCreation, + variables: { + id: -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Creation not found for given id.')], + }), + ) + }) + }) + + describe('creation id does exist', () => { + it('returns true', async () => { + await expect( + mutate({ + mutation: deletePendingCreation, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { deletePendingCreation: true }, + }), + ) + }) + }) + }) + + describe('confirmPendingCreation', () => { + describe('creation does not exits', () => { + it('throws an error', async () => { + await expect( + mutate({ + mutation: confirmPendingCreation, + variables: { + id: -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Creation not found to given id.')], + }), + ) + }) + }) + + describe('confirm own creation', () => { + beforeAll(async () => { + const now = new Date() + creation = await creationFactory(testEnv, { + email: 'peter@lustig.de', + amount: 400, + memo: 'Herzlich Willkommen bei Gradido!', + creationDate: new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString(), + }) + }) + + it('thows an error', async () => { + await expect( + mutate({ + mutation: confirmPendingCreation, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Moderator can not confirm own pending creation')], + }), + ) + }) + }) + + describe('confirm creation for other user', () => { + beforeAll(async () => { + const now = new Date() + creation = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 450, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + }) + }) + + it('returns true', async () => { + await expect( + mutate({ + mutation: confirmPendingCreation, + variables: { + id: creation ? creation.id : -1, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + data: { confirmPendingCreation: true }, + }), + ) + }) + + it('creates a transaction', async () => { + const transaction = await DbTransaction.find() + expect(transaction[0].amount.toString()).toBe('450') + expect(transaction[0].memo).toBe('Herzlich Willkommen bei Gradido liebe Bibi!') + expect(transaction[0].linkedTransactionId).toEqual(null) + expect(transaction[0].transactionLinkId).toEqual(null) + expect(transaction[0].previous).toEqual(null) + expect(transaction[0].linkedUserId).toEqual(null) + expect(transaction[0].typeId).toEqual(1) + }) + }) + + describe('confirm two creations one after the other quickly', () => { + let c1: AdminPendingCreation | void + let c2: AdminPendingCreation | void + + beforeAll(async () => { + const now = new Date() + c1 = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 50, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + }) + c2 = await creationFactory(testEnv, { + email: 'bibi@bloxberg.de', + amount: 50, + memo: 'Herzlich Willkommen bei Gradido liebe Bibi!', + creationDate: new Date(now.getFullYear(), now.getMonth() - 2, 1).toISOString(), + }) + }) + + // In the futrue this should not throw anymore + it('throws an error for the second confirmation', async () => { + const r1 = mutate({ + mutation: confirmPendingCreation, + variables: { + id: c1 ? c1.id : -1, + }, + }) + const r2 = mutate({ + mutation: confirmPendingCreation, + variables: { + id: c2 ? c2.id : -1, + }, + }) + await expect(r1).resolves.toEqual( + expect.objectContaining({ + data: { confirmPendingCreation: true }, + }), + ) + await expect(r2).resolves.toEqual( + expect.objectContaining({ + errors: [new GraphQLError('Unable to confirm creation.')], + }), + ) + }) + }) + }) + }) + }) + }) +}) diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 2009af3b0..8c3d71b73 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -52,23 +52,19 @@ export class AdminResolver { @Query(() => SearchUsersResult) async searchUsers( @Args() - { - searchText, - currentPage = 1, - pageSize = 25, - notActivated = null, - isDeleted = null, - }: SearchUsersArgs, + { searchText, currentPage = 1, pageSize = 25, filters }: SearchUsersArgs, ): Promise { const userRepository = getCustomRepository(UserRepository) const filterCriteria: ObjectLiteral[] = [] - if (notActivated !== null) { - filterCriteria.push({ emailChecked: !notActivated }) - } + if (filters) { + if (filters.filterByActivated !== null) { + filterCriteria.push({ emailChecked: filters.filterByActivated }) + } - if (isDeleted !== null) { - filterCriteria.push({ deletedAt: isDeleted ? Not(IsNull()) : IsNull() }) + if (filters.filterByDeleted !== null) { + filterCriteria.push({ deletedAt: filters.filterByDeleted ? Not(IsNull()) : IsNull() }) + } } const userFields = ['id', 'firstName', 'lastName', 'email', 'emailChecked', 'deletedAt'] @@ -157,11 +153,12 @@ export class AdminResolver { @Mutation(() => Date, { nullable: true }) async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise { const user = await dbUser.findOne({ id: userId }, { withDeleted: true }) - // user exists ? if (!user) { throw new Error(`Could not find user with userId: ${userId}`) } - // recover user account + if (!user.deletedAt) { + throw new Error('User is not deleted') + } await user.recover() return null } @@ -169,7 +166,8 @@ export class AdminResolver { @Authorized([RIGHTS.CREATE_PENDING_CREATION]) @Mutation(() => [Number]) async createPendingCreation( - @Args() { email, amount, memo, creationDate, moderator }: CreatePendingCreationArgs, + @Args() { email, amount, memo, creationDate }: CreatePendingCreationArgs, + @Ctx() context: Context, ): Promise { const user = await dbUser.findOne({ email }, { withDeleted: true }) if (!user) { @@ -181,6 +179,7 @@ export class AdminResolver { if (!user.emailChecked) { throw new Error('Creation could not be saved, Email is not activated') } + const moderator = getUser(context) const creations = await getUserCreation(user.id) const creationDateObj = new Date(creationDate) if (isCreationValid(creations, amount, creationDateObj)) { @@ -190,7 +189,7 @@ export class AdminResolver { adminPendingCreation.created = new Date() adminPendingCreation.date = creationDateObj adminPendingCreation.memo = memo - adminPendingCreation.moderator = moderator + adminPendingCreation.moderator = moderator.id await AdminPendingCreation.save(adminPendingCreation) } @@ -202,12 +201,13 @@ export class AdminResolver { async createPendingCreations( @Arg('pendingCreations', () => [CreatePendingCreationArgs]) pendingCreations: CreatePendingCreationArgs[], + @Ctx() context: Context, ): Promise { let success = false const successfulCreation: string[] = [] const failedCreation: string[] = [] for (const pendingCreation of pendingCreations) { - await this.createPendingCreation(pendingCreation) + await this.createPendingCreation(pendingCreation, context) .then(() => { successfulCreation.push(pendingCreation.email) success = true @@ -226,7 +226,8 @@ export class AdminResolver { @Authorized([RIGHTS.UPDATE_PENDING_CREATION]) @Mutation(() => UpdatePendingCreation) async updatePendingCreation( - @Args() { id, email, amount, memo, creationDate, moderator }: UpdatePendingCreationArgs, + @Args() { id, email, amount, memo, creationDate }: UpdatePendingCreationArgs, + @Ctx() context: Context, ): Promise { const user = await dbUser.findOne({ email }, { withDeleted: true }) if (!user) { @@ -236,7 +237,13 @@ export class AdminResolver { throw new Error(`User was deleted (${email})`) } - const pendingCreationToUpdate = await AdminPendingCreation.findOneOrFail({ id }) + const moderator = getUser(context) + + const pendingCreationToUpdate = await AdminPendingCreation.findOne({ id }) + + if (!pendingCreationToUpdate) { + throw new Error('No creation found to given id.') + } if (pendingCreationToUpdate.userId !== user.id) { throw new Error('user of the pending creation and send user does not correspond') @@ -248,20 +255,18 @@ export class AdminResolver { creations = updateCreations(creations, pendingCreationToUpdate) } - if (!isCreationValid(creations, amount, creationDateObj)) { - throw new Error('Creation is not valid') - } + // all possible cases not to be true are thrown in this function + isCreationValid(creations, amount, creationDateObj) pendingCreationToUpdate.amount = amount pendingCreationToUpdate.memo = memo pendingCreationToUpdate.date = new Date(creationDate) - pendingCreationToUpdate.moderator = moderator + pendingCreationToUpdate.moderator = moderator.id await AdminPendingCreation.save(pendingCreationToUpdate) const result = new UpdatePendingCreation() result.amount = amount result.memo = pendingCreationToUpdate.memo result.date = pendingCreationToUpdate.date - result.moderator = pendingCreationToUpdate.moderator result.creation = await getUserCreation(user.id) @@ -298,8 +303,11 @@ export class AdminResolver { @Authorized([RIGHTS.DELETE_PENDING_CREATION]) @Mutation(() => Boolean) async deletePendingCreation(@Arg('id', () => Int) id: number): Promise { - const entity = await AdminPendingCreation.findOneOrFail(id) - const res = await AdminPendingCreation.delete(entity) + const pendingCreation = await AdminPendingCreation.findOne(id) + if (!pendingCreation) { + throw new Error('Creation not found for given id.') + } + const res = await AdminPendingCreation.delete(pendingCreation) return !!res } @@ -309,7 +317,10 @@ export class AdminResolver { @Arg('id', () => Int) id: number, @Ctx() context: Context, ): Promise { - const pendingCreation = await AdminPendingCreation.findOneOrFail(id) + const pendingCreation = await AdminPendingCreation.findOne(id) + if (!pendingCreation) { + throw new Error('Creation not found to given id.') + } const moderatorUser = getUser(context) if (moderatorUser.id === pendingCreation.userId) throw new Error('Moderator can not confirm own pending creation') @@ -340,14 +351,15 @@ export class AdminResolver { transaction.memo = pendingCreation.memo transaction.userId = pendingCreation.userId transaction.previous = lastTransaction ? lastTransaction.id : null - // TODO pending creations decimal - transaction.amount = new Decimal(Number(pendingCreation.amount)) + transaction.amount = pendingCreation.amount transaction.creationDate = pendingCreation.date transaction.balance = newBalance transaction.balanceDate = receivedCallDate transaction.decay = decay ? decay.decay : new Decimal(0) transaction.decayStart = decay ? decay.start : null - await transaction.save() + await transaction.save().catch(() => { + throw new Error('Unable to confirm creation.') + }) await AdminPendingCreation.delete(pendingCreation) @@ -426,11 +438,11 @@ export class AdminResolver { } = { userId, } - if (!filters.withRedeemed) where.redeemedBy = null - if (!filters.withExpired) where.validUntil = MoreThan(new Date()) + if (!filters.filterByRedeemed) where.redeemedBy = null + if (!filters.filterByExpired) where.validUntil = MoreThan(new Date()) const [transactionLinks, count] = await dbTransactionLink.findAndCount({ where, - withDeleted: filters.withDeleted, + withDeleted: filters.filterByDeleted, order: { createdAt: order, }, @@ -504,7 +516,7 @@ function updateCreations(creations: Decimal[], pendingCreation: AdminPendingCrea if (index < 0) { throw new Error('You cannot create GDD for a month older than the last three months.') } - creations[index] = creations[index].plus(pendingCreation.amount) + creations[index] = creations[index].plus(pendingCreation.amount.toString()) return creations } @@ -512,12 +524,12 @@ function isCreationValid(creations: Decimal[], amount: Decimal, creationDate: Da const index = getCreationIndex(creationDate.getMonth()) if (index < 0) { - throw new Error(`No Creation found!`) + throw new Error('No information for available creations for the given date') } if (amount.greaterThan(creations[index].toString())) { throw new Error( - `The amount (${amount} GDD) to be created exceeds the available amount (${creations[index]} GDD) for this month.`, + `The amount (${amount} GDD) to be created exceeds the amount (${creations[index]} GDD) still available for this month.`, ) } diff --git a/backend/src/graphql/resolver/BalanceResolver.ts b/backend/src/graphql/resolver/BalanceResolver.ts index 909a22144..176b45354 100644 --- a/backend/src/graphql/resolver/BalanceResolver.ts +++ b/backend/src/graphql/resolver/BalanceResolver.ts @@ -1,3 +1,5 @@ +import { backendLogger as logger } from '@/server/logger' + import { Context, getUser } from '@/server/context' import { Resolver, Query, Ctx, Authorized } from 'type-graphql' import { Balance } from '@model/Balance' @@ -7,7 +9,7 @@ import { Transaction as dbTransaction } from '@entity/Transaction' import Decimal from 'decimal.js-light' import { GdtResolver } from './GdtResolver' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' -import { MoreThan, getCustomRepository } from '@dbTools/typeorm' +import { getCustomRepository } from '@dbTools/typeorm' import { TransactionLinkRepository } from '@repository/TransactionLink' @Resolver() @@ -18,15 +20,22 @@ export class BalanceResolver { const user = getUser(context) const now = new Date() + logger.addContext('user', user.id) + logger.info(`balance(userId=${user.id})...`) + const gdtResolver = new GdtResolver() const balanceGDT = await gdtResolver.gdtBalance(context) + logger.debug(`balanceGDT=${balanceGDT}`) const lastTransaction = context.lastTransaction ? context.lastTransaction : await dbTransaction.findOne({ userId: user.id }, { order: { balanceDate: 'DESC' } }) + logger.debug(`lastTransaction=${lastTransaction}`) + // No balance found if (!lastTransaction) { + logger.info(`no balance found, return Default-Balance!`) return new Balance({ balance: new Decimal(0), balanceGDT, @@ -39,16 +48,16 @@ export class BalanceResolver { context.transactionCount || context.transactionCount === 0 ? context.transactionCount : await dbTransaction.count({ where: { userId: user.id } }) - const linkCount = - context.linkCount || context.linkCount === 0 - ? context.linkCount - : await dbTransactionLink.count({ - where: { - userId: user.id, - redeemedAt: null, - validUntil: MoreThan(new Date()), - }, - }) + logger.debug(`transactionCount=${count}`) + + const linkCount = await dbTransactionLink.count({ + where: { + userId: user.id, + redeemedAt: null, + // validUntil: MoreThan(new Date()), + }, + }) + logger.debug(`linkCount=${linkCount}`) // The decay is always calculated on the last booked transaction const calculatedDecay = calculateDecay( @@ -56,6 +65,9 @@ export class BalanceResolver { lastTransaction.balanceDate, now, ) + logger.info( + `calculatedDecay(balance=${lastTransaction.balance}, balanceDate=${lastTransaction.balanceDate})=${calculatedDecay}`, + ) // The final balance is reduced by the link amount withheld const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) @@ -63,13 +75,27 @@ export class BalanceResolver { ? { sumHoldAvailableAmount: context.sumHoldAvailableAmount } : await transactionLinkRepository.summary(user.id, now) - return new Balance({ - balance: calculatedDecay.balance - .minus(sumHoldAvailableAmount.toString()) - .toDecimalPlaces(2, Decimal.ROUND_DOWN), // round towards zero + logger.debug(`context.sumHoldAvailableAmount=${context.sumHoldAvailableAmount}`) + logger.debug(`sumHoldAvailableAmount=${sumHoldAvailableAmount}`) + + const balance = calculatedDecay.balance + .minus(sumHoldAvailableAmount.toString()) + .toDecimalPlaces(2, Decimal.ROUND_DOWN) // round towards zero + + // const newBalance = new Balance({ + // balance: calculatedDecay.balance + // .minus(sumHoldAvailableAmount.toString()) + // .toDecimalPlaces(2, Decimal.ROUND_DOWN), + const newBalance = new Balance({ + balance, balanceGDT, count, linkCount, }) + logger.info( + `new Balance(balance=${balance}, balanceGDT=${balanceGDT}, count=${count}, linkCount=${linkCount}) = ${newBalance}`, + ) + + return newBalance } } diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 69e1899d9..023e5b2ff 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -1,6 +1,7 @@ /* eslint-disable new-cap */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { backendLogger as logger } from '@/server/logger' import CONFIG from '@/config' import { Context, getUser } from '@/server/context' @@ -44,15 +45,22 @@ export const executeTransaction = async ( recipient: dbUser, transactionLink?: dbTransactionLink | null, ): Promise => { + logger.info( + `executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`, + ) + if (sender.id === recipient.id) { + logger.error(`Sender and Recipient are the same.`) throw new Error('Sender and Recipient are the same.') } if (memo.length > MEMO_MAX_CHARS) { + logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`) throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`) } if (memo.length < MEMO_MIN_CHARS) { + logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`) throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`) } @@ -64,13 +72,16 @@ export const executeTransaction = async ( receivedCallDate, transactionLink, ) + logger.debug(`calculated Balance=${sendBalance}`) if (!sendBalance) { + logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`) throw new Error("user hasn't enough GDD or amount is < 0") } const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() await queryRunner.startTransaction('READ UNCOMMITTED') + logger.debug(`open Transaction to write...`) try { // transaction const transactionSend = new dbTransaction() @@ -87,6 +98,8 @@ export const executeTransaction = async ( transactionSend.transactionLinkId = transactionLink ? transactionLink.id : null await queryRunner.manager.insert(dbTransaction, transactionSend) + logger.debug(`sendTransaction inserted: ${dbTransaction}`) + const transactionReceive = new dbTransaction() transactionReceive.typeId = TransactionTypeId.RECEIVE transactionReceive.memo = memo @@ -102,12 +115,15 @@ export const executeTransaction = async ( transactionReceive.linkedTransactionId = transactionSend.id transactionReceive.transactionLinkId = transactionLink ? transactionLink.id : null await queryRunner.manager.insert(dbTransaction, transactionReceive) + logger.debug(`receive Transaction inserted: ${dbTransaction}`) // Save linked transaction id for send transactionSend.linkedTransactionId = transactionReceive.id await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend) + logger.debug(`send Transaction updated: ${transactionSend}`) if (transactionLink) { + logger.info(`transactionLink: ${transactionLink}`) transactionLink.redeemedAt = receivedCallDate transactionLink.redeemedBy = recipient.id await queryRunner.manager.update( @@ -118,13 +134,15 @@ export const executeTransaction = async ( } await queryRunner.commitTransaction() + logger.info(`commit Transaction successful...`) } catch (e) { await queryRunner.rollbackTransaction() + logger.error(`Transaction was not successful: ${e}`) throw new Error(`Transaction was not successful: ${e}`) } finally { await queryRunner.release() } - + logger.debug(`prepare Email for transaction received...`) // send notification email // TODO: translate await sendTransactionReceivedEmail({ @@ -138,7 +156,7 @@ export const executeTransaction = async ( memo, overviewURL: CONFIG.EMAIL_LINK_OVERVIEW, }) - + logger.info(`finished executeTransaction successfully`) return true } @@ -154,16 +172,21 @@ export class TransactionResolver { const now = new Date() const user = getUser(context) + logger.addContext('user', user.id) + logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.email})`) + // find current balance const lastTransaction = await dbTransaction.findOne( { userId: user.id }, { order: { balanceDate: 'DESC' } }, ) + logger.debug(`lastTransaction=${lastTransaction}`) const balanceResolver = new BalanceResolver() context.lastTransaction = lastTransaction if (!lastTransaction) { + logger.info('no lastTransaction') return new TransactionList(await balanceResolver.balance(context), []) } @@ -186,6 +209,8 @@ export class TransactionResolver { involvedUserIds.push(transaction.linkedUserId) } }) + logger.debug(`involvedUserIds=${involvedUserIds}`) + // We need to show the name for deleted users for old transactions const involvedDbUsers = await dbUser .createQueryBuilder() @@ -193,6 +218,7 @@ export class TransactionResolver { .where('id IN (:...userIds)', { userIds: involvedUserIds }) .getMany() const involvedUsers = involvedDbUsers.map((u) => new User(u)) + logger.debug(`involvedUsers=${involvedUsers}`) const self = new User(user) const transactions: Transaction[] = [] @@ -201,10 +227,13 @@ export class TransactionResolver { const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate, transactionLinkcount } = await transactionLinkRepository.summary(user.id, now) context.linkCount = transactionLinkcount + logger.debug(`transactionLinkcount=${transactionLinkcount}`) context.sumHoldAvailableAmount = sumHoldAvailableAmount + logger.debug(`sumHoldAvailableAmount=${sumHoldAvailableAmount}`) // decay & link transactions if (currentPage === 1 && order === Order.DESC) { + logger.debug(`currentPage == 1: transactions=${transactions}`) // The virtual decay is always on the booked amount, not including the generated, not yet booked links, // since the decay is substantially different when the amount is less transactions.push( @@ -216,8 +245,11 @@ export class TransactionResolver { sumHoldAvailableAmount, ), ) + logger.debug(`transactions=${transactions}`) + // virtual transaction for pending transaction-links sum if (sumHoldAvailableAmount.greaterThan(0)) { + logger.debug(`sumHoldAvailableAmount > 0: transactions=${transactions}`) transactions.push( virtualLinkTransaction( lastTransaction.balance.minus(sumHoldAvailableAmount.toString()), @@ -229,6 +261,7 @@ export class TransactionResolver { self, ), ) + logger.debug(`transactions=${transactions}`) } } @@ -240,6 +273,7 @@ export class TransactionResolver { : involvedUsers.find((u) => u.id === userTransaction.linkedUserId) transactions.push(new Transaction(userTransaction, self, linkedUser)) }) + logger.debug(`TransactionTypeId.CREATION: transactions=${transactions}`) // Construct Result return new TransactionList(await balanceResolver.balance(context), transactions) @@ -251,29 +285,38 @@ export class TransactionResolver { @Args() { email, amount, memo }: TransactionSendArgs, @Ctx() context: Context, ): Promise { + logger.info(`sendCoins(email=${email}, amount=${amount}, memo=${memo})`) + // TODO this is subject to replay attacks const senderUser = getUser(context) if (senderUser.pubKey.length !== 32) { + logger.error(`invalid sender public key:${senderUser.pubKey}`) throw new Error('invalid sender public key') } // validate recipient user const recipientUser = await dbUser.findOne({ email: email }, { withDeleted: true }) if (!recipientUser) { + logger.error(`recipient not known: email=${email}`) throw new Error('recipient not known') } if (recipientUser.deletedAt) { + logger.error(`The recipient account was deleted: recipientUser=${recipientUser}`) throw new Error('The recipient account was deleted') } if (!recipientUser.emailChecked) { + logger.error(`The recipient account is not activated: recipientUser=${recipientUser}`) throw new Error('The recipient account is not activated') } if (!isHexPublicKey(recipientUser.pubKey.toString('hex'))) { + logger.error(`invalid recipient public key: recipientUser=${recipientUser}`) throw new Error('invalid recipient public key') } await executeTransaction(amount, memo, senderUser, recipientUser) - + logger.info( + `successful executeTransaction(amount=${amount}, memo=${memo}, senderUser=${senderUser}, recipientUser=${recipientUser})`, + ) return true } } diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index c658476a4..1afce832b 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -14,6 +14,8 @@ import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail' import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail' import { printTimeDuration, activationLink } from './UserResolver' +import { logger } from '@test/testSetup' + // import { klicktippSignIn } from '@/apis/KlicktippController' jest.mock('@/mailer/sendAccountActivationEmail', () => { @@ -43,7 +45,7 @@ let mutate: any, query: any, con: any let testEnv: any beforeAll(async () => { - testEnv = await testEnvironment() + testEnv = await testEnvironment(logger) mutate = testEnv.mutate query = testEnv.query con = testEnv.con @@ -149,12 +151,14 @@ describe('UserResolver', () => { }) describe('email already exists', () => { - it('throws an error', async () => { - await expect(mutate({ mutation: createUser, variables })).resolves.toEqual( + it('throws and logs an error', async () => { + const mutation = await mutate({ mutation: createUser, variables }) + expect(mutation).toEqual( expect.objectContaining({ errors: [new GraphQLError('User already exists.')], }), ) + expect(logger.error).toBeCalledWith('User already exists with this email=peter@lustig.de') }) }) diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 4ab5a901b..7080ad68b 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -1,4 +1,6 @@ import fs from 'fs' +import { backendLogger as logger } from '@/server/logger' + import { Context, getUser } from '@/server/context' import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql' import { getConnection, getCustomRepository } from '@dbTools/typeorm' @@ -43,6 +45,7 @@ const WORDS = fs .toString() .split(',') const PassphraseGenerate = (): string[] => { + logger.trace('PassphraseGenerate...') const result = [] for (let i = 0; i < PHRASE_WORD_COUNT; i++) { result.push(WORDS[sodium.randombytes_random() % 2048]) @@ -51,7 +54,9 @@ const PassphraseGenerate = (): string[] => { } const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => { + logger.trace('KeyPairEd25519Create...') if (!passphrase.length || passphrase.length < PHRASE_WORD_COUNT) { + logger.error('passphrase empty or to short') throw new Error('passphrase empty or to short') } @@ -79,14 +84,19 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => { privKey, outputHashBuffer.slice(0, sodium.crypto_sign_SEEDBYTES), ) + logger.debug(`KeyPair creation ready. pubKey=${pubKey}`) return [pubKey, privKey] } const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => { + logger.trace('SecretKeyCryptographyCreateKey...') const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex') const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex') if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) { + logger.error( + `ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`, + ) throw new Error( `ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`, ) @@ -115,39 +125,50 @@ const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[ const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES) sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey) + logger.debug( + `SecretKeyCryptographyCreateKey...successful: encryptionKeyHash= ${encryptionKeyHash}, encryptionKey= ${encryptionKey}`, + ) return [encryptionKeyHash, encryptionKey] } const getEmailHash = (email: string): Buffer => { + logger.trace('getEmailHash...') const emailHash = Buffer.alloc(sodium.crypto_generichash_BYTES) sodium.crypto_generichash(emailHash, Buffer.from(email)) + logger.debug(`getEmailHash...successful: ${emailHash}`) return emailHash } const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => { + logger.trace('SecretKeyCryptographyEncrypt...') const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES) const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES) nonce.fill(31) // static nonce sodium.crypto_secretbox_easy(encrypted, message, nonce, encryptionKey) + logger.debug(`SecretKeyCryptographyEncrypt...successful: ${encrypted}`) return encrypted } const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: Buffer): Buffer => { + logger.trace('SecretKeyCryptographyDecrypt...') const message = Buffer.alloc(encryptedMessage.length - sodium.crypto_secretbox_MACBYTES) const nonce = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES) nonce.fill(31) // static nonce sodium.crypto_secretbox_open_easy(message, encryptedMessage, nonce, encryptionKey) + logger.debug(`SecretKeyCryptographyDecrypt...successful: ${message}`) return message } const newEmailOptIn = (userId: number): LoginEmailOptIn => { + logger.trace('newEmailOptIn...') const emailOptIn = new LoginEmailOptIn() emailOptIn.verificationCode = random(64) emailOptIn.userId = userId emailOptIn.emailOptInTypeId = OptInType.EMAIL_OPT_IN_REGISTER + logger.debug(`newEmailOptIn...successful: ${emailOptIn}`) return emailOptIn } @@ -159,8 +180,14 @@ export const checkOptInCode = async ( userId: number, optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER, ): Promise => { + logger.info(`checkOptInCode... ${optInCode}`) if (optInCode) { if (!canResendOptIn(optInCode)) { + 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, @@ -170,16 +197,20 @@ export const checkOptInCode = async ( optInCode.updatedAt = new Date() optInCode.resendCount++ } else { + logger.trace('create new OptIn for userId=' + userId) optInCode = newEmailOptIn(userId) } optInCode.emailOptInTypeId = optInType await LoginEmailOptIn.save(optInCode).catch(() => { + logger.error('Unable to save optin code= ' + optInCode) throw new Error('Unable to save optin code.') }) + logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${userId}`) return optInCode } export const activationLink = (optInCode: LoginEmailOptIn): string => { + logger.debug(`activationLink(${LoginEmailOptIn})...`) return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString()) } @@ -189,6 +220,7 @@ export class UserResolver { @Query(() => User) @UseMiddleware(klicktippNewsletterStateMiddleware) async verifyLogin(@Ctx() context: Context): Promise { + logger.info('verifyLogin...') // TODO refactor and do not have duplicate code with login(see below) const userEntity = getUser(context) const user = new User(userEntity) @@ -201,10 +233,11 @@ export class UserResolver { const coinanimation = await userSettingRepository .readBoolean(userEntity.id, Setting.COIN_ANIMATION) .catch((error) => { + logger.error('error:', error) throw new Error(error) }) user.coinanimation = coinanimation - + logger.debug(`verifyLogin... successful: ${user.firstName}.${user.lastName}, ${user.email}`) return user } @@ -215,34 +248,46 @@ export class UserResolver { @Args() { email, password, publisherId }: UnsecureLoginArgs, @Ctx() context: Context, ): Promise { + logger.info(`login with ${email}, ***, ${publisherId} ...`) email = email.trim().toLowerCase() const dbUser = await DbUser.findOneOrFail({ email }, { withDeleted: true }).catch(() => { + logger.error(`User with email=${email} does not exists`) throw new Error('No user with this credentials') }) if (dbUser.deletedAt) { + logger.error('The User was permanently deleted in database.') throw new Error('This user was permanently deleted. Contact support for questions.') } if (!dbUser.emailChecked) { + logger.error('The Users email is not validate yet.') throw new Error('User email not validated') } if (dbUser.password === BigInt(0)) { + logger.error('The User has not set a password yet.') // TODO we want to catch this on the frontend and ask the user to check his emails or resend code throw new Error('User has no password set yet') } if (!dbUser.pubKey || !dbUser.privKey) { + logger.error('The User has no private or publicKey.') // TODO we want to catch this on the frontend and ask the user to check his emails or resend code throw new Error('User has no private or publicKey') } const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash const loginUserPassword = BigInt(dbUser.password.toString()) if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) { + logger.error('The User has no valid credentials.') throw new Error('No user with this credentials') } + // add pubKey in logger-context for layout-pattern X{user} to print it in each logging message + logger.addContext('user', dbUser.id) + logger.debug('login credentials valid...') const user = new User(dbUser) + logger.debug('user=' + user) // Elopage Status & Stored PublisherId user.hasElopage = await this.hasElopage({ ...context, user: dbUser }) + logger.info('user.hasElopage=' + user.hasElopage) if (!user.hasElopage && publisherId) { user.publisherId = publisherId dbUser.publisherId = publisherId @@ -262,7 +307,7 @@ export class UserResolver { key: 'token', value: encode(dbUser.pubKey), }) - + logger.info('successful Login:' + user) return user } @@ -274,6 +319,9 @@ export class UserResolver { // The functionality is fully client side - the client just needs to delete his token with the current implementation. // we could try to force this by sending `token: null` or `token: ''` with this call. But since it bares no real security // we should just return true for now. + logger.info('Logout...') + // remove user.pubKey from logger-context to ensure a correct filter on log-messages belonging to the same user + logger.addContext('user', 'unknown') return true } @@ -283,6 +331,9 @@ export class UserResolver { @Args() { email, firstName, lastName, language, publisherId, redeemCode = null }: CreateUserArgs, ): Promise { + logger.info( + `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? // default int publisher_id = 0; @@ -295,7 +346,9 @@ export class UserResolver { 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 const userFound = await DbUser.findOne({ email }, { withDeleted: true }) + logger.info(`DbUser.findOne(email=${email}) = ${userFound}`) if (userFound) { + logger.error('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. throw new Error(`User already exists.`) } @@ -314,8 +367,10 @@ export class UserResolver { dbUser.language = language dbUser.publisherId = publisherId dbUser.passphrase = passphrase.join(' ') + logger.debug('new dbUser=' + dbUser) if (redeemCode) { const transactionLink = await dbTransactionLink.findOne({ code: redeemCode }) + logger.info('redeemCode found transactionLink=' + transactionLink) if (transactionLink) { dbUser.referrerId = transactionLink.userId } @@ -332,15 +387,13 @@ export class UserResolver { await queryRunner.startTransaction('READ UNCOMMITTED') try { await queryRunner.manager.save(dbUser).catch((error) => { - // eslint-disable-next-line no-console - console.log('Error while saving dbUser', error) + logger.error('Error while saving dbUser', error) throw new Error('error saving user') }) const emailOptIn = newEmailOptIn(dbUser.id) await queryRunner.manager.save(emailOptIn).catch((error) => { - // eslint-disable-next-line no-console - console.log('Error while saving emailOptIn', error) + logger.error('Error while saving emailOptIn', error) throw new Error('error saving email opt in') }) @@ -357,31 +410,35 @@ export class UserResolver { email, duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME), }) - - /* uncomment this, when you need the activation link on the console + logger.info(`sendAccountActivationEmail 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) { - // eslint-disable-next-line no-console - console.log(`Account confirmation link: ${activationLink}`) + logger.debug(`Account confirmation link: ${activationLink}`) } - */ await queryRunner.commitTransaction() } catch (e) { + logger.error(`error during create user with ${e}`) await queryRunner.rollbackTransaction() throw e } finally { await queryRunner.release() } + logger.info('createUser() successful...') return new User(dbUser) } @Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL]) @Mutation(() => Boolean) async forgotPassword(@Arg('email') email: string): Promise { + logger.info(`forgotPassword(${email})...`) email = email.trim().toLowerCase() const user = await DbUser.findOne({ email }) - if (!user) return true + if (!user) { + logger.warn(`no user found with ${email}`) + return true + } // can be both types: REGISTER and RESET_PASSWORD let optInCode = await LoginEmailOptIn.findOne({ @@ -389,7 +446,7 @@ export class UserResolver { }) optInCode = await checkOptInCode(optInCode, user.id, OptInType.EMAIL_OPT_IN_RESET_PASSWORD) - + logger.info(`optInCode for ${email}=${optInCode}`) // eslint-disable-next-line @typescript-eslint/no-unused-vars const emailSent = await sendResetPasswordEmailMailer({ link: activationLink(optInCode), @@ -399,13 +456,12 @@ export class UserResolver { duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME), }) - /* 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 if (!emailSent) { - // eslint-disable-next-line no-console - console.log(`Reset password link: ${link}`) + logger.debug(`Reset password link: ${activationLink(optInCode)}`) } - */ + logger.info(`forgotPassword(${email}) successful...`) return true } @@ -416,6 +472,7 @@ export class UserResolver { @Arg('code') code: string, @Arg('password') password: string, ): Promise { + logger.info(`setPassword(${code}, ***)...`) // Validate Password if (!isPassword(password)) { throw new Error( @@ -425,34 +482,44 @@ export class UserResolver { // Load code const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => { + logger.error('Could not login with emailVerificationCode') throw new Error('Could not login with emailVerificationCode') }) - + logger.debug('optInCode loaded...') // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes if (!isOptInValid(optInCode)) { + logger.error( + `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, + ) throw new Error( `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, ) } + logger.debug('optInCode is valid...') // load user const user = await DbUser.findOneOrFail({ id: optInCode.userId }).catch(() => { + logger.error('Could not find corresponding Login User') throw new Error('Could not find corresponding Login User') }) + logger.debug('user with optInCode found...') // Generate Passphrase if needed if (!user.passphrase) { const passphrase = PassphraseGenerate() user.passphrase = passphrase.join(' ') + logger.debug('new Passphrase generated...') } const passphrase = user.passphrase.split(' ') if (passphrase.length < PHRASE_WORD_COUNT) { + logger.error('Could not load a correct passphrase') // TODO if this can happen we cannot recover from that // this seem to be good on production data, if we dont // make a coding mistake we do not have a problem here throw new Error('Could not load a correct passphrase') } + logger.debug('Passphrase is valid...') // Activate EMail user.emailChecked = true @@ -464,6 +531,7 @@ export class UserResolver { user.password = passwordHash[0].readBigUInt64LE() // using the shorthash user.pubKey = keyPair[0] user.privKey = encryptedPrivkey + logger.debug('User credentials updated ...') const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() @@ -472,12 +540,15 @@ export class UserResolver { try { // Save user await queryRunner.manager.save(user).catch((error) => { + logger.error('error saving user: ' + error) throw new Error('error saving user: ' + error) }) await queryRunner.commitTransaction() + logger.info('User data written successfully...') } catch (e) { await queryRunner.rollbackTransaction() + logger.error('Error on writing User data:' + e) throw e } finally { await queryRunner.release() @@ -488,7 +559,11 @@ export class UserResolver { if (optInCode.emailOptInTypeId === OptInType.EMAIL_OPT_IN_REGISTER) { try { await klicktippSignIn(user.email, user.language, user.firstName, user.lastName) - } catch { + logger.debug( + `klicktippSignIn(${user.email}, ${user.language}, ${user.firstName}, ${user.lastName})`, + ) + } catch (e) { + logger.error('Error subscribe to klicktipp:' + e) // TODO is this a problem? // eslint-disable-next-line no-console /* uncomment this, when you need the activation link on the console @@ -503,13 +578,19 @@ export class UserResolver { @Authorized([RIGHTS.QUERY_OPT_IN]) @Query(() => Boolean) async queryOptIn(@Arg('optIn') optIn: string): Promise { + logger.info(`queryOptIn(${optIn})...`) const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: optIn }) + logger.debug(`found optInCode=${optInCode}`) // Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes if (!isOptInValid(optInCode)) { + logger.error( + `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, + ) throw new Error( `email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`, ) } + logger.info(`queryOptIn(${optIn}) successful...`) return true } @@ -520,6 +601,9 @@ export class UserResolver { { firstName, lastName, language, password, passwordNew, coinanimation }: UpdateUserInfosArgs, @Ctx() context: Context, ): Promise { + logger.info( + `updateUserInfos(${firstName}, ${lastName}, ${language}, ***, ***, ${coinanimation})...`, + ) const userEntity = getUser(context) if (firstName) { @@ -532,6 +616,7 @@ export class UserResolver { if (language) { if (!isLanguage(language)) { + logger.error(`"${language}" isn't a valid language`) throw new Error(`"${language}" isn't a valid language`) } userEntity.language = language @@ -540,6 +625,7 @@ export class UserResolver { if (password && passwordNew) { // Validate Password if (!isPassword(passwordNew)) { + logger.error('newPassword does not fullfil the rules') throw new Error( 'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!', ) @@ -548,13 +634,16 @@ export class UserResolver { // TODO: This had some error cases defined - like missing private key. This is no longer checked. const oldPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, password) if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) { + logger.error(`Old password is invalid`) throw new Error(`Old password is invalid`) } const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1]) - + logger.debug('oldPassword decrypted...') const newPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, passwordNew) // return short and long hash + logger.debug('newPasswordHash created...') const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1]) + logger.debug('PrivateKey encrypted...') // Save new password hash and newly encrypted private key userEntity.password = newPasswordHash[0].readBigUInt64LE() @@ -580,25 +669,30 @@ export class UserResolver { }) await queryRunner.commitTransaction() + logger.debug('writing User data successful...') } catch (e) { await queryRunner.rollbackTransaction() + logger.error(`error on writing updated user data: ${e}`) throw e } finally { await queryRunner.release() } - + logger.info('updateUserInfos() successfully finished...') return true } @Authorized([RIGHTS.HAS_ELOPAGE]) @Query(() => Boolean) async hasElopage(@Ctx() context: Context): Promise { + logger.info(`hasElopage()...`) const userEntity = context.user if (!userEntity) { + logger.info('missing context.user for EloPage-check') return false } - - return hasElopageBuys(userEntity.email) + const elopageBuys = hasElopageBuys(userEntity.email) + logger.debug(`has ElopageBuys = ${elopageBuys}`) + return elopageBuys } } diff --git a/backend/src/mailer/sendEMail.test.ts b/backend/src/mailer/sendEMail.test.ts index b7cc06a60..8a13c027d 100644 --- a/backend/src/mailer/sendEMail.test.ts +++ b/backend/src/mailer/sendEMail.test.ts @@ -2,6 +2,8 @@ import { sendEMail } from './sendEMail' import { createTransport } from 'nodemailer' import CONFIG from '@/config' +import { logger } from '@test/testSetup' + CONFIG.EMAIL = false CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL' CONFIG.EMAIL_SMTP_PORT = '1234' @@ -26,11 +28,6 @@ jest.mock('nodemailer', () => { describe('sendEMail', () => { let result: boolean describe('config email is false', () => { - // eslint-disable-next-line no-console - const consoleLog = console.log - const consoleLogMock = jest.fn() - // eslint-disable-next-line no-console - console.log = consoleLogMock beforeEach(async () => { result = await sendEMail({ to: 'receiver@mail.org', @@ -39,13 +36,8 @@ describe('sendEMail', () => { }) }) - afterAll(() => { - // eslint-disable-next-line no-console - console.log = consoleLog - }) - - it('logs warining to console', () => { - expect(consoleLogMock).toBeCalledWith('Emails are disabled via config') + it('logs warining', () => { + expect(logger.info).toBeCalledWith('Emails are disabled via config...') }) it('returns false', () => { diff --git a/backend/src/mailer/sendEMail.ts b/backend/src/mailer/sendEMail.ts index 13c28996b..640dd7f4c 100644 --- a/backend/src/mailer/sendEMail.ts +++ b/backend/src/mailer/sendEMail.ts @@ -1,3 +1,4 @@ +import { backendLogger as logger } from '@/server/logger' import { createTransport } from 'nodemailer' import CONFIG from '@/config' @@ -7,9 +8,10 @@ export const sendEMail = async (emailDef: { subject: string text: string }): Promise => { + logger.info(`send Email: to=${emailDef.to}, subject=${emailDef.subject}, text=${emailDef.text}`) + if (!CONFIG.EMAIL) { - // eslint-disable-next-line no-console - console.log('Emails are disabled via config') + logger.info(`Emails are disabled via config...`) return false } const transporter = createTransport({ @@ -27,7 +29,9 @@ export const sendEMail = async (emailDef: { from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`, }) if (!info.messageId) { + logger.error('error sending notification email, but transaction succeed') throw new Error('error sending notification email, but transaction succeed') } + logger.info('send Email successfully.') return true } diff --git a/backend/src/mailer/sendTransactionReceivedEmail.ts b/backend/src/mailer/sendTransactionReceivedEmail.ts index 537c13d85..692f92f9a 100644 --- a/backend/src/mailer/sendTransactionReceivedEmail.ts +++ b/backend/src/mailer/sendTransactionReceivedEmail.ts @@ -1,3 +1,4 @@ +import { backendLogger as logger } from '@/server/logger' import Decimal from 'decimal.js-light' import { sendEMail } from './sendEMail' import { transactionReceived } from './text/transactionReceived' @@ -13,6 +14,12 @@ export const sendTransactionReceivedEmail = (data: { memo: string overviewURL: string }): Promise => { + logger.info( + `sendEmail(): to=${data.recipientFirstName} ${data.recipientLastName}, + <${data.email}>, + subject=${transactionReceived.de.subject}, + text=${transactionReceived.de.text(data)}`, + ) return sendEMail({ to: `${data.recipientFirstName} ${data.recipientLastName} <${data.email}>`, subject: transactionReceived.de.subject, diff --git a/backend/src/seeds/creation/index.ts b/backend/src/seeds/creation/index.ts index 20d30d94c..38cb98361 100644 --- a/backend/src/seeds/creation/index.ts +++ b/backend/src/seeds/creation/index.ts @@ -1,6 +1,131 @@ import { CreationInterface } from './CreationInterface' import { nMonthsBefore } from '../factory/creation' +const bobsSendings = [ + { + amount: 10, + memo: 'Herzlich Willkommen bei Gradido!', + }, + { + amount: 10, + memo: 'für deine Hilfe, Betty', + }, + { + amount: 23.37, + memo: 'für deine Hilfe, David', + }, + { + amount: 47, + memo: 'für deine Hilfe, Frau Holle', + }, + { + amount: 1.02, + memo: 'für deine Hilfe, Herr Müller', + }, + { + amount: 5.67, + memo: 'für deine Hilfe, Maier', + }, + { + amount: 72.93, + memo: 'für deine Hilfe, Elsbeth', + }, + { + amount: 5.6, + memo: 'für deine Hilfe, Daniel', + }, + { + amount: 8.87, + memo: 'für deine Hilfe, Yoda', + }, + { + amount: 7.56, + memo: 'für deine Hilfe, Sabine', + }, + { + amount: 7.89, + memo: 'für deine Hilfe, Karl', + }, + { + amount: 8.9, + memo: 'für deine Hilfe, Darth Vader', + }, + { + amount: 56.79, + memo: 'für deine Hilfe, Luci', + }, + { + amount: 3.45, + memo: 'für deine Hilfe, Hanne', + }, + { + amount: 8.74, + memo: 'für deine Hilfe, Luise', + }, + { + amount: 7.85, + memo: 'für deine Hilfe, Annegred', + }, + { + amount: 32.7, + memo: 'für deine Hilfe, Prinz von Zamunda', + }, + { + amount: 44.2, + memo: 'für deine Hilfe, Charly Brown', + }, + { + amount: 38.17, + memo: 'für deine Hilfe, Michael', + }, + { + amount: 5.72, + memo: 'für deine Hilfe, Kaja', + }, + { + amount: 3.99, + memo: 'für deine Hilfe, Maja', + }, + { + amount: 4.5, + memo: 'für deine Hilfe, Martha', + }, + { + amount: 8.3, + memo: 'für deine Hilfe, Ursula', + }, + { + amount: 2.9, + memo: 'für deine Hilfe, Urs', + }, + { + amount: 4.6, + memo: 'für deine Hilfe, Mecedes', + }, + { + amount: 74.1, + memo: 'für deine Hilfe, Heidi', + }, + { + amount: 4.5, + memo: 'für deine Hilfe, Peter', + }, + { + amount: 5.8, + memo: 'für deine Hilfe, Fräulein Rottenmeier', + }, +] +const bobsTransactions: CreationInterface[] = [] +bobsSendings.forEach((sending) => { + bobsTransactions.push({ + email: 'bob@baumeister.de', + amount: sending.amount, + memo: sending.memo, + creationDate: nMonthsBefore(new Date()), + confirmed: true, + }) +}) + export const creations: CreationInterface[] = [ { email: 'bibi@bloxberg.de', @@ -10,14 +135,7 @@ export const creations: CreationInterface[] = [ confirmed: true, moveCreationDate: 12, }, - { - email: 'bob@baumeister.de', - amount: 1000, - memo: 'Herzlich Willkommen bei Gradido!', - creationDate: nMonthsBefore(new Date()), - confirmed: true, - moveCreationDate: 8, - }, + ...bobsTransactions, { email: 'raeuber@hotzenplotz.de', amount: 1000, diff --git a/backend/src/seeds/factory/creation.ts b/backend/src/seeds/factory/creation.ts index 64f693360..e49be3758 100644 --- a/backend/src/seeds/factory/creation.ts +++ b/backend/src/seeds/factory/creation.ts @@ -17,27 +17,22 @@ export const nMonthsBefore = (date: Date, months = 1): string => { export const creationFactory = async ( client: ApolloServerTestClient, creation: CreationInterface, -): Promise => { +): Promise => { const { mutate, query } = client - // login as Peter Lustig (admin) and get his user ID - const { - data: { - login: { id }, - }, - } = await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } }) + await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } }) - await mutate({ mutation: createPendingCreation, variables: { ...creation, moderator: id } }) + // TODO it would be nice to have this mutation return the id + await mutate({ mutation: createPendingCreation, variables: { ...creation } }) - // get User const user = await User.findOneOrFail({ where: { email: creation.email } }) - if (creation.confirmed) { - const pendingCreation = await AdminPendingCreation.findOneOrFail({ - where: { userId: user.id }, - order: { created: 'DESC' }, - }) + const pendingCreation = await AdminPendingCreation.findOneOrFail({ + where: { userId: user.id, amount: creation.amount }, + order: { created: 'DESC' }, + }) + if (creation.confirmed) { await mutate({ mutation: confirmPendingCreation, variables: { id: pendingCreation.id } }) if (creation.moveCreationDate) { @@ -55,5 +50,7 @@ export const creationFactory = async ( await transaction.save() } } + } else { + return pendingCreation } } diff --git a/backend/src/seeds/factory/user.ts b/backend/src/seeds/factory/user.ts index 4b5913d48..d94f94b3c 100644 --- a/backend/src/seeds/factory/user.ts +++ b/backend/src/seeds/factory/user.ts @@ -7,7 +7,7 @@ import { ApolloServerTestClient } from 'apollo-server-testing' export const userFactory = async ( client: ApolloServerTestClient, user: UserInterface, -): Promise => { +): Promise => { const { mutate } = client const { @@ -24,13 +24,15 @@ export const userFactory = async ( }) } - if (user.createdAt || user.deletedAt || user.isAdmin) { - // get user from database - const dbUser = await User.findOneOrFail({ id }) + // get user from database + const dbUser = await User.findOneOrFail({ id }) + if (user.createdAt || user.deletedAt || user.isAdmin) { if (user.createdAt) dbUser.createdAt = user.createdAt if (user.deletedAt) dbUser.deletedAt = user.deletedAt if (user.isAdmin) dbUser.isAdmin = new Date() await dbUser.save() } + + return dbUser } diff --git a/backend/src/seeds/graphql/mutations.ts b/backend/src/seeds/graphql/mutations.ts index 601b1fbbf..4598cbbe2 100644 --- a/backend/src/seeds/graphql/mutations.ts +++ b/backend/src/seeds/graphql/mutations.ts @@ -84,20 +84,8 @@ export const createTransactionLink = gql` // from admin interface export const createPendingCreation = gql` - mutation ( - $email: String! - $amount: Decimal! - $memo: String! - $creationDate: String! - $moderator: Int! - ) { - createPendingCreation( - email: $email - amount: $amount - memo: $memo - creationDate: $creationDate - moderator: $moderator - ) + mutation ($email: String!, $amount: Decimal!, $memo: String!, $creationDate: String!) { + createPendingCreation(email: $email, amount: $amount, memo: $memo, creationDate: $creationDate) } ` @@ -106,3 +94,77 @@ export const confirmPendingCreation = gql` confirmPendingCreation(id: $id) } ` + +export const deleteUser = gql` + mutation ($userId: Int!) { + deleteUser(userId: $userId) + } +` + +export const unDeleteUser = gql` + mutation ($userId: Int!) { + unDeleteUser(userId: $userId) + } +` + +export const searchUsers = gql` + query ( + $searchText: String! + $currentPage: Int + $pageSize: Int + $filters: SearchUsersFiltersInput + ) { + searchUsers( + searchText: $searchText + currentPage: $currentPage + pageSize: $pageSize + filters: $filters + ) { + userCount + userList { + userId + firstName + lastName + email + creation + emailChecked + hasElopage + emailConfirmationSend + deletedAt + } + } + } +` + +export const createPendingCreations = gql` + mutation ($pendingCreations: [CreatePendingCreationArgs!]!) { + createPendingCreations(pendingCreations: $pendingCreations) { + success + successfulCreation + failedCreation + } + } +` + +export const updatePendingCreation = gql` + mutation ($id: Int!, $email: String!, $amount: Decimal!, $memo: String!, $creationDate: String!) { + updatePendingCreation( + id: $id + email: $email + amount: $amount + memo: $memo + creationDate: $creationDate + ) { + amount + date + memo + creation + } + } +` + +export const deletePendingCreation = gql` + mutation ($id: Int!) { + deletePendingCreation(id: $id) + } +` diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index 76a386953..82067c968 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -148,3 +148,21 @@ export const queryTransactionLink = gql` } } ` + +// from admin interface + +export const getPendingCreations = gql` + query { + getPendingCreations { + id + firstName + lastName + email + amount + memo + date + moderator + creation + } + } +` diff --git a/backend/src/seeds/index.ts b/backend/src/seeds/index.ts index 37c9992a7..710f255ee 100644 --- a/backend/src/seeds/index.ts +++ b/backend/src/seeds/index.ts @@ -4,7 +4,7 @@ import createServer from '../server/createServer' import { createTestClient } from 'apollo-server-testing' -import { name, internet, random } from 'faker' +import { name, internet, datatype } from 'faker' import { users } from './users/index' import { creations } from './creation/index' @@ -13,6 +13,9 @@ import { userFactory } from './factory/user' import { creationFactory } from './factory/creation' import { transactionLinkFactory } from './factory/transactionLink' import { entities } from '@entity/index' +import CONFIG from '@/config' + +CONFIG.EMAIL = false const context = { token: '', @@ -26,7 +29,7 @@ const context = { } export const cleanDB = async () => { - // this only works as lond we do not have foreign key constraints + // this only works as long we do not have foreign key constraints for (let i = 0; i < entities.length; i++) { await resetEntity(entities[i]) } @@ -57,13 +60,16 @@ const run = async () => { firstName: name.firstName(), lastName: name.lastName(), email: internet.email(), - language: random.boolean() ? 'en' : 'de', + language: datatype.boolean() ? 'en' : 'de', }) } // create GDD for (let i = 0; i < creations.length; i++) { + const now = new Date().getTime() // we have to wait a little! quick fix for account sum problem of bob@baumeister.de, (see https://github.com/gradido/gradido/issues/1886) await creationFactory(seedClient, creations[i]) + // eslint-disable-next-line no-empty + while (new Date().getTime() < now + 1000) {} // we have to wait a little! quick fix for account sum problem of bob@baumeister.de, (see https://github.com/gradido/gradido/issues/1886) } // create Transaction Links diff --git a/backend/src/server/createServer.ts b/backend/src/server/createServer.ts index 8315fda58..a0b294281 100644 --- a/backend/src/server/createServer.ts +++ b/backend/src/server/createServer.ts @@ -22,22 +22,32 @@ import schema from '@/graphql/schema' import { elopageWebhook } from '@/webhook/elopage' import { Connection } from '@dbTools/typeorm' +import { apolloLogger } from './logger' +import { Logger } from 'log4js' + // TODO implement // import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity"; type ServerDef = { apollo: ApolloServer; app: Express; con: Connection } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const createServer = async (context: any = serverContext): Promise => { +const createServer = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: any = serverContext, + logger: Logger = apolloLogger, +): Promise => { + logger.debug('createServer...') + // open mysql connection const con = await connection() if (!con || !con.isConnected) { + logger.fatal(`Couldn't open connection to database!`) throw new Error(`Fatal: Couldn't open connection to database`) } // check for correct database version const dbVersion = await checkDBVersion(CONFIG.DB_VERSION) if (!dbVersion) { + logger.fatal('Fatal: Database Version incorrect') throw new Error('Fatal: Database Version incorrect') } @@ -62,8 +72,10 @@ const createServer = async (context: any = serverContext): Promise => introspection: CONFIG.GRAPHIQL, context, plugins, + logger, }) apollo.applyMiddleware({ app, path: '/' }) + logger.debug('createServer...successful') return { apollo, app, con } } diff --git a/backend/src/server/logger.ts b/backend/src/server/logger.ts new file mode 100644 index 000000000..939d7eaba --- /dev/null +++ b/backend/src/server/logger.ts @@ -0,0 +1,18 @@ +import log4js from 'log4js' +import CONFIG from '@/config' + +import { readFileSync } from 'fs' + +const options = JSON.parse(readFileSync(CONFIG.LOG4JS_CONFIG, 'utf-8')) + +options.categories.default.level = CONFIG.LOG_LEVEL + +log4js.configure(options) + +const apolloLogger = log4js.getLogger('apollo') +const backendLogger = log4js.getLogger('backend') + +apolloLogger.addContext('user', 'unknown') +backendLogger.addContext('user', 'unknown') + +export { apolloLogger, backendLogger } diff --git a/backend/src/server/plugins.ts b/backend/src/server/plugins.ts index a407135ea..f3067d44a 100644 --- a/backend/src/server/plugins.ts +++ b/backend/src/server/plugins.ts @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import { ApolloLogPlugin, LogMutateData } from 'apollo-log' -import cloneDeep from 'lodash.clonedeep' +import clonedeep from 'lodash.clonedeep' const setHeadersPlugin = { requestDidStart() { @@ -22,24 +21,35 @@ const setHeadersPlugin = { }, } -const apolloLogPlugin = ApolloLogPlugin({ - mutate: (data: LogMutateData) => { - // We need to deep clone the object in order to not modify the actual request - const dataCopy = cloneDeep(data) +const filterVariables = (variables: any) => { + const vars = clonedeep(variables) + if (vars.password) vars.password = '***' + if (vars.passwordNew) vars.passwordNew = '***' + return vars +} - // mask password if part of the query - if (dataCopy.context.request.variables && dataCopy.context.request.variables.password) { - dataCopy.context.request.variables.password = '***' +const logPlugin = { + requestDidStart(requestContext: any) { + const { logger } = requestContext + const { query, mutation, variables } = requestContext.request + logger.trace(`Request: +${mutation || query}variables: ${JSON.stringify(filterVariables(variables), null, 2)}`) + return { + willSendResponse(requestContext: any) { + if (requestContext.context.user) logger.trace(`User ID: ${requestContext.context.user.id}`) + if (requestContext.response.data) + logger.trace(`Response-Data: +${JSON.stringify(requestContext.response.data, null, 2)}`) + if (requestContext.response.errors) + logger.trace(`Response-Errors: +${JSON.stringify(requestContext.response.errors, null, 2)}`) + return requestContext + }, } - - // mask token at all times - dataCopy.context.context.token = '***' - - return dataCopy }, -}) +} const plugins = - process.env.NODE_ENV === 'development' ? [setHeadersPlugin] : [setHeadersPlugin, apolloLogPlugin] + process.env.NODE_ENV === 'development' ? [setHeadersPlugin] : [setHeadersPlugin, logPlugin] export default plugins diff --git a/backend/src/typeorm/DBVersion.ts b/backend/src/typeorm/DBVersion.ts index a8cb70489..cb53c49f1 100644 --- a/backend/src/typeorm/DBVersion.ts +++ b/backend/src/typeorm/DBVersion.ts @@ -1,12 +1,12 @@ import { Migration } from '@entity/Migration' +import { backendLogger as logger } from '@/server/logger' const getDBVersion = async (): Promise => { try { const dbVersion = await Migration.findOne({ order: { version: 'DESC' } }) return dbVersion ? dbVersion.fileName : null } catch (error) { - // eslint-disable-next-line no-console - console.log(error) + logger.error(error) return null } } @@ -14,8 +14,7 @@ const getDBVersion = async (): Promise => { const checkDBVersion = async (DB_VERSION: string): Promise => { const dbVersion = await getDBVersion() if (!dbVersion || dbVersion.indexOf(DB_VERSION) === -1) { - // eslint-disable-next-line no-console - console.log( + logger.error( `Wrong database version detected - the backend requires '${DB_VERSION}' but found '${ dbVersion || 'None' }`, diff --git a/backend/src/typeorm/connection.ts b/backend/src/typeorm/connection.ts index 745b2da94..d08d935d4 100644 --- a/backend/src/typeorm/connection.ts +++ b/backend/src/typeorm/connection.ts @@ -20,6 +20,9 @@ const connection = async (): Promise => { logger: new FileLogger('all', { logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH, }), + extra: { + charset: 'utf8mb4_unicode_ci', + }, }) } catch (error) { // eslint-disable-next-line no-console diff --git a/backend/src/util/utilities.ts b/backend/src/util/utilities.ts new file mode 100644 index 000000000..f77ad05ec --- /dev/null +++ b/backend/src/util/utilities.ts @@ -0,0 +1,5 @@ +export const convertObjValuesToArray = (obj: { [x: string]: string }): Array => { + return Object.keys(obj).map(function (key) { + return obj[key] + }) +} diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 51610b07e..6e1856b63 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -25,8 +25,8 @@ export const cleanDB = async () => { } } -export const testEnvironment = async () => { - const server = await createServer(context) +export const testEnvironment = async (logger?: any) => { + const server = await createServer(context, logger) const con = server.con const testClient = createTestClient(server.apollo) const mutate = testClient.mutate diff --git a/backend/test/testSetup.ts b/backend/test/testSetup.ts index d42836626..a43335e55 100644 --- a/backend/test/testSetup.ts +++ b/backend/test/testSetup.ts @@ -1,7 +1,22 @@ -/* eslint-disable no-console */ +import { backendLogger as logger } from '@/server/logger' -// disable console.info for apollo log - -// eslint-disable-next-line @typescript-eslint/no-empty-function -console.info = () => {} jest.setTimeout(1000000) + +jest.mock('@/server/logger', () => { + const originalModule = jest.requireActual('@/server/logger') + return { + __esModule: true, + ...originalModule, + backendLogger: { + addContext: jest.fn(), + trace: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + fatal: jest.fn(), + }, + } +}) + +export { logger } diff --git a/backend/yarn.lock b/backend/yarn.lock index f37b64d11..53a53cb9b 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@apollo/protobufjs@1.2.2", "@apollo/protobufjs@^1.0.3": +"@apollo/protobufjs@1.2.2": version "1.2.2" resolved "https://registry.yarnpkg.com/@apollo/protobufjs/-/protobufjs-1.2.2.tgz#4bd92cd7701ccaef6d517cdb75af2755f049f87c" integrity sha512-vF+zxhPiLtkwxONs6YanSt1EpwpGilThpneExUN5K3tCymuxNnVq2yojTvnpRjv2QfsEIt/n7ozPIIzBLwGIDQ== @@ -1265,24 +1265,6 @@ apollo-link@^1.2.14: tslib "^1.9.3" zen-observable-ts "^0.8.21" -apollo-log@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/apollo-log/-/apollo-log-1.1.0.tgz#e21287c917cf735b77adc06f07034f965e9b24de" - integrity sha512-TciLu+85LSqk7t7ZGKrYN5jFiCcRMLujBjrLiOQGHGgVVkvmKlwK0oELSS9kiHQIhTq23p8qVVWb08spLpQ7Jw== - dependencies: - apollo-server-plugin-base "^0.10.4" - chalk "^4.1.0" - fast-safe-stringify "^2.0.7" - loglevelnext "^4.0.1" - nanoid "^3.1.20" - -apollo-reporting-protobuf@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/apollo-reporting-protobuf/-/apollo-reporting-protobuf-0.6.2.tgz#5572866be9b77f133916532b10e15fbaa4158304" - integrity sha512-WJTJxLM+MRHNUxt1RTl4zD0HrLdH44F2mDzMweBj1yHL0kSt8I1WwoiF/wiGVSpnG48LZrBegCaOJeuVbJTbtw== - dependencies: - "@apollo/protobufjs" "^1.0.3" - apollo-reporting-protobuf@^0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/apollo-reporting-protobuf/-/apollo-reporting-protobuf-0.8.0.tgz#ae9d967934d3d8ed816fc85a0d8068ef45c371b9" @@ -1290,13 +1272,6 @@ apollo-reporting-protobuf@^0.8.0: dependencies: "@apollo/protobufjs" "1.2.2" -apollo-server-caching@^0.5.3: - version "0.5.3" - resolved "https://registry.yarnpkg.com/apollo-server-caching/-/apollo-server-caching-0.5.3.tgz#cf42a77ad09a46290a246810075eaa029b5305e1" - integrity sha512-iMi3087iphDAI0U2iSBE9qtx9kQoMMEWr6w+LwXruBD95ek9DWyj7OeC2U/ngLjRsXM43DoBDXlu7R+uMjahrQ== - dependencies: - lru-cache "^6.0.0" - apollo-server-caching@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/apollo-server-caching/-/apollo-server-caching-0.7.0.tgz#e6d1e68e3bb571cba63a61f60b434fb771c6ff39" @@ -1335,7 +1310,7 @@ apollo-server-core@^2.25.2: subscriptions-transport-ws "^0.9.19" uuid "^8.0.0" -apollo-server-env@^3.0.0, apollo-server-env@^3.1.0: +apollo-server-env@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/apollo-server-env/-/apollo-server-env-3.1.0.tgz#0733c2ef50aea596cc90cf40a53f6ea2ad402cd0" integrity sha512-iGdZgEOAuVop3vb0F2J3+kaBVi4caMoxefHosxmgzAbbSpvWehB8Y1QiSyyMeouYC38XNVk5wnZl+jdGSsWsIQ== @@ -1371,13 +1346,6 @@ apollo-server-express@^2.25.2: subscriptions-transport-ws "^0.9.19" type-is "^1.6.16" -apollo-server-plugin-base@^0.10.4: - version "0.10.4" - resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.10.4.tgz#fbf73f64f95537ca9f9639dd7c535eb5eeb95dcd" - integrity sha512-HRhbyHgHFTLP0ImubQObYhSgpmVH4Rk1BinnceZmwudIVLKrqayIVOELdyext/QnSmmzg5W7vF3NLGBcVGMqDg== - dependencies: - apollo-server-types "^0.6.3" - apollo-server-plugin-base@^0.13.0: version "0.13.0" resolved "https://registry.yarnpkg.com/apollo-server-plugin-base/-/apollo-server-plugin-base-0.13.0.tgz#3f85751a420d3c4625355b6cb3fbdd2acbe71f13" @@ -1392,15 +1360,6 @@ apollo-server-testing@^2.25.2: dependencies: apollo-server-core "^2.25.2" -apollo-server-types@^0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.6.3.tgz#f7aa25ff7157863264d01a77d7934aa6e13399e8" - integrity sha512-aVR7SlSGGY41E1f11YYz5bvwA89uGmkVUtzMiklDhZ7IgRJhysT5Dflt5IuwDxp+NdQkIhVCErUXakopocFLAg== - dependencies: - apollo-reporting-protobuf "^0.6.2" - apollo-server-caching "^0.5.3" - apollo-server-env "^3.0.0" - apollo-server-types@^0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/apollo-server-types/-/apollo-server-types-0.9.0.tgz#ccf550b33b07c48c72f104fbe2876232b404848b" @@ -1952,6 +1911,11 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" +date-format@^4.0.9: + version "4.0.9" + resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.9.tgz#4788015ac56dedebe83b03bc361f00c1ddcf1923" + integrity sha512-+8J+BOUpSrlKLQLeF8xJJVTxS8QfRSuJgwxSVvslzgO3E6khbI0F5mMEPf5mTYhCCm4h99knYP6H3W9n3BQFrg== + debug@2.6.9, debug@^2.2.0, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -1973,6 +1937,13 @@ debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + decimal.js-light@^2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" @@ -2558,11 +2529,6 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= -fast-safe-stringify@^2.0.7: - version "2.1.1" - resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" - integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== - fastq@^1.6.0: version "1.13.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" @@ -2632,6 +2598,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561" integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA== +flatted@^3.2.5: + version "3.2.5" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3" + integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg== + follow-redirects@^1.14.0: version "1.14.4" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379" @@ -2668,6 +2639,15 @@ fs-capacitor@^2.0.4: resolved "https://registry.yarnpkg.com/fs-capacitor/-/fs-capacitor-2.0.4.tgz#5a22e72d40ae5078b4fe64fe4d08c0d3fc88ad3c" integrity sha512-8S4f4WsCryNw2mJJchi46YgB6CR5Ze+4L1h8ewl9tEpL4SJ3ZO+c/bS4BWhB8bK+O3TMqhuZarTitd0S0eh2pA== +fs-extra@^10.1.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -2818,6 +2798,11 @@ graceful-fs@^4.1.2, graceful-fs@^4.2.4: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== + graphql-extensions@^0.15.0: version "0.15.0" resolved "https://registry.yarnpkg.com/graphql-extensions/-/graphql-extensions-0.15.0.tgz#3f291f9274876b0c289fa4061909a12678bd9817" @@ -3810,6 +3795,15 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + jsonwebtoken@^8.5.1: version "8.5.1" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d" @@ -3978,16 +3972,22 @@ lodash@4.x, lodash@^4.7.0: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +log4js@^6.4.6: + version "6.4.6" + resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.4.6.tgz#1878aa3f09973298ecb441345fe9dd714e355c15" + integrity sha512-1XMtRBZszmVZqPAOOWczH+Q94AI42mtNWjvjA5RduKTSWjEc56uOBbyM1CJnfN4Ym0wSd8cQ43zOojlSHgRDAw== + dependencies: + date-format "^4.0.9" + debug "^4.3.4" + flatted "^3.2.5" + rfdc "^1.3.0" + streamroller "^3.0.8" + loglevel@^1.6.7: version "1.7.1" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.7.1.tgz#005fde2f5e6e47068f935ff28573e125ef72f197" integrity sha512-Hesni4s5UkWkwCGJMQGAh71PaLUmKFM60dHvq0zi/vDhhrzuk+4GgNbTXJ12YYQJn6ZKBDNIjYcuQGKudvqrIw== -loglevelnext@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/loglevelnext/-/loglevelnext-4.0.1.tgz#4406c6348c243a35272ac75d7d8e4e60ecbcd011" - integrity sha512-/tlMUn5wqgzg9msy0PiWc+8fpVXEuYPq49c2RGyw2NAh0hSrgq6j/Z3YPnwWsILMoFJ+ZT6ePHnWUonkjDnq2Q== - long@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" @@ -4150,11 +4150,6 @@ named-placeholders@^1.1.2: dependencies: lru-cache "^4.1.3" -nanoid@^3.1.20: - version "3.1.32" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.32.tgz#8f96069e6239cc0a9ae8c0d3b41a3b4933a88c0a" - integrity sha512-F8mf7R3iT9bvThBoW4tGXhXFHCctyCiUUPrWF8WaTqa3h96d9QybkSeba43XVOOE3oiLfkVDe4bT8MeGmkrTxw== - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -4746,6 +4741,11 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== +rfdc@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" + integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== + rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" @@ -4981,6 +4981,15 @@ stack-utils@^2.0.3: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= +streamroller@^3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.0.8.tgz#84b190e4080ee311ca1ebe0444e30ac8eedd028d" + integrity sha512-VI+ni3czbFZrd1MrlybxykWZ8sMDCMtTU7YJyhgb9M5X6d1DDxLdJr+gSnmRpXPMnIWxWKMaAE8K0WumBp3lDg== + dependencies: + date-format "^4.0.9" + debug "^4.3.4" + fs-extra "^10.1.0" + streamsearch@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" @@ -5363,6 +5372,11 @@ universalify@^0.1.2: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" + integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== + unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" diff --git a/database/.gitignore b/database/.gitignore index bce5da58f..9e9e01ced 100644 --- a/database/.gitignore +++ b/database/.gitignore @@ -24,4 +24,4 @@ package-lock.json coverage/ -*~ \ No newline at end of file +*~ diff --git a/database/.prettierrc.js b/database/.prettierrc.js index 8495e3f20..bc1d767d7 100644 --- a/database/.prettierrc.js +++ b/database/.prettierrc.js @@ -5,4 +5,5 @@ module.exports = { trailingComma: "all", tabWidth: 2, bracketSpacing: true, + endOfLine: "auto", }; diff --git a/database/entity/0036-unique_previous_in_transactions/Transaction.ts b/database/entity/0036-unique_previous_in_transactions/Transaction.ts new file mode 100644 index 000000000..99202eee4 --- /dev/null +++ b/database/entity/0036-unique_previous_in_transactions/Transaction.ts @@ -0,0 +1,94 @@ +import Decimal from 'decimal.js-light' +import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm' +import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer' + +@Entity('transactions') +export class Transaction extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ name: 'user_id', unsigned: true, nullable: false }) + userId: number + + @Column({ type: 'int', unsigned: true, unique: true, nullable: true, default: null }) + previous: number | null + + @Column({ name: 'type_id', unsigned: true, nullable: false }) + typeId: number + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + amount: Decimal + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + balance: Decimal + + @Column({ + name: 'balance_date', + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + nullable: false, + }) + balanceDate: Date + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + decay: Decimal + + @Column({ + name: 'decay_start', + type: 'datetime', + nullable: true, + default: null, + }) + decayStart: Date | null + + @Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) + memo: string + + @Column({ name: 'creation_date', type: 'datetime', nullable: true, default: null }) + creationDate: Date | null + + @Column({ + name: 'linked_user_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + linkedUserId?: number | null + + @Column({ + name: 'linked_transaction_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + linkedTransactionId?: number | null + + @Column({ + name: 'transaction_link_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + transactionLinkId?: number | null +} diff --git a/database/entity/Transaction.ts b/database/entity/Transaction.ts index 3515202d0..5365b0f70 100644 --- a/database/entity/Transaction.ts +++ b/database/entity/Transaction.ts @@ -1 +1 @@ -export { Transaction } from './0032-add-transaction-link-to-transaction/Transaction' +export { Transaction } from './0036-unique_previous_in_transactions/Transaction' diff --git a/database/migrations/0026-combine_transaction_tables2.ts b/database/migrations/0026-combine_transaction_tables2.ts index 3abf77354..b83c5e267 100644 --- a/database/migrations/0026-combine_transaction_tables2.ts +++ b/database/migrations/0026-combine_transaction_tables2.ts @@ -28,7 +28,9 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis */ // rename `state_user_id` to `user_id` - await queryFn('ALTER TABLE `state_user_transactions` RENAME COLUMN state_user_id TO user_id;') + await queryFn( + 'ALTER TABLE `state_user_transactions` CHANGE COLUMN state_user_id user_id int(10);', + ) // Create new `amount` column, with a temporary default of null await queryFn( 'ALTER TABLE `state_user_transactions` ADD COLUMN `amount` bigint(20) DEFAULT NULL AFTER `transaction_type_id`;', @@ -214,5 +216,7 @@ export async function downgrade(queryFn: (query: string, values?: any[]) => Prom await queryFn('ALTER TABLE `state_user_transactions` DROP COLUMN `memo`;') await queryFn('ALTER TABLE `state_user_transactions` DROP COLUMN `send_sender_final_balance`;') await queryFn('ALTER TABLE `state_user_transactions` DROP COLUMN `amount`;') - await queryFn('ALTER TABLE `state_user_transactions` RENAME COLUMN user_id TO state_user_id;') + await queryFn( + 'ALTER TABLE `state_user_transactions` CHANGE COLUMN user_id state_user_id int(10);', + ) } diff --git a/database/migrations/0027-clean_transaction_table.ts b/database/migrations/0027-clean_transaction_table.ts index b5a0e0e2e..4a427e693 100644 --- a/database/migrations/0027-clean_transaction_table.ts +++ b/database/migrations/0027-clean_transaction_table.ts @@ -11,23 +11,23 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { // drop column `transaction_id`, it is not needed - await queryFn('ALTER TABLE `transactions` DROP COLUMN `transaction_id`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `transaction_id`;') // drop column `received`, it is a duplicate of balance_date - await queryFn('ALTER TABLE `transactions` DROP COLUMN `received`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `received`;') // drop column `tx_hash`, it is not needed - await queryFn('ALTER TABLE `transactions` DROP COLUMN `tx_hash`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `tx_hash`;') // drop column `signature`, it is not needed - await queryFn('ALTER TABLE `transactions` DROP COLUMN `signature`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `signature`;') // drop column `pubkey`, it is not needed - await queryFn('ALTER TABLE `transactions` DROP COLUMN `pubkey`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `pubkey`;') // drop column `creation_ident_hash`, it is not needed - await queryFn('ALTER TABLE `transactions` DROP COLUMN `creation_ident_hash`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `creation_ident_hash`;') // rename `transaction_type_id` to `type_id` - await queryFn('ALTER TABLE `transactions` RENAME COLUMN transaction_type_id TO type_id;') + await queryFn('ALTER TABLE `transactions` CHANGE COLUMN transaction_type_id type_id int(10);') // rename `linked_state_user_transaction_id` to `linked_transaction_id` await queryFn( - 'ALTER TABLE `transactions` RENAME COLUMN linked_state_user_transaction_id TO linked_transaction_id;', + 'ALTER TABLE `transactions` CHANGE COLUMN linked_state_user_transaction_id linked_transaction_id int(10);', ) } @@ -41,9 +41,9 @@ export async function downgrade(queryFn: (query: string, values?: any[]) => Prom // - creation_ident_hash (null) await queryFn( - 'ALTER TABLE `transactions` RENAME COLUMN linked_transaction_id TO linked_state_user_transaction_id;', + 'ALTER TABLE `transactions` CHANGE COLUMN linked_transaction_id linked_state_user_transaction_id int(10);', ) - await queryFn('ALTER TABLE `transactions` RENAME COLUMN type_id TO transaction_type_id;') + await queryFn('ALTER TABLE `transactions` CHANGE COLUMN type_id transaction_type_id int(10);') await queryFn( 'ALTER TABLE `transactions` ADD COLUMN `creation_ident_hash` binary(32) DEFAULT NULL AFTER `linked_state_user_transaction_id`;', ) diff --git a/database/migrations/0029-clean_transaction_table.ts b/database/migrations/0029-clean_transaction_table.ts index 0b9e2cc0d..c47524b8e 100644 --- a/database/migrations/0029-clean_transaction_table.ts +++ b/database/migrations/0029-clean_transaction_table.ts @@ -13,35 +13,39 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promis // Delete columns // delete column `amount` - await queryFn('ALTER TABLE `transactions` DROP COLUMN `amount`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `amount`;') // delete column `send_sender_final_balance` - await queryFn('ALTER TABLE `transactions` DROP COLUMN `send_sender_final_balance`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `send_sender_final_balance`;') // delete column `balance` - await queryFn('ALTER TABLE `transactions` DROP COLUMN `balance`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `balance`;') // delete column `temp_dec_send_sender_final_balance` - await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_send_sender_final_balance`;') + await queryFn( + 'ALTER TABLE `transactions` DROP COLUMN IF EXISTS `temp_dec_send_sender_final_balance`;', + ) // delete column `temp_dec_diff_send_sender_final_balance` - await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_diff_send_sender_final_balance`;') + await queryFn( + 'ALTER TABLE `transactions` DROP COLUMN IF EXISTS `temp_dec_diff_send_sender_final_balance`;', + ) // delete column `temp_dec_old_balance` - await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_old_balance`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `temp_dec_old_balance`;') // delete column `temp_dec_diff_balance` - await queryFn('ALTER TABLE `transactions` DROP COLUMN `temp_dec_diff_balance`;') + await queryFn('ALTER TABLE `transactions` DROP COLUMN IF EXISTS `temp_dec_diff_balance`;') // Rename columns // rename column `dec_amount` to `amount` - await queryFn('ALTER TABLE `transactions` RENAME COLUMN `dec_amount` to `amount`;') + await queryFn('ALTER TABLE `transactions` CHANGE COLUMN `dec_amount` `amount` DECIMAL(40,20);') // rename column `dec_balance` to `balance` - await queryFn('ALTER TABLE `transactions` RENAME COLUMN `dec_balance` to `balance`;') + await queryFn('ALTER TABLE `transactions` CHANGE COLUMN `dec_balance` `balance` DECIMAL(40,20);') // rename column `dec_decay` to `decay` - await queryFn('ALTER TABLE `transactions` RENAME COLUMN `dec_decay` to `decay`;') + await queryFn('ALTER TABLE `transactions` CHANGE COLUMN `dec_decay` `decay` DECIMAL(40,20);') // Drop tables // drop `state_balances` - await queryFn('DROP TABLE `state_balances`;') + await queryFn('DROP TABLE IF EXISTS `state_balances`;') } export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { @@ -66,9 +70,9 @@ export async function downgrade(queryFn: (query: string, values?: any[]) => Prom LEFT JOIN transactions ON t.uid = transactions.user_id AND t.date = transactions.balance_date; `) - await queryFn('ALTER TABLE `transactions` RENAME COLUMN `decay` to `dec_decay`;') - await queryFn('ALTER TABLE `transactions` RENAME COLUMN `balance` to `dec_balance`;') - await queryFn('ALTER TABLE `transactions` RENAME COLUMN `amount` to `dec_amount`;') + await queryFn('ALTER TABLE `transactions` CHANGE COLUMN `decay` `dec_decay` DECIMAL(40,20);') + await queryFn('ALTER TABLE `transactions` CHANGE COLUMN `balance` `dec_balance` DECIMAL(40,20);') + await queryFn('ALTER TABLE `transactions` CHANGE COLUMN `amount` `dec_amount` DECIMAL(40,20);') await queryFn( 'ALTER TABLE `transactions` ADD COLUMN `temp_dec_diff_balance` decimal(40,20) DEFAULT NULL AFTER linked_transaction_id;', diff --git a/database/migrations/0035-admin_pending_creations_decimal.ts b/database/migrations/0035-admin_pending_creations_decimal.ts index d3648f376..f76db7c97 100644 --- a/database/migrations/0035-admin_pending_creations_decimal.ts +++ b/database/migrations/0035-admin_pending_creations_decimal.ts @@ -8,7 +8,9 @@ export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { // rename `amount` to `amount_bigint` - await queryFn('ALTER TABLE `admin_pending_creations` RENAME COLUMN `amount` TO `amount_bigint`;') + await queryFn( + 'ALTER TABLE `admin_pending_creations` CHANGE COLUMN `amount` `amount_bigint` bigint(20);', + ) // add `amount` (decimal) await queryFn( 'ALTER TABLE `admin_pending_creations` ADD COLUMN `amount` DECIMAL(40,20) DEFAULT NULL AFTER `amount_bigint`;', @@ -37,6 +39,8 @@ export async function downgrade(queryFn: (query: string, values?: any[]) => Prom await queryFn( 'ALTER TABLE `admin_pending_creations` MODIFY COLUMN `amount_bigint` bigint(20) NOT NULL;', ) - await queryFn('ALTER TABLE `admin_pending_creations` DROP COLUMN `amount`;') - await queryFn('ALTER TABLE `admin_pending_creations` RENAME COLUMN `amount_bigint` TO `amount`;') + await queryFn('ALTER TABLE `admin_pending_creations` DROP COLUMN IF EXISTS `amount`;') + await queryFn( + 'ALTER TABLE `admin_pending_creations` CHANGE COLUMN `amount_bigint` `amount` bigint(20);', + ) } diff --git a/database/migrations/0036-unique_previous_in_transactions.ts b/database/migrations/0036-unique_previous_in_transactions.ts new file mode 100644 index 000000000..f05b044bb --- /dev/null +++ b/database/migrations/0036-unique_previous_in_transactions.ts @@ -0,0 +1,13 @@ +/* MIGRATION TO SET previous COLUMN UNIQUE in TRANSACTION table + */ + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn('ALTER TABLE `transactions` ADD UNIQUE(`previous`);') +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn('ALTER TABLE `transactions` DROP INDEX `previous`;') +} diff --git a/database/package.json b/database/package.json index 13c638c79..f5a16fd31 100644 --- a/database/package.json +++ b/database/package.json @@ -1,6 +1,6 @@ { "name": "gradido-database", - "version": "1.8.0", + "version": "1.8.3", "description": "Gradido Database Tool to execute database migrations", "main": "src/index.ts", "repository": "https://github.com/gradido/gradido/database", diff --git a/deployment/bare_metal/.env.dist b/deployment/bare_metal/.env.dist index a7e266bdf..a1751a859 100644 --- a/deployment/bare_metal/.env.dist +++ b/deployment/bare_metal/.env.dist @@ -38,7 +38,7 @@ KLICKTIPP_PASSWORD= KLICKTIPP_APIKEY_DE= KLICKTIPP_APIKEY_EN= -EMAIL=false +EMAIL=true EMAIL_USERNAME=peter@lustig.de EMAIL_SENDER=peter@lustig.de EMAIL_PASSWORD=1234 diff --git a/deployment/bare_metal/setup.md b/deployment/bare_metal/setup.md index f39228879..f43a3d655 100644 --- a/deployment/bare_metal/setup.md +++ b/deployment/bare_metal/setup.md @@ -2,6 +2,9 @@ # This assums you have root access via ssh to your cleanly setup server # Furthermore this assumes you have debian (11 64bit) running +# Check your (Sub-)Domain with your Provider. +# In this document gddhost.tld refers to your chosen domain + > ssh root@gddhost.tld # change root default shell @@ -87,9 +90,10 @@ # Adjust .env # NOTE ';' can not be part of any value +# The Github Secret is Created on Github in Settimgs -> Webhooks > cd gradido/deployment/bare_metal > cp .env.dist .env > nano .env >> Adjust values accordingly # TODO the install.sh is not yet ready to run directly - consider to use it as pattern to do it manually -> ./install.sh \ No newline at end of file +> ./install.sh diff --git a/docu/Style/Images/Checkbox_aktiv.svg b/docu/Style/Images/Checkbox_aktiv.svg new file mode 100644 index 000000000..3ee7c1b24 --- /dev/null +++ b/docu/Style/Images/Checkbox_aktiv.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docu/Style/Images/Checkbox_deaktiv.svg b/docu/Style/Images/Checkbox_deaktiv.svg new file mode 100644 index 000000000..4770d4b33 --- /dev/null +++ b/docu/Style/Images/Checkbox_deaktiv.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docu/Style/Images/Footer_banner_1920x13.png b/docu/Style/Images/Footer_banner_1920x13.png new file mode 100644 index 000000000..878c82c60 Binary files /dev/null and b/docu/Style/Images/Footer_banner_1920x13.png differ diff --git a/docu/Style/Images/Footer_banner_396x13.png b/docu/Style/Images/Footer_banner_396x13.png new file mode 100644 index 000000000..cd0f50750 Binary files /dev/null and b/docu/Style/Images/Footer_banner_396x13.png differ diff --git a/docu/Style/Images/Footer_banner_768x13.png b/docu/Style/Images/Footer_banner_768x13.png new file mode 100644 index 000000000..bea5d2f8f Binary files /dev/null and b/docu/Style/Images/Footer_banner_768x13.png differ diff --git a/docu/Style/Images/Footer_gradient.txt b/docu/Style/Images/Footer_gradient.txt new file mode 100644 index 000000000..2913bb0a9 --- /dev/null +++ b/docu/Style/Images/Footer_gradient.txt @@ -0,0 +1,5 @@ +background: linear-gradient(90deg, rgba(197,141,56,1) 6%, rgba(243,205,124,1) 30%, rgba(219,176,86,1) 54%, rgba(238,192,95,1) 63%, rgba(204,157,61,1) 88%); + +height: 13px + + diff --git a/frontend/.gitignore b/frontend/.gitignore index b19667d17..0a541ba06 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,4 +23,4 @@ package-lock.json coverage/ -*~ \ No newline at end of file +*~ diff --git a/frontend/.prettierrc.js b/frontend/.prettierrc.js index e88113754..bc1d767d7 100644 --- a/frontend/.prettierrc.js +++ b/frontend/.prettierrc.js @@ -4,5 +4,6 @@ module.exports = { singleQuote: true, trailingComma: "all", tabWidth: 2, - bracketSpacing: true + bracketSpacing: true, + endOfLine: "auto", }; diff --git a/frontend/package.json b/frontend/package.json index 18021e705..9d70ace58 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bootstrap-vue-gradido-wallet", - "version": "1.8.0", + "version": "1.8.3", "private": true, "scripts": { "start": "node run/server.js", @@ -25,6 +25,7 @@ "babel-preset-vue": "^2.0.2", "bootstrap": "^4.5.3", "bootstrap-vue": "^2.21.2", + "clipboard-polyfill": "^4.0.0-rc1", "es6-promise": "^4.1.1", "eslint": "^7.25.0", "eslint-config-prettier": "^8.1.0", diff --git a/frontend/public/img/brand/gradido_coin●.png b/frontend/public/img/brand/gradido_coin●.png new file mode 100644 index 000000000..15a5182da Binary files /dev/null and b/frontend/public/img/brand/gradido_coin●.png differ diff --git a/frontend/public/img/svg/type.svg b/frontend/public/img/svg/type.svg new file mode 100644 index 000000000..9ab1e4c48 --- /dev/null +++ b/frontend/public/img/svg/type.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/public/img/template/Blaetter.png b/frontend/public/img/template/Blaetter.png new file mode 100644 index 000000000..af11b67f2 Binary files /dev/null and b/frontend/public/img/template/Blaetter.png differ diff --git a/frontend/public/img/template/Foto_01.jpg b/frontend/public/img/template/Foto_01.jpg new file mode 100644 index 000000000..3e64e27a5 Binary files /dev/null and b/frontend/public/img/template/Foto_01.jpg differ diff --git a/frontend/public/img/template/gold_03.png b/frontend/public/img/template/gold_03.png new file mode 100644 index 000000000..0704c997a Binary files /dev/null and b/frontend/public/img/template/gold_03.png differ diff --git a/frontend/public/img/template/gradido_background_header.png b/frontend/public/img/template/gradido_background_header.png new file mode 100644 index 000000000..db651686f Binary files /dev/null and b/frontend/public/img/template/gradido_background_header.png differ diff --git a/frontend/public/img/template/logo-header.png b/frontend/public/img/template/logo-header.png new file mode 100644 index 000000000..b35dda73d Binary files /dev/null and b/frontend/public/img/template/logo-header.png differ diff --git a/frontend/src/components/GddTransactionList.spec.js b/frontend/src/components/GddTransactionList.spec.js index 37152c1c2..a6d0ef935 100644 --- a/frontend/src/components/GddTransactionList.spec.js +++ b/frontend/src/components/GddTransactionList.spec.js @@ -407,29 +407,34 @@ describe('GddTransactionList', () => { }) describe('pagination buttons', () => { + const createTransaction = (idx) => { + return { + amount: '3.14', + balanceDate: '2021-04-29T17:26:40+00:00', + decay: { + decay: '-477.01', + start: '2021-05-13T17:46:31.000Z', + end: '2022-04-20T06:51:25.000Z', + duration: 29509494, + }, + memo: 'Kreiszahl PI', + linkedUser: { + firstName: 'Bibi', + lastName: 'Bloxberg', + }, + id: idx + 1, + typeId: 'RECEIVE', + balance: '33.33', + } + } + beforeEach(async () => { + const transactionCount = 42 await wrapper.setProps({ - transactions: Array.from({ length: 42 }, (_, idx) => { - return { - amount: '3.14', - balanceDate: '2021-04-29T17:26:40+00:00', - decay: { - decay: '-477.01', - start: '2021-05-13T17:46:31.000Z', - end: '2022-04-20T06:51:25.000Z', - duration: 29509494, - }, - memo: 'Kreiszahl PI', - linkedUser: { - firstName: 'Bibi', - lastName: 'Bloxberg', - }, - id: idx + 1, - typeId: 'RECEIVE', - balance: '33.33', - } + transactions: Array.from({ length: transactionCount }, (_, idx) => { + return createTransaction(idx) }), - transactionCount: 42, + transactionCount, decayStartBlock, pageSize: 25, showPagination: true, @@ -449,22 +454,22 @@ describe('GddTransactionList', () => { ) }) }) - }) - describe('show no pagination', () => { - beforeEach(async () => { - await wrapper.setProps({ - transactions: [], - transactionCount: 2, - decayStartBlock, - pageSize: 25, - showPagination: false, + describe('show no pagination', () => { + it('shows no pagination buttons', async () => { + const transactionCount = 2 + await wrapper.setProps({ + transactions: Array.from({ length: transactionCount }, (_, idx) => { + return createTransaction(idx) + }), + transactionCount, + decayStartBlock, + pageSize: 25, + showPagination: false, + }) + expect(wrapper.find('ul.pagination').exists()).toBe(false) }) }) - - it('shows no pagination buttons', () => { - expect(wrapper.find('ul.pagination').exists()).toBe(false) - }) }) }) }) diff --git a/frontend/src/components/GddTransactionList.vue b/frontend/src/components/GddTransactionList.vue index 34f7c24ab..5becfa39e 100644 --- a/frontend/src/components/GddTransactionList.vue +++ b/frontend/src/components/GddTransactionList.vue @@ -61,7 +61,7 @@ [] }, pageSize: { type: Number, default: 25 }, @@ -110,6 +105,11 @@ export default { showPagination: { type: Boolean, default: false }, pending: { type: Boolean }, }, + data() { + return { + currentPage: 1, + } + }, methods: { updateTransactions() { this.$emit('update-transactions', { @@ -123,6 +123,11 @@ export default { return '0' }, }, + computed: { + isPaginationVisible() { + return this.showPagination && this.pageSize < this.transactionCount + }, + }, watch: { currentPage() { this.updateTransactions() @@ -134,6 +139,7 @@ export default { }, } +