Merge branch 'master' into 1973-List-open-contribution-links-in-the-wallet

This commit is contained in:
Alexander Friedland 2022-08-04 11:02:45 +02:00 committed by GitHub
commit baa7f8df4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
195 changed files with 11234 additions and 1114 deletions

View File

@ -1,3 +0,0 @@
4ƒk׀ךֻ1°,<EFBFBD>fעbלAלqִ¬cי<EFBFBD>#¾צ¾ר#s8-ְ1&»;נצד"¢פשל7d¥jM?bljfB¼ƒqֱ=
<EFBFBD>ײmyפ¿
vת·´V 

View File

@ -1,32 +0,0 @@
{
"folders": {},
"connections": {
"mariaDB-1813fbbc7bc-107c0b3aeaeb91ab": {
"provider": "mysql",
"driver": "mariaDB",
"name": "gradido",
"save-password": true,
"read-only": false,
"configuration": {
"host": "localhost",
"port": "3306",
"url": "jdbc:mariadb://localhost:3306/",
"home": "mysql_client",
"type": "dev",
"auth-model": "native",
"handlers": {}
}
}
},
"connection-types": {
"dev": {
"name": "Development",
"color": "255,255,255",
"description": "Regular development database",
"auto-commit": true,
"confirm-execute": false,
"confirm-data-change": false,
"auto-close-transactions": false
}
}
}

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.dbeaver
.project
*.log
*.bak
/node_modules/*

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>Gradido</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.wst.validation.validationbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.jkiss.dbeaver.DBeaverNature</nature>
<nature>org.eclipse.wst.jsdt.core.jsNature</nature>
</natures>
</projectDescription>

View File

@ -4,8 +4,88 @@ 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.11.0](https://github.com/gradido/gradido/compare/1.10.1...1.11.0)
- Fix navbar community item [`#2102`](https://github.com/gradido/gradido/pull/2102)
- Add validation date info to copied text after transaction link creation [`#2101`](https://github.com/gradido/gradido/pull/2101)
- Remove member area from menu (desktop and mobile), when user has no elopage account [`#2099`](https://github.com/gradido/gradido/pull/2099)
- fix: Use Inner Join for Contribution and User [`#2100`](https://github.com/gradido/gradido/pull/2100)
- Enable copying the link, username, amount, and memo text after transaction link creation [`#2098`](https://github.com/gradido/gradido/pull/2098)
- feat: Insert Missing Contributions Migration [`#2053`](https://github.com/gradido/gradido/pull/2053)
- [Bug] Wallet improvments for Contributions [`#2090`](https://github.com/gradido/gradido/pull/2090)
- [Fix] Add createdAt & contributionDate to ContributionListItems [`#2093`](https://github.com/gradido/gradido/pull/2093)
- fix: 🍰 Reset Amount In Contribution Form And Write A Test [`#2086`](https://github.com/gradido/gradido/pull/2086)
- [Feat] Replace logic to validation-provider. [`#2088`](https://github.com/gradido/gradido/pull/2088)
- fix: Add Confirm Dialog on Delete Contribution [`#2087`](https://github.com/gradido/gradido/pull/2087)
- fix: Admin Cannot Edit User Contribution [`#2085`](https://github.com/gradido/gradido/pull/2085)
- fix: Update contribution_date when Moved by Seed [`#2083`](https://github.com/gradido/gradido/pull/2083)
- chore: 🍰 Provide Volume For Backend Log-Files In Docker [`#2067`](https://github.com/gradido/gradido/pull/2067)
- feat: 🍰 Community Contribution Site And Form [`#2042`](https://github.com/gradido/gradido/pull/2042)
- [Refactor] Move MEMO_MIN_CHARS and MEMO_MAX_CHARS to const file [`#2082`](https://github.com/gradido/gradido/pull/2082)
- Fix: Test memo length on createContribution & updateContribution [`#2080`](https://github.com/gradido/gradido/pull/2080)
- chore: 🍰 Change `image` Entries In Docker Compose Files And Get Apple M1 Running [`#2050`](https://github.com/gradido/gradido/pull/2050)
- fix: Windows 0D 0A Linebreaks to Unix 0A [`#2064`](https://github.com/gradido/gradido/pull/2064)
- Add contributionDate to the Contribution object. [`#2066`](https://github.com/gradido/gradido/pull/2066)
- fix: Add Contributions to User [`#2062`](https://github.com/gradido/gradido/pull/2062)
- Feat: ContributionResolver - delete mutation [`#2035`](https://github.com/gradido/gradido/pull/2035)
- Fix: Add count to list contributions [`#2061`](https://github.com/gradido/gradido/pull/2061)
- docu: Explain how `.env` Files are Working [`#2022`](https://github.com/gradido/gradido/pull/2022)
- [WIP] 1794 feature event protocol 1 implement the basics of the business event protocol [`#1997`](https://github.com/gradido/gradido/pull/1997)
- docs: 🍰 Document The Setup Of The GraphQL Playground [`#2060`](https://github.com/gradido/gradido/pull/2060)
- Feat: List all contribution [`#2057`](https://github.com/gradido/gradido/pull/2057)
- feat: Do not log IntrospectionQuery from Query Browser [`#2059`](https://github.com/gradido/gradido/pull/2059)
- Feat: Add confirmedBy and confirmedAt for the contribution query. [`#2052`](https://github.com/gradido/gradido/pull/2052)
- Add open creations to webapp [`#2048`](https://github.com/gradido/gradido/pull/2048)
- Prevent session expiration modal from displaying negative seconds, when session is expired for more than 0 seconds [`#2054`](https://github.com/gradido/gradido/pull/2054)
- feat: mutation contribution update [`#2032`](https://github.com/gradido/gradido/pull/2032)
- feat: Login Returns Open Creations for User [`#2046`](https://github.com/gradido/gradido/pull/2046)
- Migrate transaction to valid dataset for gradido node [`#2029`](https://github.com/gradido/gradido/pull/2029)
- feat: implement contribution list query [`#2031`](https://github.com/gradido/gradido/pull/2031)
- add code for moving user creation date if transaction before exist [`#2034`](https://github.com/gradido/gradido/pull/2034)
- change text from page [`#2037`](https://github.com/gradido/gradido/pull/2037)
- Transaction link: copy link, text and more [`#2030`](https://github.com/gradido/gradido/pull/2030)
- change welcome in community text [`#2025`](https://github.com/gradido/gradido/pull/2025)
- changed link color in navbar and language switch [`#2024`](https://github.com/gradido/gradido/pull/2024)
- feat: ContributionResolver - createContribution [`#2009`](https://github.com/gradido/gradido/pull/2009)
#### [1.10.1](https://github.com/gradido/gradido/compare/1.10.0...1.10.1)
> 30 June 2022
- release: 1.10.1 [`#2021`](https://github.com/gradido/gradido/pull/2021)
- automatic session logout with info modal [`#2001`](https://github.com/gradido/gradido/pull/2001)
- 1910 separate text for the slideshow images. [`#1998`](https://github.com/gradido/gradido/pull/1998)
- Origin/1921 additional parameter checks for createContributionLinks [`#1996`](https://github.com/gradido/gradido/pull/1996)
- add missing locales [`#1999`](https://github.com/gradido/gradido/pull/1999)
- 1906 feature concept for gdd creation per linkqr code [`#1907`](https://github.com/gradido/gradido/pull/1907)
- refactor: 🍰 Not Throwing An Error When Register With Existing Email [`#1962`](https://github.com/gradido/gradido/pull/1962)
- feat: 🍰 Set Role In Admin Interface [`#1974`](https://github.com/gradido/gradido/pull/1974)
- refactor mobile style step 1 [`#1977`](https://github.com/gradido/gradido/pull/1977)
- changed mobil stage picture [`#1995`](https://github.com/gradido/gradido/pull/1995)
#### [1.10.0](https://github.com/gradido/gradido/compare/1.9.0...1.10.0)
> 17 June 2022
- release: v1.10.0 [`#1993`](https://github.com/gradido/gradido/pull/1993)
- frontend redeem contribution link [`#1988`](https://github.com/gradido/gradido/pull/1988)
- change new start picture [`#1990`](https://github.com/gradido/gradido/pull/1990)
- feat: Redeem Contribution Link [`#1987`](https://github.com/gradido/gradido/pull/1987)
- fix: Max Amount on Slider for Edit Contribution [`#1986`](https://github.com/gradido/gradido/pull/1986)
- CRUD contribution link admin interface [`#1981`](https://github.com/gradido/gradido/pull/1981)
- fix: `.env` log level for apollo and backend category [`#1967`](https://github.com/gradido/gradido/pull/1967)
- refactor: Admin Pending Creations Table to Contributions Table [`#1949`](https://github.com/gradido/gradido/pull/1949)
- devops: Update Browser List for Unit Tests as Recomended [`#1984`](https://github.com/gradido/gradido/pull/1984)
- feat: CRUD for Contribution Links in Admin Resolver [`#1979`](https://github.com/gradido/gradido/pull/1979)
- 1920 feature create contribution link table [`#1957`](https://github.com/gradido/gradido/pull/1957)
- refactor: 🍰 Delete `user_setting` Table From DB [`#1960`](https://github.com/gradido/gradido/pull/1960)
- locales link german, english navbar [`#1969`](https://github.com/gradido/gradido/pull/1969)
#### [1.9.0](https://github.com/gradido/gradido/compare/1.8.3...1.9.0)
> 2 June 2022
- devops: Release Version 1.9.0 [`#1968`](https://github.com/gradido/gradido/pull/1968)
- refactor: 🍰 Refactor To `filters` Object And Rename Filters Properties [`#1914`](https://github.com/gradido/gradido/pull/1914)
- refactor register button position [`#1964`](https://github.com/gradido/gradido/pull/1964)
- fixed redeem link is mobile start false [`#1958`](https://github.com/gradido/gradido/pull/1958)

44
DOCKER_MORE_CLOSELY.md Normal file
View File

@ -0,0 +1,44 @@
# Docker More Closely
## Apple M1 Platform
***Attention:** For using Docker commands in Apple M1 environments!*
### Enviroment Variable For Apple M1 Platform
To set the Docker platform environment variable in your terminal tab, run:
```bash
# set env variable for your shell
$ export DOCKER_DEFAULT_PLATFORM=linux/amd64
```
### Docker Compose Override File For Apple M1 Platform
For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform:
```bash
# in main folder
# for development
$ docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up
# for production
$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up
```
## Analysing Docker Builds
To analyze a Docker build, there is a wonderful tool called [dive](https://github.com/wagoodman/dive). Please sponsor if you're using it!
The `dive build` command is exactly the right one to fulfill what we are looking for.
We can use it just like the `docker build` command and get an analysis afterwards.
So, in our main folder, we use it in the following way:
```bash
# in main folder
$ dive build --target <layer-name> -t "gradido/<app-name>:local-<layer-name>" <app-folder-name-or-dot>/
```
For the specific applications, see our [publish.yml](.github/workflows/publish.yml).

View File

@ -121,6 +121,19 @@ After generating a new version you should commit the changes. This will be the C
Note: The Changelog will be regenerated with all tags on release on the external builder tool, but will not be checked in there. The Changelog on the github release will therefore always be correct, on the repo it might be incorrect due to missing tags when executing the `yarn release` command.
## How the different .env work on deploy
Each component (frontend, admin, backend and database) has its own `.env` file. When running in development with docker and nginx you usually do not have to care about the `.env`. The defaults are set by the respective config file, found in the `src/config/` folder of each component. But if you have a local `.env`, the defaults set in the config are overwritten by the `.env`. If you do not use docker, you need the `.env` in the frontend and admin interface because nginx is not running in order to find the backend.
Each component has a `.env.dist` file. This file contains all environment variables used by the component and can be used as pattern. If you want to use a local `.env`, copy the `.env.dist` and adjust the variables accordingly.
Each component has a `.env.template` file. These files are very important on deploy.
There is one `.env.dist` in the `deployment/bare_metal/` folder. This `.env.dist` contains all variables used by the components, e.g. unites all `.env.dist` from the components. On deploy, we copy this `.env.dist` to `.env` and set all variables in this new file. The deploy script loads this variables and provides them by the `.env.templates` of each component, creating an `.env` for each component (see in `deployment/bare_metal/start.sh` the `envsubst`).
To avoid forgetting to update an existing `.env` in the `deployment/bare_metal/` folder when deploying, we have an environment version variable inside the codebase of each component. You should update this version, when environment variables must be changed or added on deploy. The code checks, that the environement version provided by the `.env` is the one expected by the codebase.
## Troubleshooting
| Problem | Issue | Solution | Description |

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

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

View File

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

View File

@ -0,0 +1,49 @@
import { mount } from '@vue/test-utils'
import ContributionLink from './ContributionLink.vue'
const localVue = global.localVue
const mocks = {
$t: jest.fn((t) => t),
}
const propsData = {
items: [
{
id: 1,
name: 'Meditation',
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
amount: '200',
validFrom: '2022-04-01',
validTo: '2022-08-01',
cycle: 'täglich',
maxPerCycle: '3',
maxAmountPerMonth: 0,
link: 'https://localhost/redeem/CL-1a2345678',
},
],
count: 1,
}
describe('ContributionLink', () => {
let wrapper
const Wrapper = () => {
return mount(ContributionLink, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".contribution-link"', () => {
expect(wrapper.find('div.contribution-link').exists()).toBe(true)
})
it('emits toggle::collapse new Contribution', async () => {
wrapper.vm.editContributionLinkData()
expect(wrapper.vm.$root.$emit('bv::toggle::collapse', 'newContribution')).toBeTruthy()
})
})
})

View File

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

View File

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

View File

@ -0,0 +1,218 @@
<template>
<div class="contribution-link-form">
<div v-if="updateData" class="text-light bg-info p-3">
{{ updateData }}
</div>
<b-form class="m-5" @submit.prevent="onSubmit" ref="contributionLinkForm">
<!-- Date -->
<b-row>
<b-col>
<b-form-group :label="$t('contributionLink.validFrom')">
<b-form-datepicker
v-model="form.validFrom"
size="lg"
:min="min"
class="mb-4 test-validFrom"
reset-value=""
:label-no-date-selected="$t('contributionLink.noDateSelected')"
required
></b-form-datepicker>
</b-form-group>
</b-col>
<b-col>
<b-form-group :label="$t('contributionLink.validTo')">
<b-form-datepicker
v-model="form.validTo"
size="lg"
:min="form.validFrom ? form.validFrom : min"
class="mb-4 test-validTo"
reset-value=""
:label-no-date-selected="$t('contributionLink.noDateSelected')"
required
></b-form-datepicker>
</b-form-group>
</b-col>
</b-row>
<!-- Name -->
<b-form-group :label="$t('contributionLink.name')">
<b-form-input
v-model="form.name"
size="lg"
type="text"
placeholder="Name"
required
maxlength="100"
class="test-name"
></b-form-input>
</b-form-group>
<!-- Desc -->
<b-form-group :label="$t('contributionLink.memo')">
<b-form-textarea
v-model="form.memo"
size="lg"
:placeholder="$t('contributionLink.memo')"
required
maxlength="255"
class="test-memo"
></b-form-textarea>
</b-form-group>
<!-- Amount -->
<b-form-group :label="$t('contributionLink.amount')">
<b-form-input
v-model="form.amount"
size="lg"
type="number"
placeholder="0"
required
class="test-amount"
></b-form-input>
</b-form-group>
<b-collapse id="collapse-2">
<b-jumbotron>
<b-row class="mb-4">
<b-col>
<!-- Cycle -->
<label for="cycle">{{ $t('contributionLink.cycle') }}</label>
<b-form-select
v-model="form.cycle"
:options="cycle"
:disabled="disabled"
class="mb-3"
size="lg"
></b-form-select>
</b-col>
<b-col>
<!-- maxPerCycle -->
<label for="maxPerCycle">{{ $t('contributionLink.maxPerCycle') }}</label>
<b-form-select
v-model="form.maxPerCycle"
:options="maxPerCycle"
:disabled="disabled"
class="mb-3"
size="lg"
></b-form-select>
</b-col>
</b-row>
<!-- Max amount -->
<b-form-group :label="$t('contributionLink.maximumAmount')">
<b-form-input
v-model="form.maxAmountPerMonth"
size="lg"
:disabled="disabled"
type="number"
placeholder="0"
></b-form-input>
</b-form-group>
</b-jumbotron>
</b-collapse>
<div class="mt-6">
<b-button type="submit" variant="primary">{{ $t('contributionLink.create') }}</b-button>
<b-button type="reset" variant="danger" @click.prevent="onReset">
{{ $t('contributionLink.clear') }}
</b-button>
</div>
</b-form>
</div>
</template>
<script>
import { createContributionLink } from '@/graphql/createContributionLink.js'
export default {
name: 'ContributionLinkForm',
props: {
contributionLinkData: {
type: Object,
default() {
return {}
},
},
},
data() {
return {
form: {
name: null,
memo: null,
amount: null,
validFrom: null,
validTo: null,
cycle: 'ONCE',
maxPerCycle: 1,
maxAmountPerMonth: '0',
},
min: new Date(),
cycle: [
{ value: 'ONCE', text: this.$t('contributionLink.options.cycle.once') },
{ value: 'hourly', text: this.$t('contributionLink.options.cycle.hourly') },
{ value: 'daily', text: this.$t('contributionLink.options.cycle.daily') },
{ value: 'weekly', text: this.$t('contributionLink.options.cycle.weekly') },
{ value: 'monthly', text: this.$t('contributionLink.options.cycle.monthly') },
{ value: 'yearly', text: this.$t('contributionLink.options.cycle.yearly') },
],
maxPerCycle: [
{ value: '1', text: '1 x' },
{ value: '2', text: '2 x' },
{ value: '3', text: '3 x' },
{ value: '4', text: '4 x' },
{ value: '5', text: '5 x' },
],
}
},
methods: {
onSubmit() {
if (this.form.validFrom === null)
return this.toastError(this.$t('contributionLink.noStartDate'))
if (this.form.validTo === null) return this.toastError(this.$t('contributionLink.noEndDate'))
// alert(JSON.stringify(this.form))
this.$apollo
.mutate({
mutation: createContributionLink,
variables: {
validFrom: this.form.validFrom,
validTo: this.form.validTo,
name: this.form.name,
amount: this.form.amount,
memo: this.form.memo,
cycle: this.form.cycle,
maxPerCycle: this.form.maxPerCycle,
maxAmountPerMonth: this.form.maxAmountPerMonth,
},
})
.then((result) => {
this.link = result.data.createContributionLink.link
this.toastSuccess(this.link)
this.onReset()
})
.catch((error) => {
this.toastError(error.message)
})
},
onReset() {
this.$refs.contributionLinkForm.reset()
this.form.validFrom = null
this.form.validTo = null
},
},
computed: {
updateData() {
return this.contributionLinkData
},
disabled() {
if (this.form.cycle === 'ONCE') return true
return false
},
},
watch: {
contributionLinkData() {
this.form.name = this.contributionLinkData.name
this.form.memo = this.contributionLinkData.memo
this.form.amount = this.contributionLinkData.amount
this.form.validFrom = this.contributionLinkData.validFrom
this.form.validTo = this.contributionLinkData.validTo
this.form.cycle = this.contributionLinkData.cycle
this.form.maxPerCycle = this.contributionLinkData.maxPerCycle
this.form.maxAmountPerMonth = this.contributionLinkData.maxAmountPerMonth
},
},
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@ const propsData = {
amount: 210,
memo: 'Aktives Grundeinkommen für Januar 2022',
date: '2022-01-01T00:00:00.000Z',
moderator: 1,
moderator: null,
creation: [790, 1000, 1000],
__typename: 'PendingCreation',
},
@ -66,9 +66,10 @@ const propsData = {
},
},
{ key: 'moderator', label: 'moderator' },
{ key: 'edit_creation', label: 'edit' },
{ key: 'editCreation', label: 'edit' },
{ key: 'confirm', label: 'save' },
],
toggleDetails: false,
}
const mocks = {
@ -101,7 +102,7 @@ describe('OpenCreationsTable', () => {
})
it('has a DIV element with the class .open-creations-table', () => {
expect(wrapper.find('div.open-creations-table').exists()).toBeTruthy()
expect(wrapper.find('div.open-creations-table').exists()).toBe(true)
})
it('has a table with three rows', () => {
@ -109,7 +110,11 @@ describe('OpenCreationsTable', () => {
})
it('find first button.bi-pencil-square for open EditCreationFormular ', () => {
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBeTruthy()
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBe(true)
})
it('has no button.bi-pencil-square for user contribution ', () => {
expect(wrapper.findAll('tr').at(2).find('.bi-pencil-square').exists()).toBe(false)
})
describe('show edit details', () => {
@ -122,7 +127,15 @@ describe('OpenCreationsTable', () => {
})
it.skip('renders the component component-edit-creation-formular', () => {
expect(wrapper.find('div.component-edit-creation-formular').exists()).toBeTruthy()
expect(wrapper.find('div.component-edit-creation-formular').exists()).toBe(true)
})
})
describe('call updateUserData', () => {
it('user creations has updated data', async () => {
wrapper.vm.updateUserData(propsData.items[0], [444, 555, 666])
await wrapper.vm.$nextTick()
expect(wrapper.vm.items[0].creation).toEqual([444, 555, 666])
})
})
})

View File

@ -11,8 +11,14 @@
<b-icon icon="x" variant="light"></b-icon>
</b-button>
</template>
<template #cell(edit_creation)="row">
<b-button variant="info" size="md" @click="rowToggleDetails(row, 0)" class="mr-2">
<template #cell(editCreation)="row">
<b-button
v-if="row.item.moderator"
variant="info"
size="md"
@click="rowToggleDetails(row, 0)"
class="mr-2"
>
<b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon>
</b-button>
</template>
@ -70,12 +76,23 @@ export default {
required: true,
},
},
data() {
return {
creationUserData: {
amount: null,
date: null,
memo: null,
moderator: null,
},
}
},
methods: {
updateCreationData(data) {
this.creationUserData.amount = data.amount
this.creationUserData.date = data.date
this.creationUserData.memo = data.memo
this.creationUserData.moderator = data.moderator
this.creationUserData = data
// this.creationUserData.amount = data.amount
// this.creationUserData.date = data.date
// this.creationUserData.memo = data.memo
// this.creationUserData.moderator = data.moderator
data.row.toggleDetails()
},
updateUserData(rowItem, newCreation) {

View File

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

View File

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

View File

@ -11,6 +11,7 @@
:per-page="perPage"
:total-rows="rows"
align="center"
:hide-ellipsis="true"
></b-pagination>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,6 +19,7 @@ export const searchUsers = gql`
hasElopage
emailConfirmationSend
deletedAt
isAdmin
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@
per-page="perPage"
:total-rows="rows"
align="center"
:hide-ellipsis="true"
></b-pagination>
</b-col>
<b-col cols="12" lg="6" class="shadow p-3 mb-5 rounded bg-info">

View File

@ -1,7 +1,7 @@
import { mount } from '@vue/test-utils'
import CreationConfirm from './CreationConfirm.vue'
import { deletePendingCreation } from '../graphql/deletePendingCreation'
import { confirmPendingCreation } from '../graphql/confirmPendingCreation'
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { confirmContribution } from '../graphql/confirmContribution'
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
const localVue = global.localVue
@ -9,7 +9,7 @@ const localVue = global.localVue
const storeCommitMock = jest.fn()
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
getPendingCreations: [
listUnconfirmedContributions: [
{
id: 1,
firstName: 'Bibi',
@ -18,7 +18,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
amount: 500,
memo: 'Danke für alles',
date: new Date(),
moderator: 0,
moderator: 2,
},
{
id: 2,
@ -28,7 +28,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
amount: 1000000,
memo: 'Gut Ergattert',
date: new Date(),
moderator: 0,
moderator: 2,
},
],
},
@ -80,28 +80,54 @@ describe('CreationConfirm', () => {
})
describe('remove creation with success', () => {
beforeEach(async () => {
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
let spy
it('calls the deletePendingCreation mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: deletePendingCreation,
variables: { id: 1 },
describe('admin confirms deletion', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve('some value'))
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
it('opens a modal', () => {
expect(spy).toBeCalled()
})
it('calls the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: adminDeleteContribution,
variables: { id: 1 },
})
})
it('commits openCreationsMinus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
})
})
it('commits openCreationsMinus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
})
describe('admin cancels deletion', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve(false))
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
it('does not call the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).not.toBeCalled()
})
})
})
describe('remove creation with error', () => {
let spy
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve('some value'))
apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' })
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
@ -141,9 +167,9 @@ describe('CreationConfirm', () => {
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
})
it('calls the confirmPendingCreation mutation', () => {
it('calls the confirmContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: confirmPendingCreation,
mutation: confirmContribution,
variables: { id: 2 },
})
})

View File

@ -15,9 +15,9 @@
<script>
import Overlay from '../components/Overlay.vue'
import OpenCreationsTable from '../components/Tables/OpenCreationsTable.vue'
import { getPendingCreations } from '../graphql/getPendingCreations'
import { deletePendingCreation } from '../graphql/deletePendingCreation'
import { confirmPendingCreation } from '../graphql/confirmPendingCreation'
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
import { confirmContribution } from '../graphql/confirmContribution'
export default {
name: 'CreationConfirm',
@ -34,25 +34,28 @@ export default {
},
methods: {
removeCreation(item) {
this.$apollo
.mutate({
mutation: deletePendingCreation,
variables: {
id: item.id,
},
})
.then((result) => {
this.updatePendingCreations(item.id)
this.toastSuccess(this.$t('creation_form.toasted_delete'))
})
.catch((error) => {
this.toastError(error.message)
})
this.$bvModal.msgBoxConfirm(this.$t('creation_form.deleteNow')).then(async (value) => {
if (value)
await this.$apollo
.mutate({
mutation: adminDeleteContribution,
variables: {
id: item.id,
},
})
.then((result) => {
this.updatePendingCreations(item.id)
this.toastSuccess(this.$t('creation_form.toasted_delete'))
})
.catch((error) => {
this.toastError(error.message)
})
})
},
confirmCreation() {
this.$apollo
.mutate({
mutation: confirmPendingCreation,
mutation: confirmContribution,
variables: {
id: this.item.id,
},
@ -70,13 +73,13 @@ export default {
getPendingCreations() {
this.$apollo
.query({
query: getPendingCreations,
query: listUnconfirmedContributions,
fetchPolicy: 'network-only',
})
.then((result) => {
this.$store.commit('resetOpenCreations')
this.pendingCreations = result.data.getPendingCreations
this.$store.commit('setOpenCreations', result.data.getPendingCreations.length)
this.pendingCreations = result.data.listUnconfirmedContributions
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
})
.catch((error) => {
this.toastError(error.message)
@ -114,7 +117,7 @@ export default {
},
},
{ key: 'moderator', label: this.$t('moderator') },
{ key: 'edit_creation', label: this.$t('edit') },
{ key: 'editCreation', label: this.$t('edit') },
{ key: 'confirm', label: this.$t('save') },
]
},

View File

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

View File

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

View File

@ -199,14 +199,43 @@ describe('UserSearch', () => {
})
})
describe('change user role', () => {
const userId = 4
describe('to admin', () => {
it('updates user role to admin', async () => {
await wrapper
.findComponent({ name: 'SearchUserTable' })
.vm.$emit('updateIsAdmin', userId, new Date())
expect(wrapper.vm.searchResult.find((obj) => obj.userId === userId).isAdmin).toEqual(
expect.any(Date),
)
})
})
describe('to usual user', () => {
it('updates user role to usual user', async () => {
await wrapper
.findComponent({ name: 'SearchUserTable' })
.vm.$emit('updateIsAdmin', userId, null)
expect(wrapper.vm.searchResult.find((obj) => obj.userId === userId).isAdmin).toEqual(null)
})
})
})
describe('delete user', () => {
const now = new Date()
beforeEach(async () => {
wrapper.findComponent({ name: 'SearchUserTable' }).vm.$emit('updateDeletedAt', 4, now)
const userId = 4
beforeEach(() => {
wrapper
.findComponent({ name: 'SearchUserTable' })
.vm.$emit('updateDeletedAt', userId, new Date())
})
it('marks the user as deleted', () => {
expect(wrapper.vm.searchResult.find((obj) => obj.userId === 4).deletedAt).toEqual(now)
expect(wrapper.vm.searchResult.find((obj) => obj.userId === userId).deletedAt).toEqual(
expect.any(Date),
)
expect(wrapper.find('.test-deleted-icon').exists()).toBe(true)
})
it('toasts a success message', () => {

View File

@ -42,6 +42,7 @@
type="PageUserSearch"
:items="searchResult"
:fields="fields"
@updateIsAdmin="updateIsAdmin"
@updateDeletedAt="updateDeletedAt"
/>
<b-pagination
@ -51,6 +52,7 @@
per-page="perPage"
:total-rows="rows"
align="center"
:hide-ellipsis="true"
></b-pagination>
<div></div>
</div>
@ -111,6 +113,9 @@ export default {
this.toastError(error.message)
})
},
updateIsAdmin(userId, isAdmin) {
this.searchResult.find((obj) => obj.userId === userId).isAdmin = isAdmin
},
updateDeletedAt(userId, deletedAt) {
this.searchResult.find((obj) => obj.userId === userId).deletedAt = deletedAt
this.toastSuccess(deletedAt ? this.$t('user_deleted') : this.$t('user_recovered'))

View File

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

View File

@ -1,9 +1,9 @@
CONFIG_VERSION=v6.2022-04-21
CONFIG_VERSION=v9.2022-07-07
# Server
PORT=4000
JWT_SECRET=secret123
JWT_EXPIRES_IN=30m
JWT_EXPIRES_IN=10m
GRAPHIQL=false
GDT_API_URL=https://gdt.gradido.net
@ -28,6 +28,7 @@ COMMUNITY_NAME=Gradido Entwicklung
COMMUNITY_URL=http://localhost/
COMMUNITY_REGISTER_URL=http://localhost/register
COMMUNITY_REDEEM_URL=http://localhost/redeem/{code}
COMMUNITY_REDEEM_CONTRIBUTION_URL=http://localhost/redeem/CL-{code}
COMMUNITY_DESCRIPTION=Die lokale Entwicklungsumgebung von Gradido.
# Login Server
@ -51,6 +52,9 @@ EMAIL_CODE_REQUEST_TIME=10
# Webhook
WEBHOOK_ELOPAGE_SECRET=secret
# EventProtocol
EVENT_PROTOCOL_DISABLED=false
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
# LOG_LEVEL=info

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,14 +10,14 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0038-add_contribution_links_table',
DB_VERSION: '0044-insert_missing_contributions',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v6.2022-04-21',
EXPECTED: 'v9.2022-07-07',
CURRENT: '',
},
}
@ -25,7 +25,7 @@ const constants = {
const server = {
PORT: process.env.PORT || 4000,
JWT_SECRET: process.env.JWT_SECRET || 'secret123',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '30m',
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '10m',
GRAPHIQL: process.env.GRAPHIQL === 'true' || false,
GDT_API_URL: process.env.GDT_API_URL || 'https://gdt.gradido.net',
PRODUCTION: process.env.NODE_ENV === 'production' || false,
@ -54,6 +54,8 @@ const community = {
COMMUNITY_URL: process.env.COMMUNITY_URL || 'http://localhost/',
COMMUNITY_REGISTER_URL: process.env.COMMUNITY_REGISTER_URL || 'http://localhost/register',
COMMUNITY_REDEEM_URL: process.env.COMMUNITY_REDEEM_URL || 'http://localhost/redeem/{code}',
COMMUNITY_REDEEM_CONTRIBUTION_URL:
process.env.COMMUNITY_REDEEM_CONTRIBUTION_URL || 'http://localhost/redeem/CL-{code}',
COMMUNITY_DESCRIPTION:
process.env.COMMUNITY_DESCRIPTION || 'Die lokale Entwicklungsumgebung von Gradido.',
}
@ -92,6 +94,11 @@ const webhook = {
WEBHOOK_ELOPAGE_SECRET: process.env.WEBHOOK_ELOPAGE_SECRET || 'secret',
}
const eventProtocol = {
// global switch to enable writing of EventProtocol-Entries
EVENT_PROTOCOL_DISABLED: process.env.EVENT_PROTOCOL_DISABLED === 'true' || false,
}
// This is needed by graphql-directive-auth
process.env.APP_SECRET = server.JWT_SECRET
@ -116,6 +123,7 @@ const CONFIG = {
...email,
...loginServer,
...webhook,
...eventProtocol,
}
export default CONFIG

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

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

View File

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

View File

@ -0,0 +1,24 @@
export enum EventProtocolType {
BASIC = 'BASIC',
VISIT_GRADIDO = 'VISIT_GRADIDO',
REGISTER = 'REGISTER',
REDEEM_REGISTER = 'REDEEM_REGISTER',
INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT',
SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL',
CONFIRM_EMAIL = 'CONFIRM_EMAIL',
REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP',
LOGIN = 'LOGIN',
REDEEM_LOGIN = 'REDEEM_LOGIN',
ACTIVATE_ACCOUNT = 'ACTIVATE_ACCOUNT',
PASSWORD_CHANGE = 'PASSWORD_CHANGE',
TRANSACTION_SEND = 'TRANSACTION_SEND',
TRANSACTION_SEND_REDEEM = 'TRANSACTION_SEND_REDEEM',
TRANSACTION_REPEATE_REDEEM = 'TRANSACTION_REPEATE_REDEEM',
TRANSACTION_CREATION = 'TRANSACTION_CREATION',
TRANSACTION_RECEIVE = 'TRANSACTION_RECEIVE',
TRANSACTION_RECEIVE_REDEEM = 'TRANSACTION_RECEIVE_REDEEM',
CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE',
CONTRIBUTION_CONFIRM = 'CONTRIBUTION_CONFIRM',
CONTRIBUTION_LINK_DEFINE = 'CONTRIBUTION_LINK_DEFINE',
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,12 @@
import { ObjectType, Field } from 'type-graphql'
import { KlickTipp } from './KlickTipp'
import { User as dbUser } from '@entity/User'
import Decimal from 'decimal.js-light'
import { FULL_CREATION_AVAILABLE } from '../resolver/const/const'
@ObjectType()
export class User {
constructor(user: dbUser) {
constructor(user: dbUser, creation: Decimal[] = FULL_CREATION_AVAILABLE) {
this.id = user.id
this.email = user.email
this.firstName = user.firstName
@ -17,6 +19,7 @@ export class User {
this.isAdmin = user.isAdmin
this.klickTipp = null
this.hasElopage = null
this.creation = creation
}
@Field(() => Number)
@ -64,4 +67,7 @@ export class User {
@Field(() => Boolean, { nullable: true })
hasElopage: boolean | null
@Field(() => [Decimal])
creation: Decimal[]
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -34,9 +34,7 @@ import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualT
import Decimal from 'decimal.js-light'
import { BalanceResolver } from './BalanceResolver'
const MEMO_MAX_CHARS = 255
const MEMO_MIN_CHARS = 5
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
export const executeTransaction = async (
amount: Decimal,

View File

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

View File

@ -7,7 +7,9 @@ import { getConnection } from '@dbTools/typeorm'
import CONFIG from '@/config'
import { User } from '@model/User'
import { User as DbUser } from '@entity/User'
import { communityDbUser } from '@/util/communityUser'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { ContributionLink as dbContributionLink } from '@entity/ContributionLink'
import { encode } from '@/auth/JWT'
import CreateUserArgs from '@arg/CreateUserArgs'
import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
@ -17,9 +19,19 @@ import { OptInType } from '@enum/OptInType'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendResetPasswordEmail as sendResetPasswordEmailMailer } from '@/mailer/sendResetPasswordEmail'
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegistrationEmail'
import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import {
Event,
EventLogin,
EventRedeemRegister,
EventRegister,
EventSendConfirmationEmail,
} from '@/event/Event'
import { getUserCreation } from './util/creations'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
@ -221,7 +233,7 @@ export class UserResolver {
logger.info('verifyLogin...')
// TODO refactor and do not have duplicate code with login(see below)
const userEntity = getUser(context)
const user = new User(userEntity)
const user = new User(userEntity, await getUserCreation(userEntity.id))
// user.pubkey = userEntity.pubKey.toString('hex')
// Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage(context)
@ -271,7 +283,7 @@ export class UserResolver {
logger.addContext('user', dbUser.id)
logger.debug('login credentials valid...')
const user = new User(dbUser)
const user = new User(dbUser, await getUserCreation(dbUser.id))
logger.debug('user=' + user)
// Elopage Status & Stored PublisherId
@ -287,6 +299,9 @@ export class UserResolver {
key: 'token',
value: encode(dbUser.pubKey),
})
const ev = new EventLogin()
ev.userId = user.id
eventProtocol.writeEvent(new Event().setEventLogin(ev))
logger.info('successful Login:' + user)
return user
}
@ -327,10 +342,35 @@ export class UserResolver {
// 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)
logger.info('User already exists with this email=' + email)
// TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent.
throw new Error(`User already exists.`)
const user = new User(communityDbUser)
user.id = sodium.randombytes_random() % (2048 * 16) // TODO: for a better faking derive id from email so that it will be always the same id when the same email comes in?
user.email = email
user.firstName = firstName
user.lastName = lastName
user.language = language
user.publisherId = publisherId
logger.debug('partly faked user=' + user)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountMultiRegistrationEmail({
firstName,
lastName,
email,
})
logger.info(`sendAccountMultiRegistrationEmail of ${firstName}.${lastName} to ${email}`)
/* uncomment this, when you need the activation link on the console */
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
logger.debug(`Email not send!`)
}
logger.info('createUser() faked and send multi registration mail...')
return user
}
const passphrase = PassphraseGenerate()
@ -339,6 +379,9 @@ export class UserResolver {
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
const emailHash = getEmailHash(email)
const eventRegister = new EventRegister()
const eventRedeemRegister = new EventRedeemRegister()
const eventSendConfirmEmail = new EventSendConfirmationEmail()
const dbUser = new DbUser()
dbUser.email = email
dbUser.firstName = firstName
@ -349,10 +392,22 @@ export class UserResolver {
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
if (redeemCode.match(/^CL-/)) {
const contributionLink = await dbContributionLink.findOne({
code: redeemCode.replace('CL-', ''),
})
logger.info('redeemCode found contributionLink=' + contributionLink)
if (contributionLink) {
dbUser.contributionLinkId = contributionLink.id
eventRedeemRegister.contributionId = contributionLink.id
}
} else {
const transactionLink = await dbTransactionLink.findOne({ code: redeemCode })
logger.info('redeemCode found transactionLink=' + transactionLink)
if (transactionLink) {
dbUser.referrerId = transactionLink.userId
eventRedeemRegister.transactionId = transactionLink.id
}
}
}
// TODO this field has no null allowed unlike the loginServer table
@ -362,6 +417,7 @@ export class UserResolver {
// loginUser.pubKey = keyPair[0]
// loginUser.privKey = encryptedPrivkey
const event = new Event()
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
@ -391,6 +447,9 @@ export class UserResolver {
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
})
logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`)
eventSendConfirmEmail.userId = dbUser.id
eventProtocol.writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmEmail))
/* 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) {
@ -406,6 +465,15 @@ export class UserResolver {
await queryRunner.release()
}
logger.info('createUser() successful...')
if (redeemCode) {
eventRedeemRegister.userId = dbUser.id
eventProtocol.writeEvent(event.setEventRedeemRegister(eventRedeemRegister))
} else {
eventRegister.userId = dbUser.id
eventProtocol.writeEvent(event.setEventRegister(eventRegister))
}
return new User(dbUser)
}

View File

@ -0,0 +1,12 @@
import Decimal from 'decimal.js-light'
export const MAX_CREATION_AMOUNT = new Decimal(1000)
export const FULL_CREATION_AVAILABLE = [
MAX_CREATION_AMOUNT,
MAX_CREATION_AMOUNT,
MAX_CREATION_AMOUNT,
]
export const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100
export const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5
export const MEMO_MAX_CHARS = 255
export const MEMO_MIN_CHARS = 5

View File

@ -0,0 +1,130 @@
import { TransactionTypeId } from '@/graphql/enum/TransactionTypeId'
import { backendLogger as logger } from '@/server/logger'
import { getConnection } from '@dbTools/typeorm'
import { Contribution } from '@entity/Contribution'
import Decimal from 'decimal.js-light'
import { FULL_CREATION_AVAILABLE, MAX_CREATION_AMOUNT } from '../const/const'
interface CreationMap {
id: number
creations: Decimal[]
}
export const validateContribution = (
creations: Decimal[],
amount: Decimal,
creationDate: Date,
): void => {
logger.trace('isContributionValid', creations, amount, creationDate)
const index = getCreationIndex(creationDate.getMonth())
if (index < 0) {
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 amount (${creations[index]} GDD) still available for this month.`,
)
}
}
export const getUserCreations = async (
ids: number[],
includePending = true,
): Promise<CreationMap[]> => {
logger.trace('getUserCreations:', ids, includePending)
const months = getCreationMonths()
logger.trace('getUserCreations months', months)
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
const dateFilter = 'last_day(curdate() - interval 3 month) + interval 1 day'
logger.trace('getUserCreations dateFilter', dateFilter)
const unionString = includePending
? `
UNION
SELECT contribution_date AS date, amount AS amount, user_id AS userId FROM contributions
WHERE user_id IN (${ids.toString()})
AND contribution_date >= ${dateFilter}
AND confirmed_at IS NULL AND deleted_at IS NULL`
: ''
const unionQuery = await queryRunner.manager.query(`
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM
(SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions
WHERE user_id IN (${ids.toString()})
AND type_id = ${TransactionTypeId.CREATION}
AND creation_date >= ${dateFilter}
${unionString}) AS result
GROUP BY month, userId
ORDER BY date DESC
`)
await queryRunner.release()
return ids.map((id) => {
return {
id,
creations: months.map((month) => {
const creation = unionQuery.find(
(raw: { month: string; id: string; creation: number[] }) =>
parseInt(raw.month) === month && parseInt(raw.id) === id,
)
return MAX_CREATION_AMOUNT.minus(creation ? creation.sum : 0)
}),
}
})
}
export const getUserCreation = async (id: number, includePending = true): Promise<Decimal[]> => {
logger.trace('getUserCreation', id, includePending)
const creations = await getUserCreations([id], includePending)
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
}
export const getCreationMonths = (): number[] => {
const now = new Date(Date.now())
return [
now.getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 1, 1).getMonth() + 1,
new Date(now.getFullYear(), now.getMonth() - 2, 1).getMonth() + 1,
].reverse()
}
export const getCreationIndex = (month: number): number => {
return getCreationMonths().findIndex((el) => el === month + 1)
}
export const isStartEndDateValid = (
startDate: string | null | undefined,
endDate: string | null | undefined,
): void => {
if (!startDate) {
logger.error('Start-Date is not initialized. A Start-Date must be set!')
throw new Error('Start-Date is not initialized. A Start-Date must be set!')
}
if (!endDate) {
logger.error('End-Date is not initialized. An End-Date must be set!')
throw new Error('End-Date is not initialized. An End-Date must be set!')
}
// check if endDate is before startDate
if (new Date(endDate).getTime() - new Date(startDate).getTime() < 0) {
logger.error(`The value of validFrom must before or equals the validTo!`)
throw new Error(`The value of validFrom must before or equals the validTo!`)
}
}
export const updateCreations = (creations: Decimal[], contribution: Contribution): Decimal[] => {
const index = getCreationIndex(contribution.contributionDate.getMonth())
if (index < 0) {
throw new Error('You cannot create GDD for a month older than the last three months.')
}
creations[index] = creations[index].plus(contribution.amount.toString())
return creations
}

View File

@ -0,0 +1,31 @@
import CONFIG from '@/config'
import { sendAccountMultiRegistrationEmail } from './sendAccountMultiRegistrationEmail'
import { sendEMail } from './sendEMail'
jest.mock('./sendEMail', () => {
return {
__esModule: true,
sendEMail: jest.fn(),
}
})
describe('sendAccountMultiRegistrationEmail', () => {
beforeEach(async () => {
await sendAccountMultiRegistrationEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
})
})
it('calls sendEMail', () => {
expect(sendEMail).toBeCalledWith({
to: `Peter Lustig <peter@lustig.de>`,
subject: 'Gradido: Erneuter Registrierungsversuch mit deiner E-Mail',
text:
expect.stringContaining('Hallo Peter Lustig') &&
expect.stringContaining(CONFIG.EMAIL_LINK_FORGOTPASSWORD) &&
expect.stringContaining('https://gradido.net/de/contact/'),
})
})
})

View File

@ -0,0 +1,18 @@
import { sendEMail } from './sendEMail'
import { accountMultiRegistration } from './text/accountMultiRegistration'
import CONFIG from '@/config'
export const sendAccountMultiRegistrationEmail = (data: {
firstName: string
lastName: string
email: string
}): Promise<boolean> => {
return sendEMail({
to: `${data.firstName} ${data.lastName} <${data.email}>`,
subject: accountMultiRegistration.de.subject,
text: accountMultiRegistration.de.text({
...data,
resendLink: CONFIG.EMAIL_LINK_FORGOTPASSWORD,
}),
})
}

View File

@ -31,6 +31,7 @@ describe('sendEMail', () => {
beforeEach(async () => {
result = await sendEMail({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
@ -50,6 +51,7 @@ describe('sendEMail', () => {
CONFIG.EMAIL = true
result = await sendEMail({
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})
@ -72,6 +74,7 @@ describe('sendEMail', () => {
expect((createTransport as jest.Mock).mock.results[0].value.sendMail).toBeCalledWith({
from: `Gradido (nicht antworten) <${CONFIG.EMAIL_SENDER}>`,
to: 'receiver@mail.org',
cc: 'support@gradido.net',
subject: 'Subject',
text: 'Text text text',
})

View File

@ -5,10 +5,15 @@ import CONFIG from '@/config'
export const sendEMail = async (emailDef: {
to: string
cc?: string
subject: string
text: string
}): Promise<boolean> => {
logger.info(`send Email: to=${emailDef.to}, subject=${emailDef.subject}, text=${emailDef.text}`)
logger.info(
`send Email: to=${emailDef.to}` +
(emailDef.cc ? `, cc=${emailDef.cc}` : '') +
`, subject=${emailDef.subject}, text=${emailDef.text}`,
)
if (!CONFIG.EMAIL) {
logger.info(`Emails are disabled via config...`)

View File

@ -0,0 +1,25 @@
export const accountMultiRegistration = {
de: {
subject: 'Gradido: Erneuter Registrierungsversuch mit deiner E-Mail',
text: (data: {
firstName: string
lastName: string
email: string
resendLink: string
}): string =>
`Hallo ${data.firstName} ${data.lastName},
Deine E-Mail-Adresse wurde soeben erneut benutzt, um bei Gradido ein Konto zu registrieren.
Es existiert jedoch zu deiner E-Mail-Adresse schon ein Konto.
Klicke bitte auf den folgenden Link, falls du dein Passwort vergessen haben solltest:
${data.resendLink}
oder kopiere den obigen Link in dein Browserfenster.
Wenn du nicht derjenige bist, der sich versucht hat erneut zu registrieren, wende dich bitte an unseren support:
https://gradido.net/de/contact/
Mit freundlichen Grüßen,
dein Gradido-Team`,
},
}

View File

@ -0,0 +1,7 @@
export interface ContributionLinkInterface {
amount: number
name: string
memo: string
validFrom?: Date
validTo?: Date
}

View File

@ -0,0 +1,18 @@
import { ContributionLinkInterface } from './ContributionLinkInterface'
export const contributionLinks: ContributionLinkInterface[] = [
{
name: 'Dokumenta 2017',
memo: 'Vielen Dank für deinen Besuch bei der Dokumenta 2017',
amount: 200,
validFrom: new Date(2017, 3, 8),
validTo: new Date(2017, 6, 16),
},
{
name: 'Dokumenta 2022',
memo: 'Vielen Dank für deinen Besuch bei der Dokumenta 2022',
amount: 200,
validFrom: new Date(2022, 5, 18),
validTo: new Date(2022, 8, 25),
},
]

View File

@ -0,0 +1,29 @@
import { ApolloServerTestClient } from 'apollo-server-testing'
import { createContributionLink } from '@/seeds/graphql/mutations'
import { login } from '@/seeds/graphql/queries'
import { ContributionLink } from '@model/ContributionLink'
import { ContributionLinkInterface } from '@/seeds/contributionLink/ContributionLinkInterface'
export const contributionLinkFactory = async (
client: ApolloServerTestClient,
contributionLink: ContributionLinkInterface,
): Promise<ContributionLink> => {
const { mutate, query } = client
// login as admin
await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } })
const variables = {
amount: contributionLink.amount,
memo: contributionLink.memo,
name: contributionLink.name,
cycle: 'ONCE',
maxPerCycle: 1,
maxAmountPerMonth: 200,
validFrom: contributionLink.validFrom ? contributionLink.validFrom.toISOString() : undefined,
validTo: contributionLink.validTo ? contributionLink.validTo.toISOString() : undefined,
}
const result = await mutate({ mutation: createContributionLink, variables })
return result.data.createContributionLink
}

View File

@ -1,13 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { createPendingCreation, confirmPendingCreation } from '@/seeds/graphql/mutations'
import { adminCreateContribution, confirmContribution } from '@/seeds/graphql/mutations'
import { login } from '@/seeds/graphql/queries'
import { CreationInterface } from '@/seeds/creation/CreationInterface'
import { ApolloServerTestClient } from 'apollo-server-testing'
import { User } from '@entity/User'
import { Transaction } from '@entity/Transaction'
import { AdminPendingCreation } from '@entity/AdminPendingCreation'
import { Contribution } from '@entity/Contribution'
// import CONFIG from '@/config/index'
export const nMonthsBefore = (date: Date, months = 1): string => {
@ -17,23 +17,25 @@ export const nMonthsBefore = (date: Date, months = 1): string => {
export const creationFactory = async (
client: ApolloServerTestClient,
creation: CreationInterface,
): Promise<AdminPendingCreation | void> => {
): Promise<Contribution | void> => {
const { mutate, query } = client
await query({ query: login, variables: { email: 'peter@lustig.de', password: 'Aa12345_' } })
// TODO it would be nice to have this mutation return the id
await mutate({ mutation: createPendingCreation, variables: { ...creation } })
await mutate({ mutation: adminCreateContribution, variables: { ...creation } })
const user = await User.findOneOrFail({ where: { email: creation.email } })
const pendingCreation = await AdminPendingCreation.findOneOrFail({
const pendingCreation = await Contribution.findOneOrFail({
where: { userId: user.id, amount: creation.amount },
order: { created: 'DESC' },
order: { createdAt: 'DESC' },
})
if (creation.confirmed) {
await mutate({ mutation: confirmPendingCreation, variables: { id: pendingCreation.id } })
await mutate({ mutation: confirmContribution, variables: { id: pendingCreation.id } })
const confirmedCreation = await Contribution.findOneOrFail({ id: pendingCreation.id })
if (creation.moveCreationDate) {
const transaction = await Transaction.findOneOrFail({
@ -41,6 +43,9 @@ export const creationFactory = async (
order: { balanceDate: 'DESC' },
})
if (transaction.decay.equals(0) && transaction.creationDate) {
confirmedCreation.contributionDate = new Date(
nMonthsBefore(transaction.creationDate, creation.moveCreationDate),
)
transaction.creationDate = new Date(
nMonthsBefore(transaction.creationDate, creation.moveCreationDate),
)
@ -48,6 +53,7 @@ export const creationFactory = async (
nMonthsBefore(transaction.balanceDate, creation.moveCreationDate),
)
await transaction.save()
await confirmedCreation.save()
}
}
} else {

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