Merge remote-tracking branch 'origin/master' into 1574-Concept_to_introduce_Gradido-ID

This commit is contained in:
Claus-Peter Hübner 2022-08-24 15:31:42 +02:00
commit cb4d0dc9b2
156 changed files with 7023 additions and 595 deletions

View File

@ -4,8 +4,55 @@ 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)

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

@ -26,5 +26,5 @@ module.exports = {
testMatch: ['**/?(*.)+(spec|test).js?(x)'],
// snapshotSerializers: ['jest-serializer-vue'],
transformIgnorePatterns: ['<rootDir>/node_modules/(?!vee-validate/dist/rules)'],
testEnvironment: 'jest-environment-jsdom-sixteen',
testEnvironment: 'jest-environment-jsdom-sixteen', // why this is still needed? should not be needed anymore since jest@26, see: https://www.npmjs.com/package/jest-environment-jsdom-sixteen
}

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "1.10.1",
"version": "1.11.0",
"license": "Apache-2.0",
"private": false,
"scripts": {
@ -39,6 +39,7 @@
"identity-obj-proxy": "^3.0.0",
"jest": "26.6.3",
"jest-canvas-mock": "^2.3.1",
"jest-environment-jsdom-sixteen": "^2.0.0",
"portal-vue": "^2.1.7",
"qrcanvas-vue": "2.1.1",
"regenerator-runtime": "^0.13.9",
@ -70,7 +71,6 @@
"eslint-plugin-prettier": "3.3.1",
"eslint-plugin-promise": "^5.1.1",
"eslint-plugin-vue": "^7.20.0",
"jest-environment-jsdom-sixteen": "^2.0.0",
"postcss": "^8.4.8",
"postcss-html": "^1.3.0",
"postcss-scss": "^4.0.3",

View File

@ -0,0 +1,39 @@
import { mount } from '@vue/test-utils'
import CommunityStatistic from './CommunityStatistic'
const localVue = global.localVue
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
}
const propsData = {
value: {
totalUsers: '123',
activeUsers: '100',
deletedUsers: '5',
totalGradidoCreated: '2500',
totalGradidoDecayed: '200',
totalGradidoAvailable: '500',
totalGradidoUnbookedDecayed: '111',
},
}
describe('CommunityStatistic', () => {
let wrapper
const Wrapper = () => {
return mount(CommunityStatistic, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".community-statistic"', () => {
expect(wrapper.find('div.community-statistic').exists()).toBe(true)
})
})
})

View File

@ -0,0 +1,59 @@
<template>
<div class="community-statistic">
<div>
<b-jumbotron bg-variant="info" text-variant="white" border-variant="dark">
<template #header>{{ $t('statistic.name') }}</template>
<hr class="my-4" />
<div>
{{ $t('statistic.totalUsers') }}{{ $t('math.colon') }}
<b>{{ value.totalUsers }}</b>
</div>
<div>
{{ $t('statistic.activeUsers') }}{{ $t('math.colon') }}
<b>{{ value.activeUsers }}</b>
</div>
<div>
{{ $t('statistic.deletedUsers') }}{{ $t('math.colon') }}
<b>{{ value.deletedUsers }}</b>
</div>
<div>
{{ $t('statistic.totalGradidoCreated') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoCreated, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoCreated }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoDecayed') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoDecayed, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoDecayed }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoAvailable') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoAvailable, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoAvailable }}</small>
</div>
<div>
{{ $t('statistic.totalGradidoUnbookedDecayed') }}{{ $t('math.colon') }}
<b>{{ $n(value.totalGradidoUnbookedDecayed, 'decimal') }} {{ $t('GDD') }}</b>
<small class="ml-5">{{ value.totalGradidoUnbookedDecayed }}</small>
</div>
</b-jumbotron>
</div>
</div>
</template>
<script>
import CONFIG from '@/config'
export default {
name: 'CommunityStatistic',
props: {
value: { type: Object },
},
data() {
return {
CONFIG,
}
},
}
</script>

View File

@ -0,0 +1,29 @@
import { mount } from '@vue/test-utils'
import ContentFooter from './ContentFooter'
const localVue = global.localVue
const mocks = {
$t: jest.fn((t) => t),
$i18n: {
locale: jest.fn(() => 'en'),
},
}
describe('ContentFooter', () => {
let wrapper
const Wrapper = () => {
return mount(ContentFooter, { localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the div element ".content-footer"', () => {
expect(wrapper.find('div.content-footer').exists()).toBe(true)
})
})
})

View File

@ -1,5 +1,5 @@
<template>
<div>
<div class="content-footer">
<hr />
<b-row align-v="center" class="mt-4 justify-content-lg-between">
<b-col>

View File

@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import ContributionLinkForm from './ContributionLinkForm.vue'
import { toastErrorSpy } from '../../test/testSetup'
const localVue = global.localVue
@ -8,9 +9,13 @@ global.alert = jest.fn()
const propsData = {
contributionLinkData: {},
}
const apolloMutateMock = jest.fn().mockResolvedValue()
const mocks = {
$t: jest.fn((t) => t),
$apollo: {
mutate: apolloMutateMock,
},
}
// const mockAPIcall = jest.fn()
@ -99,4 +104,16 @@ describe('ContributionLinkForm', () => {
// })
// })
})
describe('send createContributionLink with error', () => {
beforeEach(() => {
apolloMutateMock.mockRejectedValue({ message: 'OUCH!' })
wrapper = Wrapper()
wrapper.vm.onSubmit()
})
it('toasts an error message', () => {
expect(toastErrorSpy).toBeCalledWith('contributionLink.noStartDate')
})
})
})

View File

@ -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,7 +66,7 @@ const propsData = {
},
},
{ key: 'moderator', label: 'moderator' },
{ key: 'edit_creation', label: 'edit' },
{ key: 'editCreation', label: 'edit' },
{ key: 'confirm', label: 'save' },
],
toggleDetails: false,
@ -113,6 +113,10 @@ describe('OpenCreationsTable', () => {
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', () => {
beforeEach(async () => {
await wrapper.findAll('tr').at(1).find('.bi-pencil-square').trigger('click')

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>

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,15 @@
import gql from 'graphql-tag'
export const communityStatistics = gql`
query {
communityStatistics {
totalUsers
activeUsers
deletedUsers
totalGradidoCreated
totalGradidoDecayed
totalGradidoAvailable
totalGradidoUnbookedDecayed
}
}
`

View File

@ -35,6 +35,7 @@
"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",
@ -72,6 +73,7 @@
"hide_details": "Details verbergen",
"lastname": "Nachname",
"math": {
"colon": ":",
"exclaim": "!",
"pipe": "|",
"plus": "+"
@ -104,6 +106,16 @@
"removeNotSelf": "Als Admin/Moderator kannst du dich nicht selber löschen.",
"remove_all": "alle Nutzer entfernen",
"save": "Speichern",
"statistic": {
"activeUsers": "Aktive Mitglieder",
"deletedUsers": "Gelöschte Mitglieder",
"name": "Statistik",
"totalGradidoAvailable": "GDD insgesamt im Umlauf",
"totalGradidoCreated": "GDD insgesamt geschöpft",
"totalGradidoDecayed": "GDD insgesamt verfallen",
"totalGradidoUnbookedDecayed": "Ungebuchter GDD Verfall",
"totalUsers": "Mitglieder"
},
"status": "Status",
"success": "Erfolg",
"text": "Text",

View File

@ -35,6 +35,7 @@
"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",
@ -72,6 +73,7 @@
"hide_details": "Hide details",
"lastname": "Lastname",
"math": {
"colon": ":",
"exclaim": "!",
"pipe": "|",
"plus": "+"
@ -104,6 +106,16 @@
"removeNotSelf": "As an admin/moderator, you cannot delete yourself.",
"remove_all": "Remove all users",
"save": "Speichern",
"statistic": {
"activeUsers": "Active members",
"deletedUsers": "Deleted members",
"name": "Statistic",
"totalGradidoAvailable": "Total GDD in circulation",
"totalGradidoCreated": "Total created GDD",
"totalGradidoDecayed": "Total GDD decay",
"totalGradidoUnbookedDecayed": "Unbooked GDD decay",
"totalUsers": "Members"
},
"status": "Status",
"success": "Success",
"text": "Text",

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

@ -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 adminDeleteContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: adminDeleteContribution,
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')
})

View File

@ -34,20 +34,23 @@ export default {
},
methods: {
removeCreation(item) {
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)
})
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
@ -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

@ -1,28 +1,83 @@
import { mount } from '@vue/test-utils'
import Overview from './Overview.vue'
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
listUnconfirmedContributions: [
{
pending: true,
const apolloQueryMock = jest
.fn()
.mockResolvedValueOnce({
data: {
listUnconfirmedContributions: [
{
pending: true,
},
{
pending: true,
},
{
pending: true,
},
],
},
})
.mockResolvedValueOnce({
data: {
communityStatistics: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
{
pending: true,
},
})
.mockResolvedValueOnce({
data: {
listContributionLinks: {
links: [
{
id: 1,
name: 'Meditation',
memo: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut l',
amount: '200',
validFrom: '2022-04-01',
validTo: '2022-08-01',
cycle: 'täglich',
maxPerCycle: '3',
maxAmountPerMonth: 0,
link: 'https://localhost/redeem/CL-1a2345678',
},
],
count: 1,
},
{
pending: true,
},
],
},
})
},
})
.mockResolvedValue({
data: {
listUnconfirmedContributions: [
{
pending: true,
},
{
pending: true,
},
{
pending: true,
},
],
},
})
const storeCommitMock = jest.fn()
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
$apollo: {
query: apolloQueryMock,
},
@ -47,10 +102,30 @@ describe('Overview', () => {
})
it('calls listUnconfirmedContributions', () => {
expect(apolloQueryMock).toBeCalled()
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: listUnconfirmedContributions,
}),
)
})
it('commts three pending creations to store', () => {
it('calls communityStatistics', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: communityStatistics,
}),
)
})
it('calls listContributionLinks', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: listContributionLinks,
}),
)
})
it('commits three pending creations to store', () => {
expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3)
})

View File

@ -29,26 +29,39 @@
</b-card-text>
</b-card>
<contribution-link :items="items" :count="count" />
<community-statistic class="mt-5" v-model="statistics" />
</div>
</template>
<script>
import { listContributionLinks } from '@/graphql/listContributionLinks.js'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import ContributionLink from '../components/ContributionLink.vue'
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
import CommunityStatistic from '../components/CommunityStatistic.vue'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
export default {
name: 'overview',
components: {
ContributionLink,
CommunityStatistic,
},
data() {
return {
items: [],
count: 0,
statistics: {
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
},
}
},
methods: {
async getPendingCreations() {
getPendingCreations() {
this.$apollo
.query({
query: listUnconfirmedContributions,
@ -58,7 +71,7 @@ export default {
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
})
},
async getContributionLinks() {
getContributionLinks() {
this.$apollo
.query({
query: listContributionLinks,
@ -72,9 +85,31 @@ export default {
this.toastError('listContributionLinks has no result, use default data')
})
},
getCommunityStatistics() {
this.$apollo
.query({
query: communityStatistics,
fetchPolicy: 'network-only',
})
.then((result) => {
this.statistics.totalUsers = result.data.communityStatistics.totalUsers
this.statistics.activeUsers = result.data.communityStatistics.activeUsers
this.statistics.deletedUsers = result.data.communityStatistics.deletedUsers
this.statistics.totalGradidoCreated = result.data.communityStatistics.totalGradidoCreated
this.statistics.totalGradidoDecayed = result.data.communityStatistics.totalGradidoDecayed
this.statistics.totalGradidoAvailable =
result.data.communityStatistics.totalGradidoAvailable
this.statistics.totalGradidoUnbookedDecayed =
result.data.communityStatistics.totalGradidoUnbookedDecayed
})
.catch(() => {
this.toastError('communityStatistics has no result, use default data')
})
},
},
created() {
this.getPendingCreations()
this.getCommunityStatistics()
this.getContributionLinks()
},
}

View File

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

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v8.2022-06-20
CONFIG_VERSION=v9.2022-07-07
# Server
PORT=4000
@ -52,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

@ -50,3 +50,6 @@ 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

@ -25,6 +25,14 @@
"keepFileExt" : true,
"fileNameSep" : "_"
},
"klicktipp":
{
"type": "dateFile",
"filename": "../logs/backend/klicktipp.log",
"pattern": "%d{ISO8601} %p %c %X{user} %f:%l %m",
"keepFileExt" : true,
"fileNameSep" : "_"
},
"errorFile":
{
"type": "dateFile",
@ -90,6 +98,17 @@
"level": "debug",
"enableCallStack": true
},
"klicktipp":
{
"appenders":
[
"klicktipp",
"out",
"errors"
],
"level": "debug",
"enableCallStack": true
},
"http":
{
"appenders":

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "1.10.1",
"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",
@ -19,6 +19,7 @@
"dependencies": {
"@types/jest": "^27.0.2",
"@types/lodash.clonedeep": "^4.5.6",
"@types/uuid": "^8.3.4",
"apollo-server-express": "^2.25.2",
"apollo-server-testing": "^2.25.2",
"axios": "^0.21.1",
@ -39,7 +40,8 @@
"reflect-metadata": "^0.1.13",
"sodium-native": "^3.3.0",
"ts-jest": "^27.0.5",
"type-graphql": "^1.1.1"
"type-graphql": "^1.1.1",
"uuid": "^8.3.2"
},
"devDependencies": {
"@types/express": "^4.17.12",

View File

@ -26,8 +26,13 @@ export enum RIGHTS {
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',
LIST_CONTRIBUTION_LINKS = 'LIST_CONTRIBUTION_LINKS',
COMMUNITY_STATISTICS = 'COMMUNITY_STATISTICS',
SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS',
// Admin
SEARCH_USERS = 'SEARCH_USERS',
SET_USER_ROLE = 'SET_USER_ROLE',
@ -43,7 +48,6 @@ export enum RIGHTS {
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

@ -24,8 +24,13 @@ export const ROLE_USER = new Role('user', [
RIGHTS.LIST_TRANSACTION_LINKS,
RIGHTS.GDT_BALANCE,
RIGHTS.CREATE_CONTRIBUTION,
RIGHTS.DELETE_CONTRIBUTION,
RIGHTS.LIST_CONTRIBUTIONS,
RIGHTS.LIST_ALL_CONTRIBUTIONS,
RIGHTS.UPDATE_CONTRIBUTION,
RIGHTS.SEARCH_ADMIN_USERS,
RIGHTS.LIST_CONTRIBUTION_LINKS,
RIGHTS.COMMUNITY_STATISTICS,
])
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights

View File

@ -10,14 +10,14 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0042-update_transactions_for_blockchain',
DB_VERSION: '0047-messages_tables',
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: 'v8.2022-06-20',
EXPECTED: 'v9.2022-07-07',
CURRENT: '',
},
}
@ -94,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
@ -118,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

@ -0,0 +1,14 @@
import { registerEnumType } from 'type-graphql'
export enum ContributionStatus {
PENDING = 'PENDING',
DELETED = 'DELETED',
IN_PROGRESS = 'IN_PROGRESS',
DENIED = 'DENIED',
CONFIRMED = 'CONFIRMED',
}
registerEnumType(ContributionStatus, {
name: 'ContributionStatus',
description: 'Name of the Type of the Contribution Status',
})

View File

@ -0,0 +1,12 @@
import { registerEnumType } from 'type-graphql'
export enum ContributionType {
ADMIN = 'ADMIN',
USER = 'USER',
LINK = 'LINK',
}
registerEnumType(ContributionType, {
name: 'ContributionType',
description: 'Name of the Type of the Contribution',
})

View File

@ -0,0 +1,11 @@
import { registerEnumType } from 'type-graphql'
export enum ContributionMessageType {
HISTORY = 'HISTORY',
DIALOG = 'DIALOG',
}
registerEnumType(ContributionMessageType, {
name: 'ContributionMessageType',
description: 'Name of the Type of the ContributionMessage',
})

View File

@ -0,0 +1,25 @@
import { User } from '@entity/User'
import { Field, Int, ObjectType } from 'type-graphql'
@ObjectType()
export class AdminUser {
constructor(user: User) {
this.firstName = user.firstName
this.lastName = user.lastName
}
@Field(() => String)
firstName: string
@Field(() => String)
lastName: string
}
@ObjectType()
export class SearchAdminUsersResult {
@Field(() => Int)
userCount: number
@Field(() => [AdminUser])
userList: AdminUser[]
}

View File

@ -0,0 +1,26 @@
import { ObjectType, Field } from 'type-graphql'
import Decimal from 'decimal.js-light'
@ObjectType()
export class CommunityStatistics {
@Field(() => Number)
totalUsers: number
@Field(() => Number)
activeUsers: number
@Field(() => Number)
deletedUsers: number
@Field(() => Decimal)
totalGradidoCreated: Decimal
@Field(() => Decimal)
totalGradidoDecayed: Decimal
@Field(() => Decimal)
totalGradidoAvailable: Decimal
@Field(() => Decimal)
totalGradidoUnbookedDecayed: Decimal
}

View File

@ -7,18 +7,25 @@ import { User } from './User'
export class Contribution {
constructor(contribution: dbContribution, user: User) {
this.id = contribution.id
this.user = user
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(() => User)
user: User
@Field(() => String, { nullable: true })
firstName: string | null
@Field(() => String, { nullable: true })
lastName: string | null
@Field(() => Decimal)
amount: Decimal
@ -31,13 +38,27 @@ export class Contribution {
@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)
linkCount: number
contributionCount: number
@Field(() => [Contribution])
linkList: Contribution[]
contributionList: Contribution[]
}

View File

@ -8,6 +8,8 @@ import { FULL_CREATION_AVAILABLE } from '../resolver/const/const'
export class User {
constructor(user: dbUser, creation: Decimal[] = FULL_CREATION_AVAILABLE) {
this.id = user.id
this.gradidoID = user.gradidoID
this.alias = user.alias
this.email = user.email
this.firstName = user.firstName
this.lastName = user.lastName
@ -28,6 +30,12 @@ export class User {
// `public_key` binary(32) DEFAULT NULL,
// `privkey` binary(80) DEFAULT NULL,
@Field(() => String)
gradidoID: string
@Field(() => String, { nullable: true })
alias: string
// TODO privacy issue here
@Field(() => String)
email: string

View File

@ -1857,11 +1857,17 @@ describe('AdminResolver', () => {
})
})
// TODO: Set this test in new location to have datas
describe('listContributionLinks', () => {
it('returns an error', async () => {
it('returns an empty object', async () => {
await expect(query({ query: listContributionLinks })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
data: {
listContributionLinks: {
count: 0,
links: [],
},
},
}),
)
})

View File

@ -36,6 +36,8 @@ import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User as dbUser } from '@entity/User'
import { User } from '@model/User'
import { TransactionTypeId } from '@enum/TransactionTypeId'
import { ContributionType } from '@enum/ContributionType'
import { ContributionStatus } from '@enum/ContributionStatus'
import Decimal from 'decimal.js-light'
import { Decay } from '@model/Decay'
import Paginated from '@arg/Paginated'
@ -54,11 +56,11 @@ import {
updateCreations,
} from './util/creations'
import {
CONTRIBUTIONLINK_MEMO_MAX_CHARS,
CONTRIBUTIONLINK_MEMO_MIN_CHARS,
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
@ -260,6 +262,8 @@ export class AdminResolver {
contribution.contributionDate = creationDateObj
contribution.memo = memo
contribution.moderatorId = moderator.id
contribution.contributionType = ContributionType.ADMIN
contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution)
await Contribution.save(contribution)
@ -337,6 +341,7 @@ export class AdminResolver {
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.moderatorId = moderator.id
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
await Contribution.save(contributionToUpdate)
const result = new AdminUpdateContribution()
@ -387,6 +392,8 @@ export class AdminResolver {
if (!contribution) {
throw new Error('Contribution not found for given id.')
}
contribution.contributionStatus = ContributionStatus.DELETED
await contribution.save()
const res = await contribution.softRemove()
return !!res
}
@ -454,6 +461,7 @@ export class AdminResolver {
contribution.confirmedAt = receivedCallDate
contribution.confirmedBy = moderatorUser.id
contribution.transactionId = transaction.id
contribution.contributionStatus = ContributionStatus.CONFIRMED
await queryRunner.manager.update(Contribution, { id: contribution.id }, contribution)
await queryRunner.commitTransaction()
@ -501,7 +509,7 @@ export class AdminResolver {
order: { updatedAt: 'DESC' },
})
optInCode = await checkOptInCode(optInCode, user.id)
optInCode = await checkOptInCode(optInCode, user)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendAccountActivationEmail({
@ -595,11 +603,8 @@ export class AdminResolver {
logger.error(`The memo must be initialized!`)
throw new Error(`The memo must be initialized!`)
}
if (
memo.length < CONTRIBUTIONLINK_MEMO_MIN_CHARS ||
memo.length > CONTRIBUTIONLINK_MEMO_MAX_CHARS
) {
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_MEMO_MIN_CHARS} and max=${CONTRIBUTIONLINK_MEMO_MAX_CHARS}`
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}`)
}

View File

@ -4,10 +4,12 @@
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import {
adminUpdateContribution,
confirmContribution,
createContribution,
deleteContribution,
updateContribution,
} from '@/seeds/graphql/mutations'
import { listContributions, login } from '@/seeds/graphql/queries'
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'
@ -64,6 +66,42 @@ describe('ContributionResolver', () => {
})
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({
@ -193,18 +231,21 @@ describe('ContributionResolver', () => {
).resolves.toEqual(
expect.objectContaining({
data: {
listContributions: 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',
}),
]),
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',
}),
]),
},
},
}),
)
@ -226,13 +267,16 @@ describe('ContributionResolver', () => {
).resolves.toEqual(
expect.objectContaining({
data: {
listContributions: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
listContributions: {
contributionCount: 1,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
@ -305,6 +349,48 @@ describe('ContributionResolver', () => {
})
})
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({
@ -438,4 +524,212 @@ describe('ContributionResolver', () => {
})
})
})
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

@ -3,14 +3,17 @@ import { Context, getUser } from '@/server/context'
import { backendLogger as logger } from '@/server/logger'
import { Contribution as dbContribution } from '@entity/Contribution'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { FindOperator, IsNull } from '@dbTools/typeorm'
import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm'
import ContributionArgs from '@arg/ContributionArgs'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
import { Contribution } from '@model/Contribution'
import { ContributionType } from '@enum/ContributionType'
import { ContributionStatus } from '@enum/ContributionStatus'
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 {
@ -20,6 +23,16 @@ export class ContributionResolver {
@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)
@ -32,36 +45,88 @@ export class ContributionResolver {
contribution.createdAt = new Date()
contribution.contributionDate = creationDateObj
contribution.memo = memo
contribution.contributionType = ContributionType.USER
contribution.contributionStatus = ContributionStatus.PENDING
logger.trace('contribution to save', contribution)
await dbContribution.save(contribution)
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')
}
contribution.contributionStatus = ContributionStatus.DELETED
await contribution.save()
const res = await contribution.softRemove()
return !!res
}
@Authorized([RIGHTS.LIST_CONTRIBUTIONS])
@Query(() => [Contribution])
@Query(() => ContributionListResult)
async listContributions(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Arg('filterConfirmed', () => Boolean)
filterConfirmed: boolean | null,
@Ctx() context: Context,
): Promise<Contribution[]> {
): Promise<ContributionListResult> {
const user = getUser(context)
const where: {
userId: number
confirmedBy?: FindOperator<number> | null
} = { userId: user.id }
if (filterConfirmed) where.confirmedBy = IsNull()
const contributions = await dbContribution.find({
const [contributions, count] = await dbContribution.findAndCount({
where,
order: {
createdAt: order,
},
withDeleted: true,
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return contributions.map((contribution) => new Contribution(contribution, new User(user)))
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])
@ -72,6 +137,16 @@ export class ContributionResolver {
@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({
@ -95,6 +170,7 @@ export class ContributionResolver {
contributionToUpdate.amount = amount
contributionToUpdate.memo = memo
contributionToUpdate.contributionDate = new Date(creationDate)
contributionToUpdate.contributionStatus = ContributionStatus.PENDING
dbContribution.save(contributionToUpdate)
return new UnconfirmedContribution(contributionToUpdate, user, creations)

View File

@ -0,0 +1,77 @@
import { Resolver, Query, Authorized } from 'type-graphql'
import { RIGHTS } from '@/auth/RIGHTS'
import { CommunityStatistics } from '@model/CommunityStatistics'
import { User as DbUser } from '@entity/User'
import { Transaction as DbTransaction } from '@entity/Transaction'
import { getConnection } from '@dbTools/typeorm'
import Decimal from 'decimal.js-light'
import { calculateDecay } from '@/util/decay'
@Resolver()
export class StatisticsResolver {
@Authorized([RIGHTS.COMMUNITY_STATISTICS])
@Query(() => CommunityStatistics)
async communityStatistics(): Promise<CommunityStatistics> {
const allUsers = await DbUser.find({ withDeleted: true })
let totalUsers = 0
let activeUsers = 0
let deletedUsers = 0
let totalGradidoAvailable: Decimal = new Decimal(0)
let totalGradidoUnbookedDecayed: Decimal = new Decimal(0)
const receivedCallDate = new Date()
for (let i = 0; i < allUsers.length; i++) {
if (allUsers[i].deletedAt) {
deletedUsers++
} else {
totalUsers++
const lastTransaction = await DbTransaction.findOne({
where: { userId: allUsers[i].id },
order: { balanceDate: 'DESC' },
})
if (lastTransaction) {
activeUsers++
const decay = calculateDecay(
lastTransaction.balance,
lastTransaction.balanceDate,
receivedCallDate,
)
if (decay) {
totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString())
totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString())
}
}
}
}
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
const { totalGradidoCreated } = await queryRunner.manager
.createQueryBuilder()
.select('SUM(transaction.amount) AS totalGradidoCreated')
.from(DbTransaction, 'transaction')
.where('transaction.typeId = 1')
.getRawOne()
const { totalGradidoDecayed } = await queryRunner.manager
.createQueryBuilder()
.select('SUM(transaction.decay) AS totalGradidoDecayed')
.from(DbTransaction, 'transaction')
.where('transaction.decay IS NOT NULL')
.getRawOne()
return {
totalUsers,
activeUsers,
deletedUsers,
totalGradidoCreated,
totalGradidoDecayed,
totalGradidoAvailable,
totalGradidoUnbookedDecayed,
}
}
}

View File

@ -26,6 +26,8 @@ import { User } from '@model/User'
import { calculateDecay } from '@/util/decay'
import { executeTransaction } from './TransactionResolver'
import { Order } from '@enum/Order'
import { ContributionType } from '@enum/ContributionType'
import { ContributionStatus } from '@enum/ContributionStatus'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { getUserCreation, validateContribution } from './util/creations'
@ -231,6 +233,9 @@ export class TransactionLinkResolver {
contribution.memo = contributionLink.memo
contribution.amount = contributionLink.amount
contribution.contributionLinkId = contributionLink.id
contribution.contributionType = ContributionType.LINK
contribution.contributionStatus = ContributionStatus.CONFIRMED
await queryRunner.manager.insert(DbContribution, contribution)
const lastTransaction = await queryRunner.manager

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

@ -5,7 +5,7 @@ import { testEnvironment, headerPushMock, resetToken, cleanDB, resetEntity } fro
import { userFactory } from '@/seeds/factory/user'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { createUser, setPassword, forgotPassword, updateUserInfos } from '@/seeds/graphql/mutations'
import { login, logout, verifyLogin, queryOptIn } from '@/seeds/graphql/queries'
import { login, logout, verifyLogin, queryOptIn, searchAdminUsers } from '@/seeds/graphql/queries'
import { GraphQLError } from 'graphql'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { User } from '@entity/User'
@ -20,6 +20,8 @@ import { ContributionLink } from '@model/ContributionLink'
// import { TransactionLink } from '@entity/TransactionLink'
import { logger } from '@test/testSetup'
import { validate as validateUUID, version as versionUUID } from 'uuid'
import { peterLustig } from '@/seeds/users/peter-lustig'
// import { klicktippSignIn } from '@/apis/KlicktippController'
@ -111,6 +113,8 @@ describe('UserResolver', () => {
expect(user).toEqual([
{
id: expect.any(Number),
gradidoID: expect.any(String),
alias: null,
email: 'peter@lustig.de',
firstName: 'Peter',
lastName: 'Lustig',
@ -129,6 +133,10 @@ describe('UserResolver', () => {
contributionLinkId: null,
},
])
const valUUID = validateUUID(user[0].gradidoID)
const verUUID = versionUUID(user[0].gradidoID)
expect(valUUID).toEqual(true)
expect(verUUID).toEqual(4)
})
it('creates an email optin', () => {
@ -198,7 +206,7 @@ describe('UserResolver', () => {
it('sets "de" as default language', async () => {
await mutate({
mutation: createUser,
variables: { ...variables, email: 'bibi@bloxberg.de', language: 'es' },
variables: { ...variables, email: 'bibi@bloxberg.de', language: 'it' },
})
await expect(User.find()).resolves.toEqual(
expect.arrayContaining([
@ -871,6 +879,51 @@ bei Gradidio sei dabei!`,
})
})
})
describe('searchAdminUsers', () => {
describe('unauthenticated', () => {
it('throws an error', async () => {
resetToken()
await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
await query({
query: login,
variables: {
email: 'bibi@bloxberg.de',
password: 'Aa12345_',
},
})
})
it('finds peter@lustig.de', async () => {
await expect(mutate({ mutation: searchAdminUsers })).resolves.toEqual(
expect.objectContaining({
data: {
searchAdminUsers: {
userCount: 1,
userList: expect.arrayContaining([
expect.objectContaining({
firstName: 'Peter',
lastName: 'Lustig',
}),
]),
},
},
}),
)
})
})
})
})
describe('printTimeDuration', () => {

View File

@ -3,7 +3,7 @@ import { backendLogger as logger } from '@/server/logger'
import { Context, getUser } from '@/server/context'
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
import { getConnection } from '@dbTools/typeorm'
import { getConnection, getCustomRepository, IsNull, Not } from '@dbTools/typeorm'
import CONFIG from '@/config'
import { User } from '@model/User'
import { User as DbUser } from '@entity/User'
@ -23,7 +23,20 @@ import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegi
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'
import { UserRepository } from '@/typeorm/repository/User'
import { SearchAdminUsersResult } from '@model/AdminUser'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
import { v4 as uuidv4 } from 'uuid'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
@ -35,7 +48,7 @@ const isPassword = (password: string): boolean => {
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
}
const LANGUAGES = ['de', 'en']
const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl']
const DEFAULT_LANGUAGE = 'de'
const isLanguage = (language: string): boolean => {
return LANGUAGES.includes(language)
@ -179,7 +192,7 @@ const newEmailOptIn = (userId: number): LoginEmailOptIn => {
// if optIn does not exits, it is created
export const checkOptInCode = async (
optInCode: LoginEmailOptIn | undefined,
userId: number,
user: DbUser,
optInType: OptInType = OptInType.EMAIL_OPT_IN_REGISTER,
): Promise<LoginEmailOptIn> => {
logger.info(`checkOptInCode... ${optInCode}`)
@ -199,15 +212,18 @@ export const checkOptInCode = async (
optInCode.updatedAt = new Date()
optInCode.resendCount++
} else {
logger.trace('create new OptIn for userId=' + userId)
optInCode = newEmailOptIn(userId)
logger.trace('create new OptIn for userId=' + user.id)
optInCode = newEmailOptIn(user.id)
}
if (user.emailChecked) {
optInCode.emailOptInTypeId = optInType
}
optInCode.emailOptInTypeId = optInType
await LoginEmailOptIn.save(optInCode).catch(() => {
logger.error('Unable to save optin code= ' + optInCode)
throw new Error('Unable to save optin code.')
})
logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${userId}`)
logger.debug(`checkOptInCode...successful: ${optInCode} for userid=${user.id}`)
return optInCode
}
@ -216,6 +232,19 @@ export const activationLink = (optInCode: LoginEmailOptIn): string => {
return CONFIG.EMAIL_LINK_SETPASSWORD.replace(/{optin}/g, optInCode.verificationCode.toString())
}
const newGradidoID = async (): Promise<string> => {
let gradidoId: string
let countIds: number
do {
gradidoId = uuidv4()
countIds = await DbUser.count({ where: { gradidoID: gradidoId } })
if (countIds > 0) {
logger.info('Gradido-ID creation conflict...')
}
} while (countIds > 0)
return gradidoId
}
@Resolver()
export class UserResolver {
@Authorized([RIGHTS.VERIFY_LOGIN])
@ -291,6 +320,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
}
@ -333,11 +365,13 @@ export class UserResolver {
logger.info(`DbUser.findOne(email=${email}) = ${userFound}`)
if (userFound) {
logger.info('User already exists with this email=' + email)
// ATTENTION: this logger-message will be exactly expected during tests
logger.info(`User already exists with this email=${email}`)
// TODO: this is unsecure, but the current implementation of the login server. This way it can be queried if the user with given EMail is existent.
const user = new User(communityDbUser)
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.gradidoID = uuidv4()
user.email = email
user.firstName = firstName
user.lastName = lastName
@ -367,8 +401,13 @@ export class UserResolver {
// const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
const emailHash = getEmailHash(email)
const gradidoID = await newGradidoID()
const eventRegister = new EventRegister()
const eventRedeemRegister = new EventRedeemRegister()
const eventSendConfirmEmail = new EventSendConfirmationEmail()
const dbUser = new DbUser()
dbUser.gradidoID = gradidoID
dbUser.email = email
dbUser.firstName = firstName
dbUser.lastName = lastName
@ -385,12 +424,14 @@ export class UserResolver {
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
}
}
}
@ -401,6 +442,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')
@ -430,6 +472,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) {
@ -446,6 +491,14 @@ export class UserResolver {
}
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)
}
@ -465,7 +518,7 @@ export class UserResolver {
userId: user.id,
})
optInCode = await checkOptInCode(optInCode, user.id, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
optInCode = await checkOptInCode(optInCode, user, OptInType.EMAIL_OPT_IN_RESET_PASSWORD)
logger.info(`optInCode for ${email}=${optInCode}`)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const emailSent = await sendResetPasswordEmailMailer({
@ -703,6 +756,36 @@ export class UserResolver {
logger.debug(`has ElopageBuys = ${elopageBuys}`)
return elopageBuys
}
@Authorized([RIGHTS.SEARCH_ADMIN_USERS])
@Query(() => SearchAdminUsersResult)
async searchAdminUsers(
@Args()
{ currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
): Promise<SearchAdminUsersResult> {
const userRepository = getCustomRepository(UserRepository)
const [users, count] = await userRepository.findAndCount({
where: {
isAdmin: Not(IsNull()),
},
order: {
createdAt: order,
},
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return {
userCount: count,
userList: users.map((user) => {
return {
firstName: user.firstName,
lastName: user.lastName,
}
}),
}
}
}
const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {

View File

@ -8,5 +8,5 @@ export const FULL_CREATION_AVAILABLE = [
]
export const CONTRIBUTIONLINK_NAME_MAX_CHARS = 100
export const CONTRIBUTIONLINK_NAME_MIN_CHARS = 5
export const CONTRIBUTIONLINK_MEMO_MAX_CHARS = 255
export const CONTRIBUTIONLINK_MEMO_MIN_CHARS = 5
export const MEMO_MAX_CHARS = 255
export const MEMO_MIN_CHARS = 5

View File

@ -2,6 +2,7 @@ import { MiddlewareFn } from 'type-graphql'
import { /* klicktippSignIn, */ getKlickTippUser } from '@/apis/KlicktippController'
import { KlickTipp } from '@model/KlickTipp'
import CONFIG from '@/config'
import { klickTippLogger as logger } from '@/server/logger'
// export const klicktippRegistrationMiddleware: MiddlewareFn = async (
// // Only for demo
@ -29,7 +30,9 @@ export const klicktippNewsletterStateMiddleware: MiddlewareFn = async (
if (klickTippUser) {
klickTipp = new KlickTipp(klickTippUser)
}
} catch (err) {}
} catch (err) {
logger.error(`There is no user for (email='${result.email}') ${err}`)
}
}
result.klickTipp = klickTipp
return result

View File

@ -35,12 +35,17 @@ export const creationFactory = async (
if (creation.confirmed) {
await mutate({ mutation: confirmContribution, variables: { id: pendingCreation.id } })
const confirmedCreation = await Contribution.findOneOrFail({ id: pendingCreation.id })
if (creation.moveCreationDate) {
const transaction = await Transaction.findOneOrFail({
where: { userId: user.id, creationDate: new Date(creation.creationDate) },
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 {

View File

@ -255,3 +255,9 @@ export const updateContribution = gql`
}
}
`
export const deleteContribution = gql`
mutation ($id: Int!) {
deleteContribution(id: $id)
}
`

View File

@ -185,12 +185,33 @@ export const listContributions = gql`
order: $order
filterConfirmed: $filterConfirmed
) {
id
amount
memo
contributionCount
contributionList {
id
amount
memo
}
}
}
`
export const listAllContributions = `
query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC) {
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
contributionCount
contributionList {
id
firstName
lastName
amount
memo
createdAt
confirmedAt
confirmedBy
}
}
}
`
// from admin interface
export const listUnconfirmedContributions = gql`
@ -259,3 +280,15 @@ export const listContributionLinks = gql`
}
}
`
export const searchAdminUsers = gql`
query {
searchAdminUsers {
userCount
userList {
firstName
lastName
}
}
}
`

View File

@ -12,7 +12,8 @@ log4js.configure(options)
const apolloLogger = log4js.getLogger('apollo')
const backendLogger = log4js.getLogger('backend')
const klickTippLogger = log4js.getLogger('klicktipp')
backendLogger.addContext('user', 'unknown')
export { apolloLogger, backendLogger }
export { apolloLogger, backendLogger, klickTippLogger }

View File

@ -31,20 +31,24 @@ const filterVariables = (variables: any) => {
const logPlugin = {
requestDidStart(requestContext: any) {
const { logger } = requestContext
const { query, mutation, variables } = requestContext.request
logger.info(`Request:
const { query, mutation, variables, operationName } = requestContext.request
if (operationName !== 'IntrospectionQuery') {
logger.info(`Request:
${mutation || query}variables: ${JSON.stringify(filterVariables(variables), null, 2)}`)
}
return {
willSendResponse(requestContext: any) {
if (requestContext.context.user) logger.info(`User ID: ${requestContext.context.user.id}`)
if (requestContext.response.data) {
logger.info('Response Success!')
logger.trace(`Response-Data:
if (operationName !== 'IntrospectionQuery') {
if (requestContext.context.user) logger.info(`User ID: ${requestContext.context.user.id}`)
if (requestContext.response.data) {
logger.info('Response Success!')
logger.trace(`Response-Data:
${JSON.stringify(requestContext.response.data, null, 2)}`)
}
if (requestContext.response.errors)
logger.error(`Response-Errors:
}
if (requestContext.response.errors)
logger.error(`Response-Errors:
${JSON.stringify(requestContext.response.errors, null, 2)}`)
}
return requestContext
},
}

View File

@ -6,6 +6,8 @@ import { User } from '@model/User'
const communityDbUser: dbUser = {
id: -1,
gradidoID: '11111111-2222-4333-4444-55555555',
alias: '',
email: 'support@gradido.net',
firstName: 'Gradido',
lastName: 'Akademie',

View File

@ -1000,6 +1000,11 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/uuid@^8.3.4":
version "8.3.4"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
"@types/validator@^13.1.3":
version "13.6.3"
resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.6.3.tgz#31ca2e997bf13a0fffca30a25747d5b9f7dbb7de"
@ -5437,7 +5442,7 @@ uuid@^3.1.0:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^8.0.0:
uuid@^8.0.0, uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==

View File

@ -1,6 +1,15 @@
import Decimal from 'decimal.js-light'
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn, DeleteDateColumn } from 'typeorm'
import {
BaseEntity,
Column,
Entity,
PrimaryGeneratedColumn,
DeleteDateColumn,
JoinColumn,
ManyToOne,
} from 'typeorm'
import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer'
import { User } from '../User'
@Entity('contributions')
export class Contribution extends BaseEntity {
@ -10,6 +19,10 @@ export class Contribution extends BaseEntity {
@Column({ unsigned: true, nullable: false, name: 'user_id' })
userId: number
@ManyToOne(() => User, (user) => user.contributions)
@JoinColumn({ name: 'user_id' })
user: User
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date

View File

@ -0,0 +1,83 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true })
pubKey: Buffer
@Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true })
privKey: Buffer
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
email: string
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@DeleteDateColumn()
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true })
emailHash: Buffer
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
emailChecked: boolean
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null })
isAdmin: Date | null
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@Column({
type: 'text',
name: 'passphrase',
collation: 'utf8mb4_unicode_ci',
nullable: true,
default: null,
})
passphrase: string
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
}

View File

@ -1,4 +1,13 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, DeleteDateColumn } from 'typeorm'
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@ -76,4 +85,8 @@ export class User extends BaseEntity {
default: null,
})
passphrase: string
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
}

View File

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

View File

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

View File

@ -0,0 +1,111 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class User extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'gradido_id',
length: 36,
nullable: false,
unique: true,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'alias',
length: 20,
nullable: true,
unique: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
alias: string
@Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true })
pubKey: Buffer
@Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true })
privKey: Buffer
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
email: string
@Column({
name: 'first_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
firstName: string
@Column({
name: 'last_name',
length: 255,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
lastName: string
@DeleteDateColumn()
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true })
emailHash: Buffer
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
emailChecked: boolean
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
language: string
@Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null })
isAdmin: Date | null
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
referrerId?: number | null
@Column({
name: 'contribution_link_id',
type: 'int',
unsigned: true,
nullable: true,
default: null,
})
contributionLinkId?: number | null
@Column({ name: 'publisher_id', default: 0 })
publisherId: number
@Column({
type: 'text',
name: 'passphrase',
collation: 'utf8mb4_unicode_ci',
nullable: true,
default: null,
})
passphrase: string
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
}

View File

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

View File

@ -0,0 +1,46 @@
import {
BaseEntity,
Column,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm'
import { Contribution } from '../Contribution'
@Entity('contribution_messages', {
engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci',
})
export class ContributionMessage extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ name: 'contribution_id', unsigned: true, nullable: false })
contributionId: number
@ManyToOne(() => Contribution, (contribution) => contribution.messages)
@JoinColumn({ name: 'contribution_id' })
contribution: Contribution
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@Column({ length: 2000, nullable: false, collation: 'utf8mb4_unicode_ci' })
message: string
@Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt: Date
@Column({ type: 'datetime', default: null, nullable: true, name: 'updated_at' })
updatedAt: Date
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt: Date | null
@Column({ name: 'deleted_by', default: null, unsigned: true, nullable: true })
deletedBy: number
@Column({ length: 12, nullable: false, collation: 'utf8mb4_unicode_ci' })
type: string
}

View File

@ -1 +1 @@
export { Contribution } from './0039-contributions_table/Contribution'
export { Contribution } from './0047-messages_tables/Contribution'

View File

@ -0,0 +1 @@
export { ContributionMessage } from './0047-messages_tables/ContributionMessage'

View File

@ -0,0 +1 @@
export { EventProtocol } from './0043-add_event_protocol_table/EventProtocol'

View File

@ -1 +1 @@
export { User } from './0040-add_contribution_link_id_to_user/User'
export { User } from './0046-adapt_users_table_for_gradidoid/User'

View File

@ -6,6 +6,8 @@ import { Transaction } from './Transaction'
import { TransactionLink } from './TransactionLink'
import { User } from './User'
import { Contribution } from './Contribution'
import { EventProtocol } from './EventProtocol'
import { ContributionMessage } from './ContributionMessage'
export const entities = [
Contribution,
@ -16,4 +18,6 @@ export const entities = [
Transaction,
TransactionLink,
User,
EventProtocol,
ContributionMessage,
]

View File

@ -0,0 +1,28 @@
/* MIGRATION TO ADD EVENT_PROTOCOL
*
* This migration adds the table `event_protocol` in order to store all sorts of business event data
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
CREATE TABLE IF NOT EXISTS \`event_protocol\` (
\`id\` int(10) unsigned NOT NULL AUTO_INCREMENT,
\`type\` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL,
\`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
\`user_id\` int(10) unsigned NOT NULL,
\`x_user_id\` int(10) unsigned NULL DEFAULT NULL,
\`x_community_id\` int(10) unsigned NULL DEFAULT NULL,
\`transaction_id\` int(10) unsigned NULL DEFAULT NULL,
\`contribution_id\` int(10) unsigned NULL DEFAULT NULL,
\`amount\` bigint(20) NULL DEFAULT NULL,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
// write downgrade logic as parameter of queryFn
await queryFn(`DROP TABLE IF EXISTS \`event_protocol\`;`)
}

View File

@ -0,0 +1,34 @@
/* MIGRATION TO INSERT contributions for all transactions with type creation that do not have a contribution yet */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
`INSERT INTO gradido_community.contributions
(user_id, created_at, contribution_date, memo, amount, moderator_id, confirmed_by, confirmed_at, transaction_id)
SELECT
user_id,
balance_date,
creation_date AS contribution_date,
memo,
amount,
20 AS moderator_id,
502 AS confirmed_by,
balance_date AS confirmed_at,
id
FROM
gradido_community.transactions
WHERE
type_id = 1
AND NOT EXISTS(
SELECT * FROM gradido_community.contributions
WHERE gradido_community.contributions.transaction_id = gradido_community.transactions.id);`,
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
'DELETE FROM `contributions` WHERE `contributions`.`moderator_id` = 20 AND `contributions`.`confirmed_by` = 502 AND `contributions`.`created_at` = `contributions`.`confirmed_at`;',
)
}

View File

@ -0,0 +1,39 @@
/* MIGRATION TO ADD denied_by, denied_at, contribution_type and contrinution_status
FIELDS TO contributions */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(
'ALTER TABLE `contributions` ADD COLUMN `denied_at` datetime DEFAULT NULL AFTER `confirmed_at`;',
)
await queryFn(
'ALTER TABLE `contributions` ADD COLUMN `denied_by` int(10) unsigned DEFAULT NULL AFTER `denied_at`;',
)
await queryFn(
'ALTER TABLE `contributions` ADD COLUMN `contribution_type` varchar(12) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "ADMIN" AFTER `denied_by`;',
)
await queryFn(
'ALTER TABLE `contributions` ADD COLUMN `contribution_status` varchar(12) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "PENDING" AFTER `contribution_type`;',
)
await queryFn(
'UPDATE `contributions` SET `contribution_type` = "LINK" WHERE `contribution_link_id` IS NOT NULL;',
)
await queryFn(
'UPDATE `contributions` SET `contribution_type` = "USER" WHERE `contribution_link_id` IS NULL AND `moderator_id` IS NULL;',
)
await queryFn(
'UPDATE `contributions` SET `contribution_status` = "CONFIRMED" WHERE `confirmed_at` IS NOT NULL;',
)
await queryFn(
'UPDATE `contributions` SET `contribution_status` = "DELETED" WHERE `deleted_at` IS NOT NULL;',
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `contributions` DROP COLUMN `contribution_status`;')
await queryFn('ALTER TABLE `contributions` DROP COLUMN `contribution_type`;')
await queryFn('ALTER TABLE `contributions` DROP COLUMN `denied_by`;')
await queryFn('ALTER TABLE `contributions` DROP COLUMN `denied_at`;')
}

View File

@ -0,0 +1,44 @@
/* MIGRATION TO ADD GRADIDO_ID
*
* This migration adds new columns to the table `users` and creates the
* new table `user_contacts`
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { v4 as uuidv4 } from 'uuid'
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
// First add gradido_id as nullable column without Default
await queryFn('ALTER TABLE `users` ADD COLUMN `gradido_id` CHAR(36) NULL AFTER `id`;')
// Second update gradido_id with ensured unique uuidv4
const usersToUpdate = await queryFn('SELECT `id`, `gradido_id` FROM `users`') // WHERE 'u.gradido_id' is null`,)
for (const id in usersToUpdate) {
const user = usersToUpdate[id]
let gradidoId = null
let countIds = null
do {
gradidoId = uuidv4()
countIds = await queryFn(
`SELECT COUNT(*) FROM \`users\` WHERE \`gradido_id\` = "${gradidoId}"`,
)
} while (countIds[0] > 0)
await queryFn(
`UPDATE \`users\` SET \`gradido_id\` = "${gradidoId}" WHERE \`id\` = "${user.id}"`,
)
}
// third modify gradido_id to not nullable and unique
await queryFn('ALTER TABLE `users` MODIFY COLUMN `gradido_id` CHAR(36) NOT NULL UNIQUE;')
await queryFn(
'ALTER TABLE `users` ADD COLUMN `alias` varchar(20) NULL UNIQUE AFTER `gradido_id`;',
)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE users DROP COLUMN gradido_id;')
await queryFn('ALTER TABLE users DROP COLUMN alias;')
}

View File

@ -0,0 +1,30 @@
/**
* MIGRATION TO CREATE THE MESSAGES TABLES
*
* This migration creates the `messages` tables in the `community_server` database (`gradido_community`).
* This is done to keep all data in the same place and is to be understood in conjunction with the next migration
* `0046-messages_tables` which will fill the tables with the existing data
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`
CREATE TABLE IF NOT EXISTS \`contribution_messages\` (
\`id\` int(10) unsigned NOT NULL AUTO_INCREMENT,
\`contribution_id\` int(10) unsigned NOT NULL,
\`user_id\` int(10) unsigned NOT NULL,
\`message\` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL,
\`created_at\` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
\`updated_at\` datetime DEFAULT NULL,
\`deleted_at\` datetime DEFAULT NULL,
\`deleted_by\` int(10) unsigned DEFAULT NULL,
\`type\` varchar(12) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT "DIALOG",
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn(`DROP TABLE IF EXISTS \`contribution_messages\`;`)
}

View File

@ -1,6 +1,6 @@
{
"name": "gradido-database",
"version": "1.10.1",
"version": "1.11.0",
"description": "Gradido Database Tool to execute database migrations",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/database",
@ -37,6 +37,7 @@
"typescript": "^4.3.5"
},
"dependencies": {
"@types/uuid": "^8.3.4",
"cross-env": "^7.0.3",
"crypto": "^1.0.1",
"decimal.js-light": "^2.5.1",
@ -44,6 +45,7 @@
"mysql2": "^2.3.0",
"reflect-metadata": "^0.1.13",
"ts-mysql-migrate": "^1.0.2",
"typeorm": "^0.2.38"
"typeorm": "^0.2.38",
"uuid": "^8.3.2"
}
}

View File

@ -137,6 +137,11 @@
resolved "https://registry.yarnpkg.com/@types/node/-/node-16.10.3.tgz#7a8f2838603ea314d1d22bb3171d899e15c57bd5"
integrity sha512-ho3Ruq+fFnBrZhUYI46n/bV2GjwzSkwuT4dTf0GkuNFmnb8nq4ny2z9JEVemFi6bdEJanHLlYfy9c6FN9B9McQ==
"@types/uuid@^8.3.4":
version "8.3.4"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
"@types/zen-observable@0.8.3":
version "0.8.3"
resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.3.tgz#781d360c282436494b32fe7d9f7f8e64b3118aa3"
@ -2088,6 +2093,11 @@ util-deprecate@~1.0.1:
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
uuid@^8.3.2:
version "8.3.2"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
v8-compile-cache@^2.0.3:
version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"

View File

@ -26,7 +26,7 @@ COMMUNITY_REDEEM_CONTRIBUTION_URL=https://stage1.gradido.net/redeem/CL-{code}
COMMUNITY_DESCRIPTION="Gradido Development Stage1 Test Community"
# backend
BACKEND_CONFIG_VERSION=v8.2022-06-20
BACKEND_CONFIG_VERSION=v9.2022-07-07
JWT_EXPIRES_IN=10m
GDT_API_URL=https://gdt.gradido.net
@ -53,6 +53,10 @@ EMAIL_CODE_REQUEST_TIME=10
WEBHOOK_ELOPAGE_SECRET=secret
# EventProtocol
EVENT_PROTOCOL_DISABLED=false
# database
DATABASE_CONFIG_VERSION=v1.2022-03-18

View File

@ -0,0 +1,43 @@
# This file defines the Apple M1 chip settings. It overrides docker-compose.override.yml,
# which defines the development settings.
# To use it it is required to explicitly define if you want to build with it:
# > docker-compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up
version: "3.4"
services:
########################################################
# FRONTEND #############################################
########################################################
frontend:
platform: linux/amd64
########################################################
# ADMIN INTERFACE ######################################
########################################################
admin:
platform: linux/amd64
#########################################################
## MARIADB ##############################################
#########################################################
mariadb:
platform: linux/amd64
########################################################
# BACKEND ##############################################
########################################################
backend:
platform: linux/amd64
########################################################
# DATABASE #############################################
########################################################
database:
platform: linux/amd64
#########################################################
## NGINX ################################################
#########################################################
nginx:
platform: linux/amd64

View File

@ -1,11 +1,13 @@
version: "3.4"
services:
########################################################
# FRONTEND #############################################
########################################################
frontend:
image: gradido/frontend:development
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/frontend:local-development
build:
target: development
environment:
@ -22,7 +24,8 @@ services:
# ADMIN INTERFACE ######################################
########################################################
admin:
image: gradido/admin:development
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/admin:local-development
build:
target: development
environment:
@ -39,7 +42,8 @@ services:
# BACKEND ##############################################
########################################################
backend:
image: gradido/backend:development
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/backend:local-development
build:
target: development
networks:
@ -62,10 +66,11 @@ services:
########################################################
database:
# we always run on production here since else the service lingers
# feel free to change this behaviour if it seems useful
# Due to problems with the volume caching the built files
# we changed this to test build. This keeps the service running.
image: gradido/database:test_up
# feel free to change this behaviour if it seems useful
# Due to problems with the volume caching the built files
# we changed this to test build. This keeps the service running.
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/database:local-test_up
build:
target: test_up
environment:
@ -89,9 +94,7 @@ services:
#########################################################
## NGINX ################################################
#########################################################
nginx:
volumes:
- ./logs/nginx:/var/log/nginx
# nginx:
#########################################################
## PHPMYADMIN ###########################################

View File

@ -6,6 +6,7 @@ services:
# BACKEND ##############################################
########################################################
backend:
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/backend:test
build:
target: test

View File

@ -6,11 +6,13 @@
version: "3.4"
services:
########################################################
# FRONTEND #############################################
########################################################
frontend:
image: gradido/frontend:latest
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/frontend:local-production
build:
context: ./frontend
target: production
@ -35,7 +37,8 @@ services:
# ADMIN INTERFACE ######################################
########################################################
admin:
image: gradido/admin:latest
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/admin:local-production
build:
context: ./admin
target: production
@ -77,7 +80,8 @@ services:
# BACKEND ##############################################
########################################################
backend:
image: gradido/backend:latest
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/backend:local-production
build:
# since we have to include the entities from ./database we cannot define the context as ./backend
# this might blow build image size to the moon ?!
@ -103,12 +107,16 @@ services:
# Application only envs
#env_file:
# - ./frontend/.env
volumes:
# <host_machine_directy>:<container_directory> mirror bidirectional path in local context with path in Docker container
- ./logs/backend:/logs/backend
########################################################
# DATABASE #############################################
########################################################
database:
#image: gradido/database:production_up
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/database:local-production_up
build:
context: ./database
target: production_up
@ -144,6 +152,8 @@ services:
- admin
ports:
- 80:80
volumes:
- ./logs/nginx:/var/log/nginx
networks:
external-net:

View File

@ -0,0 +1,219 @@
# Contribution Messaging
Die Idee des *Contribution Messagings* besteht darin, dass ein User an eine existierende Contribution eine von ihm erfasste Nachricht anhängen kann. Als Ursprungsmotivation dieses *Contribution Messagings* ist eigentlich die Kommunikation zwischen dem Support-Mitarbeiter und dem Ersteller der Contribution gedacht. Doch es sind auch Nachrichten anderer User in dieser Kommunikationkette denkbar, um beispielsweise Bemerkungen, Kritik und Anregungen zu erstellten Contributions zu hinterlassen. Dadurch soll das Miteinander innerhalb einer Community für die geleisteten Gemeinwohl-Aktivitäten in den Vordergrund gerückt werden.
## Hinweis
In diesem Dokument werden alle zu dem Thema besprochenen und angedachten Anforderungen, unabhängig in welcher Ausbaustufe diese umgesetzt werden, beschrieben. Für die weitere Entwicklung und zur Erstellung der Technischen Tickets werden die einzelnen Anforderungen mit einem Hinweis - Beispiel [AS-1] für Ausbaustufe 1- zur geplanten Ausbaustufe direkt angeben. Die Markierung [AS-x] deutet auf eine Anforderung in einer nächsten Stufe hin. Wobei die Priorisierung und damit noch nicht klar ist, in welcher Ausbaustufe diese Anforderung letztendlich wirklich umgesetzt wird.
## Allgemeine Anforderungen
### Contribution
* **[AS-1]** Eine Contribution bekommt zu den aktuell implementierten drei Status-Werten (eingereicht, bestätigt und abgelehnt) noch einen vierten Status (in Arbeit) hinzu
* eingereicht (pending): Die Contribution wurde vom User bzw. vom Moderator neu erfasst und wartet auf Bearbeitung der Moderatoren
* in Arbeit (inprogress): Die Contribution wurde von einem Moderator in Bearbeitung genommen, in dem er eine Rückfrage als Nachricht an den User erfasst hat und wartet auf Beantwortung vom User, dem die Contribution zugeordnet ist
* bestätigt (confirmed): Die Contribution wurde von einem Moderator genehmigt und der Betrag ist dem User der Contribution schon gutgeschrieben. Dies ist eine Ende-Zustand auf den keine weitere Bearbeitung mehr folgt. **[AS-x]** Es kann selbst nach einer Bestätigung der Contribution noch eine neue Nachricht dazu erfasst werden.
* abgelehnt (denied): die Contribution wurde vom Moderator abgelehnt und es hat keine Gutschrift des Betrages auf dem Konto des Users stattgefunden. Dies ist ein Ende-Zustand auf den keine weitere Bearbeitung mehr folgt. **[AS-x]** Es kann selbst nach einer Ablehnung der Contribution noch eine neue Nachricht dazu erfasst werden.
* gelöscht (deleted): die Contribution wurde von einem Moderator oder dem User gelöscht. Dies kann zu jeder Zeit aus dem Status pending oder inprogress nicht aber aus dem Status confirmed oder denied heraus initiiert werden und ist unabhängig, ob an der Contribution schon Messages anhängig sind. Das Löschen wird als Soft-Delete implementiert in dem die Attribute *deletedAt* und *deletedBy* gesetzt werden. Im Status deleted kommt die Contribution nicht mehr zur Anzeige, bleibt aber in der Datanbank erhalten.
* **[AS-1]** Sobald ein Moderator eine Contribution zur Bearbeitung anzeigt, wird sofort die ModeratorId und/oder der neue Status in die Contribution gespeichert, um diese für andere Moderatoren zu sperren. Dies ist notwendig, um fälschlicherweise ein paralleles Bearbeiten einer Contribution durch mehrere Moderatoren zu verhindern.
* **[AS-1]** Bei der Bestätigung einer Contribution wird neben der Status-Änderung auf *confirmed* eine neue Nachricht erzeugt. Diese Nachricht enthält einen Bestätigungstext, der im Frontend als Standard-Bestätigungstext vorbelegt ist und vom Moderator für eine individuelle Bestätigung überschrieben werden kann. Das Speichern der Contribution-Bestätigung erfolgt nach dem die zugehörige Schöpfung als Transaktion erfolgreich gespeichert ist. Falls es beim Speichern der Gutschrift und/oder der Contribution inkl. Message zu einem Fehler kommt, darf weder die Contribution noch die Transaktion gespeichert werden, sondern es muss eine Fehlermeldung ohne Änderungen der Datanbankdaten erfolgen.
* **[AS-1]** Bei der Ablehnung einer Contribution wird neben der Status-Änderung auf *denied* eine neue Nachricht erzeugt. Diese Nachricht enthält einen Ablehnungtext, der im Frontend als Standard-Ablehnungstext vorbelegt ist und vom Moderator für eine individuelle Begründung überschrieben werden kann.
* **[AS-1]** Zu einer Contribution können nur der besitzende User und die Moderatoren eine Nachricht erfassen. **[AS-x]** Für evtl. Bemerkungen, Kritik und Anregungen kann ein beliebiger User über die *Gemeinschaft*-Anzeige auch Nachrichten zu Contributions anderer User erfassen.
* **[AS-1]** Eine Contribution kann vom User der Contribution oder einem Moderator gelöscht werden, egal ob an der Contribution schon Nachrichten zugeordnet sind oder nicht. Beim Löschen muss der User bzw. Moderator eine Begründung formulieren, die beim Löschen dann als Nachricht an die Contribution noch angehängt wird. Dies dient der besseren Nachvollziehbarkeit im Falle von nachträglichen Rückfragen bzw. Analysen.
* **[AS-1]** Eine Contribution zeigt in der Normal-Ansicht per farblichem Icon bzw. textuell, ob
* noch keine Nachrichten existieren (graue Sprechblase)
* mindestens eine Nachricht existiert (blaue Sprechblase)
* **[AS-x]** Gesamtsanzahl der existierenden Nachrichten (z.B. Zahl im bzw. neben Sprechbalsen-Icon)
* **[AS-x]** Anzahl der ungelesenen Nachrichten (z.B. Zahl/Zahl im bzw. neben Sprechblasen-Icon)
* **[AS-1]** Das Bearbeiten einer Contribution ist im Status *eingereicht* möglich. Solange noch keine Nachrichten anhängig sind, wird nach dem Bearbeiten keine weitere Aktion notwendig, es erfolgt noch keine Historisierung. Sobald aber schon mindestens eine Nachricht anhängig ist, muss eine Historisierung der Contribution-Bearbeitung erfolgen. Das Ziel der Historisierung ist die jeweilige Version einer Contribution vor der Bearbeitung als Message in die Nachrichtenliste einzutragen und dabei diese HISTORY-Message so zeitlich einzusortieren, dass diese mit den DIALOG-Messages inhaltlich korrespondiert. Das bedeutet, dass beim Starten der Bearbeitung der original Contributiontext und -Betrag als Nachricht mit besonderer Kennzeichnung (*type* = HISTORY) und mit Zeitstempel für Contribution-Version 1: *Message.createdAt* = Contribution.createdAt bzw. für Contribution-Version >1: *Message.createdAt* = Contribution.updatedAt erzeugt und angehängt wird. Der geänderte bzw. neue Inhalt der Contribution wird in der Contribution selbst gespeichert und das *updatedAt* und *updatedBy* der Contribution wird aktualisiert.
* **[AS-1]** Folgende Status-Übergänge sind für eine Contribution möglich:
![img](./image/ContributionStates.png)
* **[AS-x]** Für das Bearbeiten der Contributions im AdminInterface bedarf es einer Möglichkeit der Synchronisierung zwischen den Moderatoren. Dazu kann in die Contribution eine *workerId* eingetragen werden, die jedem Moderator zeigt, wer aktuell diese Contribution in Bearbeitung hat. Diese Information ist lediglich informativ, denn jeder Moderator kann sich selbst als workerId eintragen, um beispielsweise die Bearbeitung, bei längerer Abwesenheit des aktuell bearbeitenden Moderators, selbst zu übernehmen. Das AdminInterface bietet dazu für jede Contribution neben der Anzeige des aktuell bearbeitenden Moderators (Name aus User-Daten über *workerId* ermittelt) eine Möglichkeit die eigene ModeratorId als *workerId* in die Contribution einzutragen. Ein Wechsel der *workerId* hat keine Auswirkungen auf den aktuellen Status der Contribution.
### Nachrichten
* **[AS-1]** Die Nachrichten zu einer Contribution werden als Detail-Ansicht der Contribution zeitlich absteigend - per `createdAt` - sortiert als einfache Liste angezeigt, das heißt die neueste Nachricht steht oben.
* **[AS-1]** Nur der User, dem die Contribution zugeordnet ist und alle Moderatoren können Nachrichten zu einer Contribution verfassen und bearbeiten. Alle anderen User haben nur Leserechte auf die Contributions und Nachrichten anderer User.
* **[AS-x]** Jeder beliebige User kann zu einer Contribution, egal in welchem Status diese sich befindet, eine Nachricht erstellen.
* **[AS-1]** In den Anzeigen der Contribution-Listen "*Meine Beiträge*" und "*Gemeinschaft*" kann jede enthaltene Contribution analog der Detail-Ansicht einer Transaktion aufgeklappt werden, um die schon angehängten Nachrichten anzuzeigen.
* **[AS-1]** Mit der Detail-Ansicht einer Contribution wird auch ein Button eingeblendet, über den die Erfassung einer neuen Nachricht gestartet wird.
* **[AS-1]** Mit der Anzeige einer Nachricht wird auch der Ersteller und der Erstellungszeitpunkt dieser Nachricht angezeigt.
* **[AS-1]** Analog zur Detailansicht einer Contribution in der Wallet wird auch eine Detailansicht der Contributions im Admin-Bereich eingebaut.
* **[AS-1]** Die Länge einer Nachricht wird auf maximal 2000 Zeichen begrenzt
* **[AS-1]** Die Nachrichten werden als einfach verkettete Liste an die Contribution angehängt. Es wird keine Untertützung von Nachrichten an Nachrichten geben
* **[AS-1]** Eine Nachricht unterscheidet sich im Typ, ob es eine vom User/Moderator erstellte DIALOG-Message oder ob es eine vom System automatisierte HISTORY-Message ist. Nachrichten, die während einer Bestätigung bzw. Ablehnung einer Contribution mit vordefinierten Texten vom System erstellt werden sind dennoch vom Typ DIALOG, denn der vordefinierte Text kann vom Moderator auch manuell überschrieben bzw. angepasst werden.
* **[AS-1]** Ist die letzte, sprich jüngste Nachricht eine manuell erstellte Nachricht, kann diese vom Ersteller der Nachricht - `Messages.userId` - nachträglich bearbeitet werden. Neben dem geänderten Nachrichtentext wird der Zeitpunkt der Änderung im Feld `updatedAt `erfasst. Die Einsortierung in der Nachrichtenliste bleibt auch bei einer nachträglichen Änderung auf dem Feld `createdAt`.
* **[AS-x]** Eine existente Nachricht kann nur von einem Moderator gelöscht werden - Unterbindung von unliebsamen Troll-Inhalten. Dabei wird ein SoftDelete ausgeführt und das Attribut *deleted_at* und *deleted_by* mit dem Zeitpunkt des Löschens und mit der UserId des Moderators gesetzt.
* **[AS-x]** Ist die jüngste Nachricht der Liste eine DIALOG-Nachricht, dann kann diese vom User, der sie erstellt hat, nachträglich bearbeiten werden. In der Nachrichtenanzeige wird dazu ein Stift-Icon sichtbar, das die Anzeige der Nachricht in einen Bearbeitungsmodus versetzt, der den vorhandenen Text plus einen Abbruch- und einen Speichern-Button anzeigt. Mit Beenden des Bearbeitungsmodus per Speichern-Button wird der geänderte Text und der aktuelle Zeitpunkt in das *updated_at* gespeichert.
* **[AS-x]** Die Verwaltung ob eine Nachricht neu ist und ob diese schon vom User/Moderator angezeigt sprich gelesen wurde, muss für eine spätere Ausbaustufe noch im Detail spezifiziert werden. Die einfache Variante mit einem Attribut *presentedAt* würde nur für den User funktionieren, nicht aber für zusätzlich mehrere Moderatoren und ggf. später für andere User.
## Contribution Ansichten
### Contribution-Liste "MeineBeiträge" Ansicht
Die Contributions werden in den User-Ansichten "Meine Beiträge" und "Gemeinschaft", sowie im Admin-Interface als Listen angezeigt. Das nachfolgende Bild zeigt beispielhaft eine Contribution-List in der "Meine Beiträge"-Ansicht.
![img](./image/ContributionMyList.png)
Die Liste der Contributions enthält vier Contributions, je eine in den vier verschiedenen Darstellungsarten "eingereicht", "in Bearbeitung", "bestätigt" und "abgelehnt". Die ersten beiden Contributions im Status "eingereicht" und "in Bearbeitung" können nachträglich noch bearbeitet oder gar gelöscht werden - zu erkennen an den Icons "Stift" und "Mülleimer". Diese Möglichkeit besteht bei den beiden anderen Contributions nicht mehr, da diese schon vom Support entsprechend bestätigt oder gar abgelehnt wurden.
Das Icon Sprechblase in der Farbe grau deutet darauf hin, dass es zu dieser Contribution noch keine gespeicherten Nachrichten gibt. Ist das Sprechblasen-Icon blau, dann existieren zu der Contribution schon gespeicherte Nachrichten. Ein Klick auf das Sprechblasen-Icon öffnet die Nachrichten-Ansicht der entsprechenden Contribution
**[AS-x]** Ist zu der Farbe blau auch noch eine Zahl, wie bei der zweiten Contribution sichtbar, dann ist in der Nachrichtenliste dieser Contribution eine neue Nachricht enthalten, die noch nicht vom User zur Anzeige gebracht wurde. Ein Klick auf das Sprechblasen-Icon öffnet die Nachrichten-Ansicht der entsprechenden Contribution, wodurch ein Update aller neuen noch ungelesenen Nachrichten erfolgt, in dem der Zeitpunkt der Anzeige in das Attribut *presented_at* eingetragen wird. Gleichzeit wird auch dadurch die Zahl unterhalb des blauen Sprechblasen-Icons gelöscht analog der dritten und vierten Contribution.
## Contribution-Liste "Gemeinschaft" Ansicht
Im Unterschied zur Contributions-Listen Ansicht "Meine Beiträge" wird in der "Gemeinschaft"-Ansicht jeder Contribution zusätzlich der User, dem die Contribution zugeordnet ist, angezeigt.
![img](./image/ContributionCommunityList.png)
Als User-Information kann der Vorname und Nachname oder sofern vorhanden auch der Alias angezeigt werden.
### Contribution-LifeCycle
#### Erfassung
Sobald auf der Seite "Schreiben" eine neue Contribution erfasst wurde, wird diese auf der Seite "Meine Beiträge" im Status "eingereicht" angzeigt, wie im nachfolgenden Bild mit den ersten beiden Contributions dargestellt. Bei der Erfassung der Contribution wird neben den fachlichen Attributen wie Memo und Betrag gleichzeitig das Feld `created_at` mit dem aktuellen Zeitpunkt und das Feld `user_id` bzw `moderator_id` mit der UserId des aktuell angemeldeten Users bzw des Support-Moderators eingetragen.
![img](./image/ContributionMyListLifeCycle1.png)
#### In Bearbeitung
Sobald ein Moderator im Admin-Interface eine Contribution im Status "*eingereicht*" zur Anzeige bringt, wird das Feld *inprogress_at* mit dem aktuellen Zeitpunkt und das Feld *inprogress_by* mit der UserId des Moderators gespeichert. Dadurch wird verhindert, dass ein paralleles Bearbeiten einer pending Contribution durch den Support stattfindet.
Falls der Moderator eine Rückfrage an den User richten möchte, zum Beispiel weil die Beschreibung der Aktivität nicht ganz klar ist, dann erfasst der Moderator einen Rückfragetext, der als Nachricht an die Contribution in die Tabelle `contribution_messages` geschrieben wird. Die Nachricht enthält neben dem Text im Feld `message `und dem Zeitpunkt im Feld `created_at`, die ModeratorID im Feld `user_id `und den Bezug zur Contribution im Feld `contribution_id`. Im Feld `type `wird das Enum 'DIALOG' eingetragen, um für die Anzeige in der Nachrichten-Liste das gewünschte Anzeigelayout gegenüber Nachrichten vom Typ 'HISTORY' zu verwenden.
Eine so erfasste Rückfrage vom Support kann der User durch das Icon "?" erkennen, auch steht unterhalb des nun blauen Sprechblasen-Icons die Anzahl der neuen ungelesenen Nachrichten. Mit Klick auf das Sprechblasen-Icon wird die Detailansicht der Contribution geöffnet und die angehängte(n) Nachricht(en) mit der Rückfrage sichtbar. Durch das Öffnen der Detailansicht und der Anzeige der Nachrichten werden in alle noch ungelesenen Nachrichten der aktuelle Zeitpunkt in das Attribut *presented_at* geschrieben und die Zahl unterhalb des Sprechblasen-Icons gelöscht.
![img](./image/ContributionMyListLifeCycle2.png)
Der User kann über das Stift-Icon die Contribution bearbeiten oder über das Mülleimer-Icon die Contribution löschen. Auch das Erfassen einer Nachricht an den Support wäre über das Sprechblasen-Icon mit den 3 Punkten denkbar. Das Besondere beim Speichern der bearbeiteten Contribution bzw. einer erfassten Nachrichten ist die implizite Umsetzung des Contribution-Status wieder zurück auf den Wert "*eingereicht*", damit der Support die Bearbeitung der Contribution wieder erkennen kann und übernimmt.
Nach der Bearbeitung der Contribution durch den User wird beim Speichern der ursprüngliche Inhalt der Contribution als Nachricht vom Type 'HISTORY' angehängt, der dann optisch sich auch von den anderen Dialog-Nachrichten unterscheidet - hier mit anderem Hintergrund und Schriftfarbe als eine 'DIALOG'-Nachricht. Egal von welchem Typ eine Nachricht ist - HISTORY oder DIALOG -, sie wird immer über das Attribut *created_at* einsortiert.
![img](./image/ContributionMyListLifeCycle3.png)
#### Bestätigen / Ablehnen
Sobald der Support wieder die Contribution im Status "*eingereicht*" zur weiteren Bearbeitung hat, kann der Moderator diese entweder "*bestätigen*" oder "*ablehnen*". Im Admin-Interface hat der Moderator dazu jeweils einen Button, über den er die weitere Verarbeitungslogik startet. Das Frontend öffnet dazu ein Dialog, in dem ein Standardtext für die Bestätigung bzw. für die Ablehnung vorbelegt ist. Dieser kann durch direktes Betätigen des "*Bestätigen*"-Button bzw. des "*Ablehnen*"-Buttons übernommen oder vor dem Betätigen der Buttons den vorgeschlagene Text durch eine individuelle Begründung überschrieben werden. Der Begründungstext wird in beiden Fällen als 'DIALOG'-Nachricht an die Contribution angehängt, wie im nächsten Bild sichtbar.
![img](./image/ContributionMyListLifeCycle4.png)
### Contribution-MessageList Ansicht und Bearbeitung
Mit Klicken auf das Sprechblasen-Icon kann die Nachrichten-Ansicht der Contribution geöffnet, wie im nachfolgenden Bild dargestellt und auch wieder geschlossen werden.
![img](./image/ContributionMyListDeniedMessages.png)
Bei geöffneter Nachrichten-Ansicht wird unterhalb der Contribution eine Kopfzeile "Nachrichten" und darunter die Liste der Nachrichten chronologisch absteigend nach ihrem `createdAt`-Datum sortiert angezeigt. Pro Nachricht ist der Absender, der Zeitstempel der Nachrichtenerstellung und der Nachrichtentext zu sehen.
Eine einmal erstellte Nachricht kann vom User selbst zwar nicht mehr gelöscht, aber sie kann als jüngste Nachricht in der Nachrichtenliste vom Ersteller noch einmal bearbeitet werden, wie an dem sichtbaren Stift-Icon in der Nachricht zu erkennen ist. Mit Klick auf das Stift-Icon wechselt die Anzeige der Nachricht in den Bearbeitungsmodus (analog dem Erfassen einer neuen Nachricht wie im nächsten Kapitel), in dem der Text nun verändert werden kann und zwei angezeigte Buttons zum Speichern bzw zum Verwerfen der Änderungen. Sobald eine Nachricht nachträglich bearbeitet wurde, wird der Zeitpunkt der Bearbeitung zusätzlich mit dem Label "bearbeitet am: < Zeitpunkt >" hinter dem Creation-Zeitpunkt angezeigt.
Um der Gefahr von unliebsamen Inhalten z.B. durch Trolle zu begegenen, kann ein Moderator eine Nachricht löschen. Es wird dabei ein SoftDelete ausgeführt, wodurch in die Nachricht das Attribut *deleted_at* und *deleted_by* mit dem Zeitpunkt des Löschens und mit der UserId des Moderators gesetzt wird.
### Contribution-CreateMessage Ansicht
Man kann aber mit Klicken auf den Button rechts - Sprechblasen-Icon mit den Punkten - in der Nachrichten-Kopfzeile eine neue Nachricht erstellen, siehe dazu nächstes Bild.
![img](./image/ContributionMyListMessagesNeu.png)
Es wird mit Klicken auf das Sprechblasen-Icon mit den drei Punkten ein neues Nachrichten-Fenster direkt unterhalb der Nachrichten-Kopfzeile eingeblendet. Das Textfeld ist leer und enthält lediglich den eingeblendeten Hinweis-Text, der mit Beginn der Texteingabe sofort verschwindet. Über die beiden Buttons rechts unten im Nachrichten-Eingabefenster kann die eingegebene Nachricht gespeichert oder verworfen werden. Sobald die neue Nachricht gespeichert wurde, erscheint diese analog den schon vorhandenen Nachrichten mit dem gleichen Erscheinungsbild und ohne Speicher- oder Verwerfen-Button. Der User kann beliebig viele Nachrichten eingeben, es gibt hierzu keine Begrenzung bzw. Validierung.
## Contribution-Message Services
### searchContributions of User
Mit diesem Service werden alle Contributions aber ohne Messages, die dem einen User zugeordnet sind gelesen und nach ihrem CreatedAt-Datum zeitlich absteigend sortiert. Ein mögliches Paged-Reading über eine größere Menge an Contributions muss dabei möglich sein. Es werden weitere evtl. transiente Informationen pro Contribution mit geliefert, um die entsprechenden Ausprägungen im Frontend ansteuern zu können:
* Status: eingereicht / in Bearbeitung / bestätigt / abgelehnt
* Messages vorhanden: ja / nein
* **[AS-x]** falls Messages vorhanden, wieviele davon sind ungelesen
### searchContributions for all
Mit diesem Service werden alle Contributions aber ohne Messages, aller User gelesen und nach ihrem CreatedAt-Datum zeitlich absteigend sortiert. Ein mögliches Paged-Reading über eine größere Menge an Contributions muss dabei möglich sein. Es werden weitere evtl. transiente Informationen pro Contribution mit geliefert, um die entsprechenden Ausprägungen im Frontend ansteuern zu können:
* Status: eingereicht / in Bearbeitung / bestätigt / abgelehnt
* Messages vorhanden: ja / nein
* **[AS-x]** falls Messages vorhanden, wieviele davon sind ungelesen
* User-Info: Vorname, Nachname oder sofern vorhanden dann der Alias
### updateContribution
Über diesen Service kann eine Contribution nur verändert werden, wenn sie im Status "*eingereicht*" oder "*in Bearbeitung*" ist. Dies kann nur der Ersteller der Contribution selbst ausführen. Implizit wird dabei zur Historisierung eine 'HISTORY'-Nachricht mit dem ürsprünglichen Inhalt der Contribution erstellt. In der bearbeiteten Contribution wird neben den geänderten Attributen `Memo `und `Amount `das `created_at`-Datum auf den Zeitpunkt des Updates gesetzt.
### deleteContribution
Mit diesem Service kann eine Contribution im Status "*eingereicht*" bzw. "*in Bearbeitung*" und nur vom Ersteller selbst oder von einem Moderator gelöscht werden. Dabei wird nur ein SoftDelete durchgeführt, indem das `deleted_at`-Datum auf den aktuellen Zeitpunkt und das `deleted_by`-Attribut mit der UserId des Users/Moderators gesetzt wird.
### confirmContribution
Über diesen Service kann der Moderator im AdminInterface eine "*eingereichte*" bzw. eine "*in Bearbeitung*" Contribution bestätigen. Dabei wird implizit das Attribut "`confirmed_by`" auf die UserId des Moderators gesetzt und das Attribut "`confirmed_at`" auf den aktuellen Zeitpunkt. Zusätzlich wird eine Nachricht mit dem übergebenen Begründungstext der Confirmation an die Contribution angehängt.
### denyContribution
Über diesen Service kann der Moderator im AdminInterface eine "*eingereichte*" bzw. ein "*in Bearbeitung*" Contribution ablehnen. Dabei wird implizit das Attribut "`denied_by`" auf die UserId des Moderators, das Attribut "`denied_at`" auf den aktuellen Zeitpunkt gesetzt. Zusätzlich wird eine Nachricht mit dem übergebenen Begründungstext der Ablehnung an die Contribution angehängt.
### searchContributionMessages
Dieser Service liefert zu einer bestimmten Contribution alle gespeicherten Nachrichten chronologisch nach dem `CreatedAt`-Datum absteigend sortiert. Neben dem Nachrichtentext und dem CreatedAt-Datum wird auch der User, der die Nachricht erstellt hat, geliefert. Als User-Daten wird entweder der Vorname und Nachname oder falls vorhanden der Alias geliefert. Ein mögliches Paged-Reading über eine größere Menge an Messages muss dabei möglich sein.
### createMessageForContribution
Über diesen Service kann zu einer bestimmten Contribution eine neue Nachricht gespeichert werden. Neben dem Nachrichten-Text wird die `contribution_id`, die `user_id `des Nachrichten Erstellers, der `type `der Nachricht und das `created_at`-Datum gespeichert.
### UpdatePresentedMessagesOfContribution
**[AS-x]** Die Verwaltung wer hat wann welche Nachricht angezeigt bzw. welche Nachricht ist für ein User bzw ein Moderator noch neu und ungelesen bedarf einer komplexeren Verwaltung, die auf eine zukünftige Ausbaustufe verschoben wird. Es wird dabei eine Many-To-Many Beziehung zwischen einer Message und einem User benötigt.
Ein einfacher Ansatz, der aber nur für den User funktionieren könnte, wäre mit dem Attribut *presented_at* in der Message. Mit diesem Service werden alle Nachrichten einer Contribution mit *presented_at* = null aktualisiert, in dem der aktuelle Zeitpunkt in das Attribut *presented_at* eingetragen wird.
## Datenbank Anpassungen
Das Class-Diagramm der beteiligten Tabellen gibt einen ersten Eindruck:
![img](https://file+.vscode-resource.vscode-cdn.net/d%3A/Dev/Gradido/docu/Concepts/BusinessRequirements/image/Contributions_DB_classdiagramm.png)
### Contributions Tabelle
Die Contribution-Tabelle benötigt für die Speicherung der verschiedenen Status-Constellationen folgende zusätzliche bzw. vorhandene Attribute:
inprogress_at: **[neues Attribut]** speichert den Zeitpunkt, wann die Contribution durch den Support-Mitarbeiter *in Arbeit genommen* wurde
inprogress_by: **[neues Attribut]** speichert die UserId des Moderators, der die Contribution *in Arbeit genommen* hat
confirmed_at: [vorhandenes Attribut] speichert den Zeitpunkt, wann die Contribution durch den Support-Mitarbeiter *bestätigt* wurde
confirmed_by: [vorhandenes Attribut] speichert die UserId des Moderators, der die Contribution *bestätigt* hat
denied_at: [vorhandenes Attribut] speichert den Zeitpunkt, wann die Contribution *abgelehnt* wurde
denied_by: [vorhandenes Attribut] speichert die UserId des Moderators, der die Contribution *abgelehnt* hat
deleted_at: **[neues Attribut]** speichert den Zeitpunkt, wann die Contribution *gelöscht* wurde
deleted_by: **[neues Attribut]** speichert die UserId des Moderators, der die Contribution *gelöscht* hat
### ContributionMessages Tabelle
Die ContributionMessages Tabelle ist gänzlich neu mit allen Attributen:
id: technical primary key
contribution_id: foreign key to contributions table the message belong to
user_id: foreign key to user table the message was created by
message: the message-text
created_at: the point of the time the message entry was created
type: Enum 'DIALOG' or 'HISTORY' to distingue between normal dialog message entries and messages for historisation of contribution
updated_at: the point of time the messages was updated
presented_at: the point of time the message was read for presenting in frontend

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@ -0,0 +1,57 @@
<mxfile host="65bd71144e">
<diagram id="3Q0Z80-KxArOo_UvcNgm" name="Seite-1">
<mxGraphModel dx="1176" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2336" pageHeight="1654" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="9" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;startArrow=none;startFill=0;endArrow=classic;endFill=1;" edge="1" parent="1" source="2" target="6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="10" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;startArrow=none;startFill=0;endArrow=classic;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="2" target="7">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="11" style="edgeStyle=none;html=1;entryX=0;entryY=0;entryDx=0;entryDy=0;fontSize=16;startArrow=none;startFill=0;endArrow=classic;endFill=1;exitX=0.967;exitY=0.725;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="2" target="8">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="2" value="&lt;font style=&quot;font-size: 16px&quot;&gt;pending&lt;/font&gt;" style="ellipse;whiteSpace=wrap;html=1;fillColor=#333333;fontColor=#FFFFFF;" vertex="1" parent="1">
<mxGeometry x="160" y="200" width="120" height="80" as="geometry"/>
</mxCell>
<mxCell id="4" style="edgeStyle=none;html=1;entryX=0;entryY=1;entryDx=0;entryDy=0;fontSize=16;exitX=0;exitY=0.5;exitDx=0;exitDy=0;startArrow=classic;startFill=1;endArrow=none;endFill=0;" edge="1" parent="1" source="3" target="2">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="240" y="360"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="5" style="edgeStyle=none;html=1;entryX=1;entryY=1;entryDx=0;entryDy=0;fontSize=16;exitX=0;exitY=0;exitDx=0;exitDy=0;" edge="1" parent="1" source="3" target="2">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="340" y="320"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="12" style="edgeStyle=none;html=1;entryX=0;entryY=1;entryDx=0;entryDy=0;fontSize=16;startArrow=none;startFill=0;endArrow=classic;endFill=1;exitX=0.942;exitY=0.288;exitDx=0;exitDy=0;exitPerimeter=0;" edge="1" parent="1" source="3" target="6">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="13" style="edgeStyle=none;html=1;entryX=0;entryY=1;entryDx=0;entryDy=0;fontSize=16;startArrow=none;startFill=0;endArrow=classic;endFill=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" edge="1" parent="1" source="3" target="7">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="14" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;startArrow=none;startFill=0;endArrow=classic;endFill=1;" edge="1" parent="1" source="3" target="8">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="3" value="&lt;font style=&quot;font-size: 16px&quot;&gt;in progress&lt;/font&gt;" style="ellipse;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="360" y="360" width="120" height="80" as="geometry"/>
</mxCell>
<mxCell id="6" value="&lt;font style=&quot;font-size: 16px&quot;&gt;confirmed&lt;/font&gt;" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="640" y="160" width="120" height="80" as="geometry"/>
</mxCell>
<mxCell id="7" value="&lt;font style=&quot;font-size: 16px&quot;&gt;denied&lt;/font&gt;" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="640" y="280" width="120" height="80" as="geometry"/>
</mxCell>
<mxCell id="8" value="&lt;font style=&quot;font-size: 16px&quot;&gt;deleted&lt;/font&gt;" style="ellipse;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;" vertex="1" parent="1">
<mxGeometry x="640" y="400" width="120" height="80" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

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