Merge branch 'master' into test-validation-of-optin-codes

This commit is contained in:
Moriz Wahl 2022-11-30 14:15:21 +01:00
commit 10781b2e6b
53 changed files with 1845 additions and 359 deletions

View File

@ -4,8 +4,21 @@ 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.15.0](https://github.com/gradido/gradido/compare/1.14.1...1.15.0)
- fix(database): wrong balance and decay values [`#2423`](https://github.com/gradido/gradido/pull/2423)
- fix(backend): wrong balance after transaction receive [`#2422`](https://github.com/gradido/gradido/pull/2422)
- feat(other): feature gradido roadmap [`#2301`](https://github.com/gradido/gradido/pull/2301)
- refactor(backend): new password encryption implementation [`#2353`](https://github.com/gradido/gradido/pull/2353)
- refactor(admin): statistics in a table and on separate page in admin area [`#2399`](https://github.com/gradido/gradido/pull/2399)
- feat(backend): 🍰 Email Templates [`#2163`](https://github.com/gradido/gradido/pull/2163)
- fix(backend): timezone problems [`#2393`](https://github.com/gradido/gradido/pull/2393)
#### [1.14.1](https://github.com/gradido/gradido/compare/1.14.0...1.14.1)
> 14 November 2022
- chore(release): version 1.14.1 - hotfix [`#2391`](https://github.com/gradido/gradido/pull/2391)
- fix(frontend): load contributionMessages is fixed [`#2390`](https://github.com/gradido/gradido/pull/2390)
#### [1.14.0](https://github.com/gradido/gradido/compare/1.13.3...1.14.0)

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "1.14.1",
"version": "1.15.0",
"license": "Apache-2.0",
"private": false,
"scripts": {

View File

@ -1,39 +0,0 @@
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

@ -1,59 +0,0 @@
<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

@ -38,10 +38,12 @@ export default {
form: {
text: '',
},
loading: false,
}
},
methods: {
onSubmit(event) {
this.loading = true
this.$apollo
.mutate({
mutation: adminCreateContributionMessage,
@ -55,9 +57,11 @@ export default {
this.$emit('update-state', this.contributionId)
this.form.text = ''
this.toastSuccess(this.$t('message.request'))
this.loading = false
})
.catch((error) => {
this.toastError(error.message)
this.loading = false
})
},
onReset(event) {
@ -66,10 +70,7 @@ export default {
},
computed: {
disabled() {
if (this.form.text !== '') {
return false
}
return true
return this.form.text === '' || this.loading
},
},
}

View File

@ -49,28 +49,36 @@ describe('NavBar', () => {
it('has a link to overview', () => {
expect(wrapper.findAll('.nav-item').at(0).find('a').attributes('href')).toBe('/')
})
it('has a link to /user', () => {
expect(wrapper.findAll('.nav-item').at(1).find('a').attributes('href')).toBe('/user')
})
it('has a link to /creation', () => {
expect(wrapper.findAll('.nav-item').at(2).find('a').attributes('href')).toBe('/creation')
})
it('has a link to /creation-confirm', () => {
expect(wrapper.findAll('.nav-item').at(3).find('a').attributes('href')).toBe(
'/creation-confirm',
)
})
it('has a link to /contribution-links', () => {
expect(wrapper.findAll('.nav-item').at(4).find('a').attributes('href')).toBe(
'/contribution-links',
)
})
it('has a link to /statistic', () => {
expect(wrapper.findAll('.nav-item').at(5).find('a').attributes('href')).toBe('/statistic')
})
})
describe('wallet', () => {
const assignLocationSpy = jest.fn()
beforeEach(async () => {
await wrapper.findAll('.nav-item').at(5).find('a').trigger('click')
await wrapper.findAll('.nav-item').at(6).find('a').trigger('click')
})
it.skip('changes window location to wallet', () => {
@ -89,7 +97,7 @@ describe('NavBar', () => {
window.location = {
assign: windowLocationMock,
}
await wrapper.findAll('.nav-item').at(6).find('a').trigger('click')
await wrapper.findAll('.nav-item').at(7).find('a').trigger('click')
})
it('redirects to /logout', () => {

View File

@ -22,6 +22,7 @@
<b-nav-item to="/contribution-links">
{{ $t('navbar.automaticContributions') }}
</b-nav-item>
<b-nav-item to="/statistic">{{ $t('navbar.statistic') }}</b-nav-item>
<b-nav-item @click="wallet">{{ $t('navbar.my-account') }}</b-nav-item>
<b-nav-item @click="logout">{{ $t('navbar.logout') }}</b-nav-item>
</b-navbar-nav>

View File

@ -0,0 +1,50 @@
import { mount } from '@vue/test-utils'
import StatisticTable from './StatisticTable.vue'
const localVue = global.localVue
const propsData = {
value: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
}
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
$d: jest.fn((d) => d),
}
describe('StatisticTable', () => {
let wrapper
const Wrapper = () => {
return mount(StatisticTable, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class .statistic-table', () => {
expect(wrapper.find('div.statistic-table').exists()).toBe(true)
})
describe('renders the table', () => {
it('with three colunms', () => {
expect(wrapper.findAll('thead > tr > th')).toHaveLength(3)
})
it('with seven rows', () => {
expect(wrapper.findAll('tbody > tr')).toHaveLength(7)
})
})
})
})

View File

@ -0,0 +1,84 @@
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="statistic-table">
<b-table-simple style="width: auto" class="mt-5" striped stacked="md">
<b-thead>
<b-tr>
<b-th></b-th>
<b-th class="text-right">{{ $t('statistic.count') }}</b-th>
<b-th class="text-right">{{ $t('statistic.details') }}</b-th>
</b-tr>
</b-thead>
<b-tbody>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.totalUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.activeUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.activeUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.deletedUsers') }}</b>
</b-td>
<b-td class="text-right">{{ value.deletedUsers }}</b-td>
<b-td></b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoCreated') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoCreated, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoCreated }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoDecayed') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoDecayed, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoDecayed }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoAvailable') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoAvailable, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoAvailable }}</b-td>
</b-tr>
<b-tr>
<b-td>
<b>{{ $t('statistic.totalGradidoUnbookedDecayed') }}</b>
</b-td>
<b-td class="text-right">
{{ $n(value.totalGradidoUnbookedDecayed, 'decimal') }} {{ $t('GDD') }}
</b-td>
<b-td class="text-right">{{ value.totalGradidoUnbookedDecayed }}</b-td>
</b-tr>
</b-tbody>
</b-table-simple>
</div>
</template>
<script>
export default {
name: 'StatisticTable',
props: {
value: {
type: Object,
required: true,
},
},
}
</script>

View File

@ -85,7 +85,6 @@
"hide_details": "Details verbergen",
"lastname": "Nachname",
"math": {
"colon": ":",
"equals": "=",
"exclaim": "!",
"pipe": "|",
@ -104,6 +103,7 @@
"my-account": "Mein Konto",
"open_creation": "Offene Schöpfungen",
"overview": "Übersicht",
"statistic": "Statistik",
"user_search": "Nutzersuche"
},
"not_open_creations": "Keine offenen Schöpfungen",
@ -125,8 +125,9 @@
"save": "Speichern",
"statistic": {
"activeUsers": "Aktive Mitglieder",
"count": "Menge",
"deletedUsers": "Gelöschte Mitglieder",
"name": "Statistik",
"details": "Details",
"totalGradidoAvailable": "GDD insgesamt im Umlauf",
"totalGradidoCreated": "GDD insgesamt geschöpft",
"totalGradidoDecayed": "GDD insgesamt verfallen",

View File

@ -85,7 +85,6 @@
"hide_details": "Hide details",
"lastname": "Lastname",
"math": {
"colon": ":",
"equals": "=",
"exclaim": "!",
"pipe": "|",
@ -104,6 +103,7 @@
"my-account": "My Account",
"open_creation": "Open creations",
"overview": "Overview",
"statistic": "Statistic",
"user_search": "User search"
},
"not_open_creations": "No open creations",
@ -125,8 +125,9 @@
"save": "Speichern",
"statistic": {
"activeUsers": "Active members",
"count": "Count",
"deletedUsers": "Deleted members",
"name": "Statistic",
"details": "Details",
"totalGradidoAvailable": "Total GDD in circulation",
"totalGradidoCreated": "Total created GDD",
"totalGradidoDecayed": "Total GDD decay",

View File

@ -0,0 +1,98 @@
import { mount } from '@vue/test-utils'
import CommunityStatistic from './CommunityStatistic.vue'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { toastErrorSpy } from '../../test/testSetup'
import VueApollo from 'vue-apollo'
import { createMockClient } from 'mock-apollo-client'
const mockClient = createMockClient()
const apolloProvider = new VueApollo({
defaultClient: mockClient,
})
const localVue = global.localVue
localVue.use(VueApollo)
const defaultData = () => {
return {
communityStatistics: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
}
}
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => n),
}
describe('CommunityStatistic', () => {
let wrapper
const communityStatisticsMock = jest.fn()
mockClient.setRequestHandler(
communityStatistics,
communityStatisticsMock
.mockRejectedValueOnce({ message: 'Ouch!' })
.mockResolvedValue({ data: defaultData() }),
)
const Wrapper = () => {
return mount(CommunityStatistic, { localVue, mocks, apolloProvider })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the Div Element ".community-statistic"', () => {
expect(wrapper.find('div.community-statistic').exists()).toBe(true)
})
describe('server response for get statistics is an error', () => {
it('toast an error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
})
describe('server response for getting statistics is success', () => {
it('renders the data correctly', () => {
expect(wrapper.findAll('tr').at(1).findAll('td').at(1).text()).toEqual('3113')
expect(wrapper.findAll('tr').at(2).findAll('td').at(1).text()).toEqual('1057')
expect(wrapper.findAll('tr').at(3).findAll('td').at(1).text()).toEqual('35')
expect(wrapper.findAll('tr').at(4).findAll('td').at(1).text()).toEqual(
'4083774.05000000000000000000 GDD',
)
expect(wrapper.findAll('tr').at(4).findAll('td').at(2).text()).toEqual(
'4083774.05000000000000000000',
)
expect(wrapper.findAll('tr').at(5).findAll('td').at(1).text()).toEqual(
'-1062639.13634129622923372197 GDD',
)
expect(wrapper.findAll('tr').at(5).findAll('td').at(2).text()).toEqual(
'-1062639.13634129622923372197',
)
expect(wrapper.findAll('tr').at(6).findAll('td').at(1).text()).toEqual(
'2513565.869444365732411569 GDD',
)
expect(wrapper.findAll('tr').at(6).findAll('td').at(2).text()).toEqual(
'2513565.869444365732411569',
)
expect(wrapper.findAll('tr').at(7).findAll('td').at(1).text()).toEqual(
'-500474.6738366222166261272 GDD',
)
expect(wrapper.findAll('tr').at(7).findAll('td').at(2).text()).toEqual(
'-500474.6738366222166261272',
)
})
})
})
})

View File

@ -0,0 +1,42 @@
<template>
<div class="community-statistic">
<statistic-table v-model="statistics" />
</div>
</template>
<script>
import { communityStatistics } from '@/graphql/communityStatistics.js'
import StatisticTable from '../components/Tables/StatisticTable.vue'
export default {
name: 'CommunityStatistic',
components: {
StatisticTable,
},
data() {
return {
statistics: {
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
},
}
},
apollo: {
CommunityStatistics: {
query() {
return communityStatistics
},
update({ communityStatistics }) {
this.statistics = communityStatistics
},
error({ message }) {
this.toastError(message)
},
},
},
}
</script>

View File

@ -1,6 +1,5 @@
import { mount } from '@vue/test-utils'
import Overview from './Overview.vue'
import { communityStatistics } from '@/graphql/communityStatistics.js'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
const localVue = global.localVue
@ -22,19 +21,6 @@ const apolloQueryMock = jest
],
},
})
.mockResolvedValueOnce({
data: {
communityStatistics: {
totalUsers: 3113,
activeUsers: 1057,
deletedUsers: 35,
totalGradidoCreated: '4083774.05000000000000000000',
totalGradidoDecayed: '-1062639.13634129622923372197',
totalGradidoAvailable: '2513565.869444365732411569',
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
},
},
})
.mockResolvedValue({
data: {
listUnconfirmedContributions: [
@ -88,14 +74,6 @@ describe('Overview', () => {
)
})
it('calls communityStatistics', () => {
expect(apolloQueryMock).toBeCalledWith(
expect.objectContaining({
query: communityStatistics,
}),
)
})
it('commits three pending creations to store', () => {
expect(storeCommitMock).toBeCalledWith('setOpenCreations', 3)
})

View File

@ -28,32 +28,13 @@
</b-link>
</b-card-text>
</b-card>
<community-statistic class="mt-5" v-model="statistics" />
</div>
</template>
<script>
import { communityStatistics } from '@/graphql/communityStatistics.js'
import CommunityStatistic from '../components/CommunityStatistic.vue'
import { listUnconfirmedContributions } from '@/graphql/listUnconfirmedContributions.js'
export default {
name: 'overview',
components: {
CommunityStatistic,
},
data() {
return {
statistics: {
totalUsers: null,
activeUsers: null,
deletedUsers: null,
totalGradidoCreated: null,
totalGradidoDecayed: null,
totalGradidoAvailable: null,
totalGradidoUnbookedDecayed: null,
},
}
},
methods: {
getPendingCreations() {
this.$apollo
@ -65,30 +46,9 @@ export default {
this.$store.commit('setOpenCreations', result.data.listUnconfirmedContributions.length)
})
},
getCommunityStatistics() {
this.$apollo
.query({
query: communityStatistics,
})
.then((result) => {
this.statistics.totalUsers = result.data.communityStatistics.totalUsers
this.statistics.activeUsers = result.data.communityStatistics.activeUsers
this.statistics.deletedUsers = result.data.communityStatistics.deletedUsers
this.statistics.totalGradidoCreated = result.data.communityStatistics.totalGradidoCreated
this.statistics.totalGradidoDecayed = result.data.communityStatistics.totalGradidoDecayed
this.statistics.totalGradidoAvailable =
result.data.communityStatistics.totalGradidoAvailable
this.statistics.totalGradidoUnbookedDecayed =
result.data.communityStatistics.totalGradidoUnbookedDecayed
})
.catch(() => {
this.toastError('communityStatistics has no result, use default data')
})
},
},
created() {
this.getPendingCreations()
this.getCommunityStatistics()
},
}
</script>

View File

@ -44,8 +44,8 @@ describe('router', () => {
})
describe('routes', () => {
it('has seven routes defined', () => {
expect(routes).toHaveLength(8)
it('has nine routes defined', () => {
expect(routes).toHaveLength(9)
})
it('has "/overview" as default', async () => {
@ -82,12 +82,19 @@ describe('router', () => {
})
describe('contribution-links', () => {
it('loads the "ContributionLinks" component', async () => {
it('loads the "ContributionLinks" page', async () => {
const component = await routes.find((r) => r.path === '/contribution-links').component()
expect(component.default.name).toBe('ContributionLinks')
})
})
describe('statistics', () => {
it('loads the "CommunityStatistic" page', async () => {
const component = await routes.find((r) => r.path === '/statistic').component()
expect(component.default.name).toBe('CommunityStatistic')
})
})
describe('not found page', () => {
it('renders the "NotFound" component', async () => {
const component = await routes.find((r) => r.path === '*').component()

View File

@ -6,6 +6,10 @@ const routes = [
path: '/',
component: () => import('@/pages/Overview.vue'),
},
{
path: '/statistic',
component: () => import('@/pages/CommunityStatistic.vue'),
},
{
// TODO: Implement a "You are logged out"-Page
path: '/logout',

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v11.2022-10-27
CONFIG_VERSION=v12.2022-11-10
# Server
PORT=4000
@ -61,7 +61,8 @@ EVENT_PROTOCOL_DISABLED=false
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
# LOG_LEVEL=info
# DHT
# if you set this value, the DHT hyperswarm will start to announce and listen
# on an hash created from this tpoic
# DHT_TOPIC=GRADIDO_HUB
# Federation
# if you set the value of FEDERATION_DHT_TOPIC, the DHT hyperswarm will start to announce and listen
# on an hash created from this topic
# FEDERATION_DHT_TOPIC=GRADIDO_HUB
# FEDERATION_DHT_SEED=64ebcb0e3ad547848fef4197c6e2332f

View File

@ -56,5 +56,6 @@ WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET
# EventProtocol
EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED
# DHT
DHT_TOPIC=$DHT_TOPIC
# Federation
FEDERATION_DHT_TOPIC=$FEDERATION_DHT_TOPIC
FEDERATION_DHT_SEED=$FEDERATION_DHT_SEED

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "1.14.1",
"version": "1.15.0",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",
@ -19,13 +19,7 @@
},
"dependencies": {
"@hyperswarm/dht": "^6.2.0",
"@types/email-templates": "^10.0.1",
"@types/i18n": "^0.13.4",
"@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",
"class-validator": "^0.13.1",
"cors": "^2.8.5",
@ -46,18 +40,23 @@
"random-bigint": "^0.0.1",
"reflect-metadata": "^0.1.13",
"sodium-native": "^3.3.0",
"ts-jest": "^27.0.5",
"type-graphql": "^1.1.1",
"uuid": "^8.3.2"
},
"devDependencies": {
"@types/email-templates": "^10.0.1",
"@types/express": "^4.17.12",
"@types/faker": "^5.5.9",
"@types/i18n": "^0.13.4",
"@types/jest": "^27.0.2",
"@types/jsonwebtoken": "^8.5.2",
"@types/lodash.clonedeep": "^4.5.6",
"@types/node": "^16.10.3",
"@types/nodemailer": "^6.4.4",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^4.28.0",
"@typescript-eslint/parser": "^4.28.0",
"apollo-server-testing": "^2.25.2",
"eslint": "^7.29.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^16.0.3",
@ -66,8 +65,10 @@
"eslint-plugin-prettier": "^3.4.0",
"eslint-plugin-promise": "^5.1.0",
"faker": "^5.5.3",
"jest": "^27.2.4",
"nodemon": "^2.0.7",
"prettier": "^2.3.1",
"ts-jest": "^27.0.5",
"ts-node": "^10.0.0",
"tsconfig-paths": "^3.14.0",
"typescript": "^4.3.4"

View File

@ -10,14 +10,14 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0052-add_updated_at_to_contributions',
DB_VERSION: '0054-recalculate_balance_and_decay',
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: 'v11.2022-10-27',
EXPECTED: 'v12.2022-11-10',
CURRENT: '',
},
}
@ -117,7 +117,8 @@ if (
}
const federation = {
DHT_TOPIC: process.env.DHT_TOPIC || null,
FEDERATION_DHT_TOPIC: process.env.FEDERATION_DHT_TOPIC || null,
FEDERATION_DHT_SEED: process.env.FEDERATION_DHT_SEED || null,
}
const CONFIG = {

View File

@ -4,11 +4,16 @@
import DHT from '@hyperswarm/dht'
// import { Connection } from '@dbTools/typeorm'
import { backendLogger as logger } from '@/server/logger'
import CONFIG from '@/config'
function between(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
const KEY_SECRET_SEEDBYTES = 32
const getSeed = (): Buffer | null =>
CONFIG.FEDERATION_DHT_SEED ? Buffer.alloc(KEY_SECRET_SEEDBYTES, CONFIG.FEDERATION_DHT_SEED) : null
const POLLTIME = 20000
const SUCCESSTIME = 120000
const ERRORTIME = 240000
@ -27,8 +32,9 @@ export const startDHT = async (
): Promise<void> => {
try {
const TOPIC = DHT.hash(Buffer.from(topic))
const keyPair = DHT.keyPair()
const keyPair = DHT.keyPair(getSeed())
logger.info(`keyPairDHT: publicKey=${keyPair.publicKey.toString('hex')}`)
logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex')}`)
const node = new DHT({ keyPair })

View File

@ -0,0 +1,12 @@
import { registerEnumType } from 'type-graphql'
export enum PasswordEncryptionType {
NO_PASSWORD = 0,
EMAIL = 1,
GRADIDO_ID = 2,
}
registerEnumType(PasswordEncryptionType, {
name: 'PasswordEncryptionType', // this one is mandatory
description: 'Type of the password encryption', // this one is optional
})

View File

@ -74,7 +74,10 @@ export class TransactionLinkResolver {
const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay)
// validate amount
await calculateBalance(user.id, holdAvailableAmount, createdDate)
const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate)
if (!sendBalance) {
throw new Error("user hasn't enough GDD or amount is < 0")
}
const transactionLink = dbTransactionLink.create()
transactionLink.userId = user.id

View File

@ -16,7 +16,7 @@ import { stephenHawking } from '@/seeds/users/stephen-hawking'
import { EventProtocol } from '@entity/EventProtocol'
import { Transaction } from '@entity/Transaction'
import { User } from '@entity/User'
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { cleanDB, testEnvironment } from '@test/helpers'
import { logger } from '@test/testSetup'
import { GraphQLError } from 'graphql'
import { findUserByEmail } from './UserResolver'
@ -253,50 +253,21 @@ describe('send coins', () => {
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError(`User has not received any GDD yet`)],
errors: [new GraphQLError(`user hasn't enough GDD or amount is < 0`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`No prior transaction found for user with id: ${user[1].id}`,
`user hasn't enough GDD or amount is < 0 : balance=null`,
)
})
})
describe('sending negative amount', () => {
it('throws an error', async () => {
jest.clearAllMocks()
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: -50,
memo: 'testing negative',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError('Transaction amount must be greater than 0')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Transaction amount must be greater than 0: -50')
})
})
})
describe('user has some GDD', () => {
beforeAll(async () => {
resetToken()
// login as bob again
await query({ mutation: login, variables: bobData })
// create contribution as user bob
const contribution = await mutate({
mutation: createContribution,
@ -316,6 +287,37 @@ describe('send coins', () => {
await query({ mutation: login, variables: bobData })
})
afterAll(async () => {
await cleanDB()
})
/*
describe('trying to send negative amount', () => {
it('throws an error', async () => {
expect(
await mutate({
mutation: sendCoins,
variables: {
email: 'peter@lustig.de',
amount: -50,
memo: 'testing negative',
},
}),
).toEqual(
expect.objectContaining({
errors: [new GraphQLError(`user hasn't enough GDD or amount is < 0`)],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`user hasn't enough GDD or amount is < 0 : balance=null`,
)
})
})
*/
describe('good transaction', () => {
it('sends the coins', async () => {
expect(

View File

@ -39,7 +39,6 @@ import { findUserByEmail } from './UserResolver'
import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { Decay } from '../model/Decay'
export const executeTransaction = async (
amount: Decimal,
@ -69,8 +68,17 @@ export const executeTransaction = async (
// validate amount
const receivedCallDate = new Date()
const sendBalance = await calculateBalance(sender.id, amount, receivedCallDate, transactionLink)
const sendBalance = await calculateBalance(
sender.id,
amount.mul(-1),
receivedCallDate,
transactionLink,
)
logger.debug(`calculated Balance=${sendBalance}`)
if (!sendBalance) {
logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`)
throw new Error("user hasn't enough GDD or amount is < 0")
}
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
@ -100,24 +108,7 @@ export const executeTransaction = async (
transactionReceive.userId = recipient.id
transactionReceive.linkedUserId = sender.id
transactionReceive.amount = amount
// state received balance
let receiveBalance: {
balance: Decimal
decay: Decay
lastTransactionId: number
} | null
// try received balance
try {
receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
} catch (e) {
logger.info(
`User with no transactions sent: ${recipient.id}, has received a transaction of ${amount} GDD from user: ${sender.id}`,
)
receiveBalance = null
}
const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount
transactionReceive.balanceDate = receivedCallDate
transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0)

View File

@ -36,6 +36,9 @@ import { UserContact } from '@entity/UserContact'
import { OptInType } from '../enum/OptInType'
import { UserContactType } from '../enum/UserContactType'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { encryptPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
// import { klicktippSignIn } from '@/apis/KlicktippController'
@ -146,6 +149,7 @@ describe('UserResolver', () => {
publisherId: 1234,
referrerId: null,
contributionLinkId: null,
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
},
])
const valUUID = validateUUID(user[0].gradidoID)
@ -491,7 +495,8 @@ describe('UserResolver', () => {
})
it('updates the password', () => {
expect(newUser.password).toEqual('3917921995996627700')
const encryptedPass = encryptPassword(newUser, 'Aa12345_')
expect(newUser.password.toString()).toEqual(encryptedPass.toString())
})
/*
@ -1155,6 +1160,93 @@ describe('UserResolver', () => {
})
})
})
describe('password encryption type', () => {
describe('user just registered', () => {
let bibi: User
it('has password type gradido id', async () => {
const users = await User.find()
bibi = users[1]
expect(bibi).toEqual(
expect.objectContaining({
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
})
})
describe('user has encryption type email', () => {
const variables = {
email: 'bibi@bloxberg.de',
password: 'Aa12345_',
publisherId: 1234,
}
let bibi: User
beforeAll(async () => {
const usercontact = await UserContact.findOneOrFail(
{ email: 'bibi@bloxberg.de' },
{ relations: ['user'] },
)
bibi = usercontact.user
bibi.passwordEncryptionType = PasswordEncryptionType.EMAIL
bibi.password = SecretKeyCryptographyCreateKey(
'bibi@bloxberg.de',
'Aa12345_',
)[0].readBigUInt64LE()
await bibi.save()
})
it('changes to gradidoID on login', async () => {
await mutate({ mutation: login, variables: variables })
const usercontact = await UserContact.findOneOrFail(
{ email: 'bibi@bloxberg.de' },
{ relations: ['user'] },
)
bibi = usercontact.user
expect(bibi).toEqual(
expect.objectContaining({
firstName: 'Bibi',
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
})
it('can login after password change', async () => {
resetToken()
expect(await mutate({ mutation: login, variables: variables })).toEqual(
expect.objectContaining({
data: {
login: {
email: 'bibi@bloxberg.de',
firstName: 'Bibi',
hasElopage: false,
id: expect.any(Number),
isAdmin: null,
klickTipp: {
newsletterState: false,
},
language: 'de',
lastName: 'Bloxberg',
publisherId: 1234,
},
},
}),
)
})
})
})
})
describe('printTimeDuration', () => {

View File

@ -40,17 +40,15 @@ import { SearchAdminUsersResult } from '@model/AdminUser'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
import { v4 as uuidv4 } from 'uuid'
import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
// eslint-disable-next-line @typescript-eslint/no-var-requires
const random = require('random-bigint')
// We will reuse this for changePassword
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', 'es', 'fr', 'nl']
const DEFAULT_LANGUAGE = 'de'
const isLanguage = (language: string): boolean => {
@ -107,48 +105,6 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
return [pubKey, privKey]
}
const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
logger.trace('SecretKeyCryptographyCreateKey...')
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
logger.error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
throw new Error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
}
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
sodium.crypto_hash_sha512_init(state)
sodium.crypto_hash_sha512_update(state, Buffer.from(salt))
sodium.crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
sodium.crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
sodium.crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, sodium.crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES)
sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
logger.debug(
`SecretKeyCryptographyCreateKey...successful: encryptionKeyHash= ${encryptionKeyHash}, encryptionKey= ${encryptionKey}`,
)
return [encryptionKeyHash, encryptionKey]
}
const SecretKeyCryptographyEncrypt = (message: Buffer, encryptionKey: Buffer): Buffer => {
logger.trace('SecretKeyCryptographyEncrypt...')
const encrypted = Buffer.alloc(message.length + sodium.crypto_secretbox_MACBYTES)
@ -280,12 +236,17 @@ export class UserResolver {
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new Error('User has no private or publicKey')
}
const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
const loginUserPassword = BigInt(dbUser.password.toString())
if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) {
if (!verifyPassword(dbUser, password)) {
logger.error('The User has no valid credentials.')
throw new Error('No user with this credentials')
}
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
dbUser.password = encryptPassword(dbUser, password)
await dbUser.save()
}
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
logger.addContext('user', dbUser.id)
logger.debug('validation of login credentials successful...')
@ -416,6 +377,7 @@ export class UserResolver {
dbUser.lastName = lastName
dbUser.language = language
dbUser.publisherId = publisherId
dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD
dbUser.passphrase = passphrase.join(' ')
logger.debug('new dbUser=' + dbUser)
if (redeemCode) {
@ -569,7 +531,7 @@ export class UserResolver {
): Promise<boolean> {
logger.info(`setPassword(${code}, ***)...`)
// Validate Password
if (!isPassword(password)) {
if (!isValidPassword(password)) {
logger.error('Password entered is lexically invalid')
throw new Error(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
@ -627,10 +589,11 @@ export class UserResolver {
userContact.emailChecked = true
// Update Password
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
user.password = passwordHash[0].readBigUInt64LE() // using the shorthash
user.password = encryptPassword(user, password)
user.pubKey = keyPair[0]
user.privKey = encryptedPrivkey
logger.debug('User credentials updated ...')
@ -736,7 +699,7 @@ export class UserResolver {
if (password && passwordNew) {
// Validate Password
if (!isPassword(passwordNew)) {
if (!isValidPassword(passwordNew)) {
logger.error('newPassword does not fullfil the rules')
throw new Error(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
@ -748,7 +711,7 @@ export class UserResolver {
userEntity.emailContact.email,
password,
)
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) {
if (!verifyPassword(userEntity, password)) {
logger.error(`Old password is invalid`)
throw new Error(`Old password is invalid`)
}
@ -764,7 +727,8 @@ export class UserResolver {
logger.debug('PrivateKey encrypted...')
// Save new password hash and newly encrypted private key
userEntity.password = newPasswordHash[0].readBigUInt64LE()
userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
userEntity.password = encryptPassword(userEntity, passwordNew)
userEntity.privKey = encryptedPrivkey
}

View File

@ -19,8 +19,14 @@ async function main() {
})
// start DHT hyperswarm when DHT_TOPIC is set in .env
if (CONFIG.DHT_TOPIC) {
await startDHT(CONFIG.DHT_TOPIC) // con,
if (CONFIG.FEDERATION_DHT_TOPIC) {
// eslint-disable-next-line no-console
console.log(
`starting Federation on ${CONFIG.FEDERATION_DHT_TOPIC} ${
CONFIG.FEDERATION_DHT_SEED ? 'with seed...' : 'without seed...'
}`,
)
await startDHT(CONFIG.FEDERATION_DHT_TOPIC) // con,
}
}

View File

@ -0,0 +1,71 @@
import CONFIG from '@/config'
import { backendLogger as logger } from '@/server/logger'
import { User } from '@entity/User'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
// We will reuse this for changePassword
export const isValidPassword = (password: string): boolean => {
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
}
export const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
logger.trace('SecretKeyCryptographyCreateKey...')
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
logger.error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
throw new Error(
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
)
}
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
sodium.crypto_hash_sha512_init(state)
sodium.crypto_hash_sha512_update(state, Buffer.from(salt))
sodium.crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
sodium.crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
sodium.crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, sodium.crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES)
sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return [encryptionKeyHash, encryptionKey]
}
export const getUserCryptographicSalt = (dbUser: User): string => {
switch (dbUser.passwordEncryptionType) {
case PasswordEncryptionType.NO_PASSWORD: {
logger.error('Password not set for user ' + dbUser.id)
throw new Error('Password not set for user ' + dbUser.id) // user has no password
}
case PasswordEncryptionType.EMAIL: {
return dbUser.emailContact.email
break
}
case PasswordEncryptionType.GRADIDO_ID: {
return dbUser.gradidoID
break
}
default:
logger.error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
throw new Error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
}
}

View File

@ -0,0 +1,14 @@
import { User } from '@entity/User'
// import { logger } from '@test/testSetup' getting error "jest is not defined"
import { getUserCryptographicSalt, SecretKeyCryptographyCreateKey } from './EncryptorUtils'
export const encryptPassword = (dbUser: User, password: string): bigint => {
const salt = getUserCryptographicSalt(dbUser)
const keyBuffer = SecretKeyCryptographyCreateKey(salt, password) // return short and long hash
const passwordHash = keyBuffer[0].readBigUInt64LE()
return passwordHash
}
export const verifyPassword = (dbUser: User, password: string): boolean => {
return dbUser.password.toString() === encryptPassword(dbUser, password).toString()
}

View File

@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { PasswordEncryptionType } from '@/graphql/enum/PasswordEncryptionType'
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
import { User as dbUser } from '@entity/User'
import { UserContact } from '@entity/UserContact'
@ -26,6 +27,8 @@ const communityDbUser: dbUser = {
isAdmin: null,
publisherId: 0,
passphrase: '',
// default password encryption type
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
hasId: function (): boolean {
throw new Error('Function not implemented.')
},

View File

@ -1,17 +1,5 @@
import Decimal from 'decimal.js-light'
export const objectValuesToArray = (obj: { [x: string]: string }): Array<string> => {
return Object.keys(obj).map(function (key) {
return obj[key]
})
}
// to improve code readability, as String is needed, it is handled inside this utility function
export const decimalAddition = (a: Decimal, b: Decimal): Decimal => {
return a.add(b.toString())
}
// to improve code readability, as String is needed, it is handled inside this utility function
export const decimalSubtraction = (a: Decimal, b: Decimal): Decimal => {
return a.minus(b.toString())
}

View File

@ -5,8 +5,6 @@ import { Decay } from '@model/Decay'
import { getCustomRepository } from '@dbTools/typeorm'
import { TransactionLinkRepository } from '@repository/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { decimalSubtraction, decimalAddition } from './utilities'
import { backendLogger as logger } from '@/server/logger'
function isStringBoolean(value: string): boolean {
const lowerValue = value.toLowerCase()
@ -25,26 +23,13 @@ async function calculateBalance(
amount: Decimal,
time: Date,
transactionLink?: dbTransactionLink | null,
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number }> {
// negative or empty amount should not be allowed
if (amount.lessThanOrEqualTo(0)) {
logger.error(`Transaction amount must be greater than 0: ${amount}`)
throw new Error('Transaction amount must be greater than 0')
}
// check if user has prior transactions
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> {
const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } })
if (!lastTransaction) {
logger.error(`No prior transaction found for user with id: ${userId}`)
throw new Error('User has not received any GDD yet')
}
if (!lastTransaction) return null
const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time)
// new balance is the old balance minus the amount used
const balance = decimalSubtraction(decay.balance, amount)
const balance = decay.balance.add(amount.toString())
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time)
@ -52,16 +37,11 @@ async function calculateBalance(
// else we cannot redeem links which are more or equal to half of what an account actually owns
const releasedLinkAmount = transactionLink ? transactionLink.holdAvailableAmount : new Decimal(0)
const availableBalance = decimalSubtraction(balance, sumHoldAvailableAmount)
if (decimalAddition(availableBalance, releasedLinkAmount).lessThan(0)) {
logger.error(
`Not enough funds for a transaction of ${amount} GDD, user with id: ${userId} has only ${balance} GDD available`,
)
throw new Error('Not enough funds for transaction')
if (
balance.minus(sumHoldAvailableAmount.toString()).plus(releasedLinkAmount.toString()).lessThan(0)
) {
return null
}
logger.debug(`calculated Balance=${balance}`)
return { balance, lastTransactionId: lastTransaction.id, decay }
}

View File

@ -100,6 +100,8 @@ COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
# Copy Mnemonic files
COPY --from=build ${DOCKER_WORKDIR}/src/config/*.txt ./src/config/
# Copy log folder
COPY --from=build ${DOCKER_WORKDIR}/log ./log
# Copy run scripts run/
# COPY --from=build ${DOCKER_WORKDIR}/run ./run

View File

@ -0,0 +1,127 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToMany,
JoinColumn,
OneToOne,
} from 'typeorm'
import { Contribution } from '../Contribution'
import { ContributionMessage } from '../ContributionMessage'
import { UserContact } from '../UserContact'
@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,
collation: 'utf8mb4_unicode_ci',
})
gradidoID: string
@Column({
name: 'alias',
length: 20,
nullable: 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({
type: 'text',
name: 'passphrase',
collation: 'utf8mb4_unicode_ci',
nullable: true,
default: null,
})
passphrase: string
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
@JoinColumn({ name: 'email_id' })
emailContact: UserContact
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
emailId: number | null
@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
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
@Column({ type: 'bigint', default: 0, unsigned: true })
password: BigInt
@Column({
name: 'password_encryption_type',
type: 'int',
unsigned: true,
nullable: false,
default: 0,
})
passwordEncryptionType: number
@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
@OneToMany(() => Contribution, (contribution) => contribution.user)
@JoinColumn({ name: 'user_id' })
contributions?: Contribution[]
@OneToMany(() => ContributionMessage, (message) => message.user)
@JoinColumn({ name: 'user_id' })
messages?: ContributionMessage[]
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
@JoinColumn({ name: 'user_id' })
userContacts?: UserContact[]
}

View File

@ -0,0 +1,60 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
DeleteDateColumn,
OneToOne,
} from 'typeorm'
import { User } from './User'
@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
export class UserContact extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({
name: 'type',
length: 100,
nullable: true,
default: null,
collation: 'utf8mb4_unicode_ci',
})
type: string
@OneToOne(() => User, (user) => user.emailContact)
user: User
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false })
userId: number
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
email: string
@Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true })
emailVerificationCode: BigInt
@Column({ name: 'email_opt_in_type_id' })
emailOptInTypeId: number
@Column({ name: 'email_resend_count' })
emailResendCount: number
// @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true })
// emailHash: Buffer
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
emailChecked: boolean
@Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' })
phone: string
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
createdAt: Date
@Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' })
updatedAt: Date | null
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
deletedAt: Date | null
}

View File

@ -1 +1 @@
export { User } from './0049-add_user_contacts_table/User'
export { User } from './0053-change_password_encryption/User'

View File

@ -1 +1 @@
export { UserContact } from './0049-add_user_contacts_table/UserContact'
export { UserContact } from './0053-change_password_encryption/UserContact'

2
database/log/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,24 @@
/* MIGRATION TO ADD ENCRYPTION TYPE TO PASSWORDS
*
* This migration adds and renames columns in the table `users`
*/
/* 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 users RENAME COLUMN created TO created_at;')
await queryFn('ALTER TABLE users RENAME COLUMN deletedAt TO deleted_at;')
// alter table emp rename column emp_name to name
await queryFn(
'ALTER TABLE users ADD COLUMN password_encryption_type int(10) NOT NULL DEFAULT 0 AFTER password;',
)
await queryFn(`UPDATE users SET password_encryption_type = 1 WHERE id IN
(SELECT user_id FROM user_contacts WHERE email_checked = 1)`)
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE users RENAME COLUMN created_at TO created;')
await queryFn('ALTER TABLE users RENAME COLUMN deleted_at TO deletedAt;')
await queryFn('ALTER TABLE users DROP COLUMN password_encryption_type;')
}

View File

@ -0,0 +1,160 @@
/* MIGRATION TO FIX WRONG BALANCE
*
* Due to a bug in the code
* the amount of a receive balance is substracted
* from the previous balance instead of added.
*
* Therefore all balance and decay fields must
* be recalculated
*
* WARNING: This Migration must be run in TZ=UTC
*/
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
import fs from 'fs'
import Decimal from 'decimal.js-light'
// Set precision value
Decimal.set({
precision: 25,
rounding: Decimal.ROUND_HALF_UP,
})
const DECAY_START_TIME = new Date('2021-05-13 17:46:31') // GMT+0
interface Decay {
balance: Decimal
decay: Decimal | null
start: Date | null
end: Date | null
duration: number | null
}
export enum TransactionTypeId {
CREATION = 1,
SEND = 2,
RECEIVE = 3,
}
function decayFormula(value: Decimal, seconds: number): Decimal {
return value.mul(new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds))
}
function calculateDecay(
amount: Decimal,
from: Date,
to: Date,
startBlock: Date = DECAY_START_TIME,
): Decay {
const fromMs = from.getTime()
const toMs = to.getTime()
const startBlockMs = startBlock.getTime()
if (toMs < fromMs) {
throw new Error('to < from, reverse decay calculation is invalid')
}
// Initialize with no decay
const decay: Decay = {
balance: amount,
decay: null,
start: null,
end: null,
duration: null,
}
// decay started after end date; no decay
if (startBlockMs > toMs) {
return decay
}
// decay started before start date; decay for full duration
if (startBlockMs < fromMs) {
decay.start = from
decay.duration = (toMs - fromMs) / 1000
}
// decay started between start and end date; decay from decay start till end date
else {
decay.start = startBlock
decay.duration = (toMs - startBlockMs) / 1000
}
decay.end = to
decay.balance = decayFormula(amount, decay.duration)
decay.decay = decay.balance.minus(amount)
return decay
}
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
// Write log file
const logFile = 'log/0054-recalculate_balance_and_decay.log.csv'
await fs.writeFile(
logFile,
`email;first_name;last_name;affected_transactions;new_balance;new_decay;old_balance;old_decay;delta;\n`,
(err) => {
if (err) throw err
},
)
// Find all users & loop over them
const users = await queryFn('SELECT user_id FROM transactions GROUP BY user_id;')
for (let u = 0; u < users.length; u++) {
const userId = users[u].user_id
// find all transactions for a user
const transactions = await queryFn(
`SELECT *, CONVERT(balance, CHAR) as dec_balance, CONVERT(decay, CHAR) as dec_decay FROM transactions WHERE user_id = ${userId} ORDER BY balance_date ASC;`,
)
let previous = null
let affectedTransactions = 0
let balance = new Decimal(0)
for (let t = 0; t < transactions.length; t++) {
const transaction = transactions[t]
const decayStartDate = previous ? previous.balance_date : transaction.balance_date
const amount = new Decimal(transaction.amount)
const decay = calculateDecay(balance, decayStartDate, transaction.balance_date)
balance = decay.balance.add(amount)
const userContact = await queryFn(
`SELECT email, first_name, last_name FROM users LEFT JOIN user_contacts ON users.email_id = user_contacts.id WHERE users.id = ${userId}`,
)
const userEmail = userContact.length === 1 ? userContact[0].email : userId
const userFirstName = userContact.length === 1 ? userContact[0].first_name : ''
const userLastName = userContact.length === 1 ? userContact[0].last_name : ''
// Update if needed
if (!balance.eq(transaction.dec_balance)) {
await queryFn(`
UPDATE transactions SET
balance = ${balance},
decay = ${decay.decay ? decay.decay : 0}
WHERE id = ${transaction.id};
`)
affectedTransactions++
// Log on last entry
if (t === transactions.length - 1) {
fs.appendFile(
logFile,
`${userEmail};${userFirstName};${userLastName};${affectedTransactions};${balance};${
decay.decay ? decay.decay : 0
};${transaction.dec_balance};${transaction.dec_decay};${balance.sub(
transaction.dec_balance,
)};\n`,
(err) => {
if (err) throw err
},
)
}
}
// previous
previous = transaction
}
}
}
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {}

View File

@ -1,6 +1,6 @@
{
"name": "gradido-database",
"version": "1.14.1",
"version": "1.15.0",
"description": "Gradido Database Tool to execute database migrations",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/database",

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=v11.2022-10-27
BACKEND_CONFIG_VERSION=v12.2022-11-10
JWT_EXPIRES_IN=10m
GDT_API_URL=https://gdt.gradido.net
@ -59,10 +59,11 @@ WEBHOOK_ELOPAGE_SECRET=secret
# EventProtocol
EVENT_PROTOCOL_DISABLED=false
## DHT
## if you set this value, the DHT hyperswarm will start to announce and listen
## on an hash created from this tpoic
# DHT_TOPIC=GRADIDO_HUB
# Federation
# if you set the value of FEDERATION_DHT_TOPIC, the DHT hyperswarm will start to announce and listen
# on an hash created from this topic
# FEDERATION_DHT_TOPIC=GRADIDO_HUB
# FEDERATION_DHT_SEED=64ebcb0e3ad547848fef4197c6e2332f
# database
DATABASE_CONFIG_VERSION=v1.2022-03-18

View File

@ -0,0 +1,379 @@
# Zeitzonen
Die Gradido-Anwendung läuft im Backend in der Zeitzone UTC und im Frontend in der jeweiligen lokalen Zeitzone, in der der User sich anmeldet. Dadurch kann es zu zeitlichen Diskrepanzen kommen, die innerhalb der Anwendungslogik aufgelöst bzw. entsprechend behandelt werden müssen. In den folgenden Kapiteln werden die verschiedenen zeitlichen Konstellationen dargestellt und für die verschiedenen fachlichen Prozesse die daraus resultierenden Problemlösungen beschrieben.
![img](./image/ZeitzonenKonstellationen.png)
## Beispiel 1
Ein User meldet sich in einer Zeitzone t0 - 4 an. Das bedeutet der User liegt 4 Stunden gegenüber der Backend-Zeit zurück.
Konkret hat der User die Zeit 31.08.2022 21:00:00 auf dem Server ist aber die Zeit bei 01.09.2022 01:00:00
Für die Erstellung einer Contribution hat der User noch folgende Gültigkeitsmonate und Beträge zur Wahl:
Juni 2022: 500 GDD | Juli 2022: 200 GDD | August 2022: 1000 GDD
**aber das Backend liefert nur die Beträge, die eigentlich so korrekt wären!!!!!**
**Juli 2022: 200 GDD | August 2022: 1000 GDD | September 2022: 1000 GDD**
Er möchte für den Juni 2022 eine Contribution mit 500 GDD erfassen. **Wird ihm der Juni noch als Schöpfungsmonat angezeigt?**
Falls ja, dann wählt er dabei im FE im Kalender den 30.06.2022. Dann liefert das FE folgende Contribution-Daten an das Backend:
* Gültigkeitsdatum: 30.06.2022 00:00:00
* Memo: text
* Betrag: 500 GDD
* **Zeitzone: wird eine Zeitzone des User aus dem Context geliefert? Das fehlt: entweder über eine Zeit vom FE zum BE und ermitteln Offset im BE**
Im Backend wird dieses dann interpretiert und verarbeitet mit:
* **Belegung des Schöpfungsmonate-Arrays: [ 6, 7, 8] oder [7, 8, 9] da auf dem Server ja schon der 01.09.2022 ist?**
* Gültigkeitsdatum: **30.06.2022 00:00:00 oder 01.07.2022 04:00:00 ?**
* Memo: text
* Betrag 500 GDD
* created_at: 01.07.2022 04:00:00
**Frage: wird die Contribution dem Juni (6) oder dem Juli (7) zugeordnet?**
1. falls Juni zugeordnet kann die Contribution mit 500 GDD eingelöst werden
2. falls Juli zugeordnet muss die Contribution mit 500 GDD abgelehnt werden, da möglicher Schöpfungsbetrag überschritten
## Beispiel 2
Ein User meldet sich in einer Zeitzone t0 + 1 an. Das bedeutet der User liegt 1 Stunde gegenüber der Backend-Zeit voraus.
Konkret hat der User die Zeit 01.09.2022 00:20:00 auf dem Server ist aber die Zeit bei 31.08.2022 23:20:00
Für die Erstellung einer Contribution hat der User noch folgende Gültigkeitsmonate und Beträge zur Wahl:
Juli 2022: 200 GDD | August 2022: 1000 GDD | September 2022: 1000 GDD
**oder wird ihm**
**
Juni 2022: 500 GDD | Juli 2022: 200 GDD | August 2022: 1000 GDD**
**angezeigt, da auf dem BE noch der 31.08.2022 ist?**
Er möchte für den September 2022 eine Contribution mit 500 GDD erfassen und wählt dabei im FE im Kalender den 01.09.2022. Dann liefert das FE folgende Contribution-Daten an das Backend:
* Gültigkeitsdatum: 01.09.2022 00:00:00 (siehe Logauszüge der Fehleranalyse im Ticket #2179)
* Memo: text
* Betrag: 500 GDD
* **Zeitzone: wird eine Zeitzone des User aus dem Context geliefert?**
Im Backend wird dieses dann interpretiert und verarbeitet mit:
* Belegung des Schöpfungsmonate-Arrays: [ 6, 7, 8] **wie kann der User dann aber vorher September 2022 für die Schöpfung auswählen?**
* Gültigkeitsdatum: 01.09.2022 00:00:00
* Memo: text
* Betrag 500 GDD
* created_at: 31.08.2022 23:20:00
Es kommt zu einem **Fehler im Backend**, da im Schöpfungsmonate-Array kein September (9) vorhanden ist, da auf dem Server noch der 31.08.2022 und damit das Array nur die Monate Juni, Juli, August und nicht September beinhaltet.
## Erkenntnisse:
* die dem User angezeigten Schöpfungsmonate errechnen sich aus der lokalen User-Zeit und nicht aus der Backend-Zeit
* das Backend muss somit für Ermittlung der möglichen Schöpfungsmonate und deren noch freien Schöpfungssummen den UserTimeOffset berücksichten
* der gewählte Schöpfungsmonat muss 1:1 vom Frontend in das Backend übertragen werden
* es darf kein Mapping in die Backend-Zeit erfolgen
* sondern es muss der jeweilige UserTimeOffset mitgespeichert werden
* die Logik im BE muss den übertragenen bzw. ermittelten Offset der FE-Zeit entsprechend berücksichten und nicht die Backendzeit in der Logik anwenden
* im BE darf es kein einfaches now = new Date() geben
* im BE muss stattdessen ein userNow = new Date() + UserTimeOffset verwendet werden
* ein CreatedAt / UpdatedAt / DeletedAt / ConfirmedAt wird wie bisher in BE-Zeit gespeichert
* **NEIN nicht notwendig:** plus in einer jeweils neuen Spalte CreatedOffset / UpdatedOffset / DeletedOffset / ConfirmedOffset der dabei gültige UserTimeOffset
* im FE wird immer im Request-Header der aktuelle Zeitpunkt mit Zeitzone geschrieben
*
## Entscheidung
* in den HTTP-Request-Header wird generell der aktuelle Timestamp des Clients eingetragen, sodass die aktuelle Uhrzeit des Users ohne weitere Signatur-Änderungen in jedem Aufruf am Backend ankommt. Moritz erstellt Ticket
* es wird eine Analyse aller Backend-Aufrufe gemacht, die die Auswertung der User-Time und dessen evtl. Timezone-Differenz in der Logik des Backend-Aufrufs benötigt.
* diese Backend-Methoden müssen fachlich so überarbeitet werden, dass immer aus dem Timezone-Offset die korrekte fachliche Logik als Ergebnis heraus kommt. In der Datenbank wird aber immer die UTC-Zeit gespeichert.
* Es werden keine zusätzlichen Datanbank-Attribute zur Speicherung des User-TimeOffsets benötigt.
## Analyse der Backend-Aufrufe
Es werden alle Resolver und ihre Methoden sowie im Resolver exportierte Attribute/Methoden untersucht.
Mit + gekennzeichnet sind diese, die mit dem UserTimeOffset interagieren und überarbeitet werden müssen.
Mit - gekennzeichnet sind diese, die keiner weiteren Aktion bedürfen.
### AdminResolver
#### + adminCreateContribution
Hier wird der User zur übergebenen Email inklusive der Summen über die letzten drei Schöpfungsmonate aus seinen vorhandenen Contributions, egal ob bestätigt oder noch offen ermittelt.
Hier muss der User-TimeOffset berücksichtigt werden, um die korrekten drei Schöpfungsmonate und dann daraus die korrekten Beträge der Contributions zu ermitteln.
Zusätzlich wird als Parameter ein *creationDate* vom User mitgeliefert, das dem User-TimeOffset unterliegt. Auch dieses muss entsprechend beachtet und beim internen Aufruf von *validateContribution()* und der Initialisierung der Contribution berücksichtigt werden.
#### - adminCreateContributionMessage
nothing to do
#### + adminCreateContributions
Hier wird eine Liste von übergebenen Contributions über den internen Aufruf von *adminCreateContribution()* verarbeitet. Da dort eine Berücksichtigung des User-TimeOffsets notwendig ist, muss hier die UserTime entsprechen im Context weitergereicht werden.
#### - adminDeleteContribution
nothing to do
#### + adminUpdateContribution
analog adminCreateContribution() muss hier der User-TimeOffset berücksichtigt werden.
#### + confirmContribution
Hier wird intern *getUserCreation()* und *validateContribution()* aufgerufen, daher analog adminCreateContribution()
#### + createContributionLink
Hier werden zwar ein *ValidFrom* und ein *ValidTo* Datum übergeben, doch dürften diese keiner Beachtung eines User-TimezoneOffsets unterliegen. Trotzdem bitte noch einmal verifizieren.
#### - creationTransactionList
nothing to do
#### - deleteContributionLink
Es wird zwar der *deletedAt*-Zeitpunkt als Rückgabewert geliefert, doch m.E. dürft hier keine Berücksichtigung des User-TimezoneOffsets notwendig sein.
#### - deleteUser
Es wird zwar der *deletedAt*-Zeitpunkt als Rückgabewert geliefert, doch m.E. dürft hier keine Berücksichtigung des User-TimezoneOffsets notwendig sein.
#### - listContributionLinks
nothing to do
#### + listTransactionLinksAdmin
Hier wird die BE-Zeit für die Suche nach ValidUntil verwendet. Dies sollte nocheinmal verifiziert werden.
#### + listUnconfirmedContributions
Hier wird intern *getUserCreations()* aufgerufen für die Summen der drei Schöpfungsmonate, somit ist der User-TimezoneOffset zu berücksichtigen.
#### + searchUsers
Hier wird intern *getUserCreations()* aufgerufen für die Summen der drei Schöpfungsmonate, somit ist der User-TimezoneOffset zu berücksichtigen.
#### - sendActivationEmail
analog *UserResolver.checkOptInCode*
#### - setUserRole
nothing to do
#### - unDeleteUser
nothing to do
#### + updateContributionLink
Hier werden zwar ein *ValidFrom* und ein *ValidTo* Datum übergeben, doch dürften diese keiner Beachtung eines User-TimezoneOffsets unterliegen. Trotzdem bitte noch einmal verifizieren.
### BalanceResolver
#### + balance
Hier wird der aktuelle Zeitpunkt des BE verwendet, um den Decay und die Summen der Kontostände zu ermitteln. Dies müsste eigentlich von dem User-TimezoneOffset unabhängig sein. Sollte aber noch einmal dahingehend verifiziert werden.
### CommunityResolver
#### - communities
nothing to do
#### - getCommunityInfo
nothing to do
### ContributionMessageResolver
#### - createContributionMessage
nothing to do
#### - listContributionMessages
nothing to do
### ContributionResolver
#### + createContribution
Hier wird der User inklusive der Summen über die letzten drei Schöpfungsmonate aus seinen vorhandenen Contributions, egal ob bestätigt oder noch offen ermittelt.
Hier muss der User-TimeOffset berücksichtigt werden, um die korrekten drei Schöpfungsmonate und dann daraus die korrekten Beträge der Contributions zu ermitteln.
Zusätzlich wird als Parameter ein *creationDate* vom User mitgeliefert, das dem User-TimeOffset unterliegt. Auch dieses muss entsprechend beachtet und beim internen Aufruf von *validateContribution()* und der Initialisierung der Contribution berücksichtigt werden.
#### - deleteContribution
nothing to do
#### - listAllContributions
nothing to do
#### - listContributions
nothing to do
#### + updateContribution
Hier werden die Contributions des Users inklusive der Summen über die letzten drei Schöpfungsmonate aus seinen vorhandenen Contributions, egal ob bestätigt oder noch offen ermittelt.
Hier muss der User-TimeOffset berücksichtigt werden, um die korrekten drei Schöpfungsmonate und dann daraus die korrekten Beträge der Contributions zu ermitteln.
Zusätzlich wird als Parameter ein *creationDate* vom User mitgeliefert, das dem User-TimeOffset unterliegt. Auch dieses muss entsprechend beachtet und beim internen Aufruf von *validateContribution()* und dem Update der Contribution berücksichtigt werden.
### GdtResolver
#### - existPid
nothing to do
#### - gdtBalance
nothing to do
#### - listGDTEntries
nothing to do
### KlicktippResolver
nothing to do
### StatisticsResolver
#### + communityStatistics
Hier werden die Daten zum aktuellen BE-Zeitpunkt ermittelt und dem User angezeigt. Aber der User hat ggf. einen anderen TimeOffset. Daher die Frage, ob die Ermittlung der Statistik-Daten mit dem User-TimeOffset stattfinden muss.
### TransactionLinkResolver
#### - transactionLinkCode
nothing to do
#### - transactionLinkExpireDate
nothing to do
#### - createTransactionLink
nothing to do
#### - deleteTransactionLink
nothing to do
#### - listTransactionLinks
nothing to do
#### - queryTransactionLink
nothing to do
#### - redeemTransactionLink
nothing to do
### TransactionResolver
#### - executeTransaction
nothing to do
#### - sendCoins
nothing to do
#### + transactionList
Hier wird der aktuelle BE-Zeitpunkt verwendet, um die Summen der vorhandenen Transactions bis zu diesem Zeitpunkt zu ermitteln. Nach ersten Einschätzungen dürfte es hier nichts zu tun geben. Aber es sollte noch einmal geprüft werden.
### UserResolver
#### - activationLink
nothing to do
#### - checkOptInCode
Hier wird der übergebene OptIn-Code geprüft, ob schon wieder eine erneute Email gesendet werden kann. Die Zeiten werden auf reiner BE-Zeit verglichen, von daher gibt es hier nichts zu tun.
#### - createUser
nothing to do
#### - forgotPassword
In dieser Methode wird am Ende in der Methode *sendResetPasswordEmailMailer()* die Zeit berechnet, wie lange der OptIn-Code im Link gültig ist, default 1440 min oder 24 h.
Es ist keine User-TimeOffset zu berücksichten, da der OptInCode direkt als Parameter im Aufruf von queryOptIn verwendet und dann dort mit der BE-Time verglichen wird.
#### - hasElopage
nothing to do
#### - login
nothing to do
#### - logout
nothing to do
#### - queryOptIn
Hier wird der OptIn-Code aus der *sendResetPasswordEmailMailer()* als Parameter geliefert. Da dessen Gültigkeit zuvor in forgotPassword mit der BE-Zeit gesetzt wurde, benögt man hier keine Berücksichtigung des User-TimeOffsets.
#### - searchAdminUsers
nothing to do
#### - setPassword
nothing to do, analog *queryOptIn*
#### - printTimeDuration
nothing to do
#### - updateUserInfos
nothing to do
#### + verifyLogin
Hier wird der User inklusive der Summen über die letzten drei Schöpfungsmonate aus seinen vorhandenen Contribtutions, egal ob bestätigt oder noch offen ermittelt.
Hier muss der User-TimeOffset berücksichtigt werden, um die korrekten drei Schöpfungsmonate und dann daraus die korrekten Beträge der Contributions zu ermitteln.

View File

@ -0,0 +1,217 @@
<mxfile host="65bd71144e">
<diagram id="-PxXzgsMUT8aslXVdGG0" name="Seite-1">
<mxGraphModel dx="1022" 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="2" value="" style="endArrow=classic;html=1;strokeWidth=5;fillColor=#60a917;strokeColor=#2D7600;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="160" y="320" as="sourcePoint"/>
<mxPoint x="2160" y="320" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="3" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1160" y="360" as="sourcePoint"/>
<mxPoint x="1160" y="320" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="4" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1130" y="370" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="5" value="Backend&lt;br style=&quot;font-size: 18px;&quot;&gt;Zeitzone UTC" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=18;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="40" y="360" width="200" height="30" as="geometry"/>
</mxCell>
<mxCell id="6" value="Frontend&lt;br style=&quot;font-size: 18px;&quot;&gt;verschiedene Zeitzonen" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=18;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="40" y="240" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="7" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1160" y="320" as="sourcePoint"/>
<mxPoint x="1160" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="8" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1080" y="320" as="sourcePoint"/>
<mxPoint x="1080" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="9" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1000" y="320" as="sourcePoint"/>
<mxPoint x="1000" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="10" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="920" y="320" as="sourcePoint"/>
<mxPoint x="920" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="11" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="840" y="320" as="sourcePoint"/>
<mxPoint x="840" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="12" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="760" y="320" as="sourcePoint"/>
<mxPoint x="760" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="13" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="680" y="320" as="sourcePoint"/>
<mxPoint x="680" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="14" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="600" y="320" as="sourcePoint"/>
<mxPoint x="600" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="15" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="520" y="320" as="sourcePoint"/>
<mxPoint x="520" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="16" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="440" y="320" as="sourcePoint"/>
<mxPoint x="440" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="17" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="360" y="320" as="sourcePoint"/>
<mxPoint x="360" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="18" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="279.75" y="320" as="sourcePoint"/>
<mxPoint x="279.75" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="19" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="200" y="320" as="sourcePoint"/>
<mxPoint x="200" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="20" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="2120" y="320" as="sourcePoint"/>
<mxPoint x="2120" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="21" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="2040" y="320" as="sourcePoint"/>
<mxPoint x="2040" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="22" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1960" y="320" as="sourcePoint"/>
<mxPoint x="1960" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="23" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1880" y="320" as="sourcePoint"/>
<mxPoint x="1880" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="24" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1800" y="320" as="sourcePoint"/>
<mxPoint x="1800" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="25" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1720" y="320" as="sourcePoint"/>
<mxPoint x="1720" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="26" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1640" y="320" as="sourcePoint"/>
<mxPoint x="1640" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="27" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1560" y="320" as="sourcePoint"/>
<mxPoint x="1560" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="28" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1480" y="320" as="sourcePoint"/>
<mxPoint x="1480" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="29" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1400" y="320" as="sourcePoint"/>
<mxPoint x="1400" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="30" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1319.75" y="320" as="sourcePoint"/>
<mxPoint x="1319.75" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="31" value="" style="endArrow=none;html=1;strokeWidth=5;" edge="1" parent="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="1240" y="320" as="sourcePoint"/>
<mxPoint x="1240" y="280" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="32" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1130" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="33" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 2&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="970" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="36" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 4&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="810" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="38" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 6&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="650" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="40" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 8&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="490" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="42" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt; - 10&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="330" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="43" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 10&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1930" y="240" width="70" height="30" as="geometry"/>
</mxCell>
<mxCell id="44" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 8&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1770" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="45" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 6&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1610" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="46" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 4&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1450" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="47" value="&lt;font style=&quot;font-size: 24px&quot;&gt;&lt;b&gt;t&lt;/b&gt;&lt;/font&gt;&lt;font size=&quot;1&quot;&gt;&lt;b style=&quot;font-size: 13px&quot;&gt;0&lt;/b&gt;&lt;b style=&quot;font-size: 20px&quot;&gt;&amp;nbsp;+ 2&lt;/b&gt;&lt;/font&gt;" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
<mxGeometry x="1290" y="240" width="60" height="30" as="geometry"/>
</mxCell>
<mxCell id="89" value="mögliche Zeitzonen-Konstellationen" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=22;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="40" y="130" width="400" height="30" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

167
docu/RoadMap_2022-2023.md Normal file
View File

@ -0,0 +1,167 @@
# Roadmap 2022 / 2023
## unsortierte Sammlung von Themen
1. backend access layer
- Refactoring der Resolver-Klassen
- Daten-Zugriffschicht zur Kapselung der DB-Schicht
- Transfer-Datenmodel zum Austausch von Daten zwischen den Schichten
- technisches Transaktion-Handling und Lösung von Deadlocks
- Konzept in Arbeit
2. capturing alias
- Konzept fertig
- Änderungen in Register- und Login-Prozess
3. Passwort-Verschlüsselung: Refactoring
- Konzept aufteilen in Ausbaustufen
- Altlasten entsorgen
- Versionierung/Typisierung der verwendeten Verschlüsselungslogik notwendig
- DB-Migration auf encryptionType=EMAIL
4. Passwort-Verschlüsselung: Login mit impliziter Neuverschlüsselung
* Logik der Passwortverschlüsselung auf GradidoID einführen
* bei Login mit encryptionType=Email oder OneTime triggern einer Neuverschlüsselung per GradidoID
* Unabhängigkeit von Email erzeugen
* Änderung der User-Email ermöglichen
5. Contribution-Categories
- Bewertung und Kategorisierung von Schöpfungen: Was hat Wer für Wen geleistet?
- Regeln auf Categories ermöglichen
- Konzept in Arbeit
6. Statistics / Analysen
7. Contribution-Link editieren
8. User-Tagging
- Eine UserTag dient zur einfachen Gruppierung gleichgesinnter oder örtlich gebundener User
- Motivation des User-Taggings: bilden kleinerer lokaler User-Gruppen und jeder kennt jeden
- Einführung einer UserTaggings-Tabelle und eine User-UserTaggings-Zuordnungs-Tabelle
- Ein Moderator kann im AdminInterface die Liste der UserTags pflegen
- neues TAG anlegen
- vorhandenes TAG umbenennen
- ein TAG löschen, sofern kein User mehr diesem TAG zugeordnet ist
- Will ein User ein TAG zugeordnet werden, so kann dies nur ein Moderator im AdminInterface tun
- Ein Moderator kann im AdminInterface
- ein TAG einem User zuordnen
- ein TAG von einem User entfernen
- wichtige UseCases:
- Zuordnung eines Users zu einem TAG durch einen Moderator
- TAG spezifische Schöpfung
- User muss für seinen Beitrag ein TAG auswählen können, dem er zuvor zugeordnet wurde
- TAG-Moderator kann den Beitrag bestätigen, weil er den User mit dem TAG (persönlich) kennt
9. User-Beziehungen und Favoritenverwaltung
- User-User-Zuordnung
- aus Tx-Liste die aktuellen Favoriten ermitteln
- Verwaltung von Zuordnungen
- Auswahl
- Berechtigungen
- Gruppierung
- Community-übergreifend
- User-Beziehungen
10. technische Ablösung der Email und Ersatz durch GradidoID
* APIs / Links / etc mit Email anpassen, so dass keine Email mehr verwendet wird
* Email soll aber im Aussen für User optional noch verwendbar bleiben
* Intern erfolgt aber auf jedenfall ein Mapping auf GradidoID egal ob per Email oder Alias angefragt wird
11. Zeitzone
- User sieht immer seine Locale-Zeit und Monate
- Admin sieht immer UTC-Zeit und Monate
- wichtiges Kriterium für Schöpfung ist das TargetDate ( heißt in DB contributionDate)
- Berechnung der möglichen Schöpfungen muss somit auf dem TargetDate der Schöpfung ermittelt werden! **(Ist-Zustand)**
- Kann es vorkommen, dass das TargetDate der Contribution vor dem CreationDate der TX liegt? Ja
- Beispiel: User in Tokyo Locale mit Offest +09:00
- aktiviert Contribution-Link mit Locale: 01.11.2022 07:00:00+09:00 = TargetDate = Zieldatum der Schöpfung
- die Contribution wird gespeichert mit
- creationDate=31.10.2022 22:00:00 UTC
- contributionDate=01.11.2022 07:00:00
- (neu) clientRequestTime=01.11.2022 07:00:00+09:00
- durch automatische Bestätigung und sofortiger Transaktion wird die TX gespeichert mit
- creationDate=31.10.2022 22:00:00 UTC
- **zwingende Prüfung aller Requeste: auf -12h <= ClientRequestTime <= +12h**
- Prüfung auf Sommerzeiten und exotische Länder beachten
-
- zur Analyse und Problemverfolgung von Contributions immer original ClientRequestTime mit Offset in DB speichern
- Beispiel für täglichen Contribution-Link während des Monats:
- 17.10.2022 22:00 +09:00 => 17.10.2022 UTC: 17.10.2022 13:00 UTC => 17.10.2022
- 18.10.2022 02:00 +09:00 => 18.10.2022 UTC: 17.10.2022 17:00 UTC => 17.10.2022 !!!! darf nicht weil gleicher Tag !!!
- Beispiel für täglichen Contribution-Link am Monatswechsel:
- 31.10.2022 22:00 +09:00 => 31.10.2022 UTC: 31.10.2022 15:00 UTC => 31.10.2022
- 01.11.2022 07:00 +09:00 => 01.11.2022 UTC: 31.10.2022 22:00 UTC => 31.10.2022 !!!! darf nicht weil gleicher Tag !!!
12. Layout
13. Lastschriften-Link
14. Registrierung mit Redeem-Link:
* bei inaktivem Konto, sprich bisher noch keine Email-Bestätigung, keine Buchung möglich
* somit speichern des Links zusammen mit OptIn-Code
* damit kann in einem Resend der ConfirmationEmail der Link auch korrekt wieder mitgeliefert werden
15. Manuelle User-Registrierung für Admin
- soll am 10.12.2022 für den Tag bei den Galliern produktiv sein
16. Dezentralisierung / Federation
- Hyperswarm
- funktioniert schon im Prototyp
- alle Instanzen finden sich gegenseitig
- ToDo:
- Infos aus HyperSwarm in der Community speichern
- Prüfung ob neue mir noch unbekannte Community hinzugekommen ist?
- Triggern der Authentifizierungs- und Autorisierungs-Handshake für neue Community
- Authentifizierungs- und Autorisierungs-Handshake
- Inter-Community-Communication
- **ToDos**:
- DB-Migration für Community-Tabelle, User-Community-Zuordnungen, UserRights-Tabelle
- Berechtigungen für Communities
- Register- und Login-Prozess für Community-Anmeldung anpassen
- Auswahl-Box einer Community
- createUser mit Zuordnung zur ausgewählten Community
- Schöpfungsprozess auf angemeldete Community anpassen
- "Beitrag einreichen"-Dialog auf angemeldete Community anpassen
- "meine Beiträge zum Gemeinwohl" mit Filter auf angemeldete Community anpassen
- "Gemeinschaft"-Dialog auf angemeldete Community anpassen
- "Mein Profil"-Dialog auf Communities anpassen
- Umzug-Service in andere Community
- Löschen der Mitgliedschaft zu angemeldeter Community (Deaktivierung der Zuordnung "User-Community")
- "Senden"-Dialog mit Community-Auswahl
- "Transaktion"-Dialog mit Filter auf angemeldeter Community
- AdminInterface auf angemeldete Community anpassen
- "Übersicht"-Dialog mit Filter auf angemeldete Community
- "Nutzersuche"-Dialog mit Filter auf angemeldete Community
- "Mehrfachschöpfung"-Dialog mit Filter auf angemeldete Comunity
- Subject/Texte/Footer/... der Email-Benachrichtigungen auf angemeldete Community anpassen
## Priorisierung
1. Contribution-Link editieren (vlt schon im vorherigen Bugfix-Release Ende Okt. 2022 fertig)
2. Passwort-Verschlüsselung: Refactoring **Konzeption fertig!!**!
3. Manuelle User-Registrierung für Admin (10.12.2022) **Konzeption ongoing!!**!
4. Passwort-Verschlüsselung: implizite Login-Neuverschlüsselung **Konzeption fertig!!**!
5. Layout
6. Zeitzone
7. Dezentralisierung / Federation
8. capturing alias **Konzeption fertig!!**!
9. Registrierung mit Redeem-Link: bei inaktivem Konto keine Buchung möglich
10. Subgruppierung / User-Tagging (einfacher Ansatz)
11. backend access layer
12. technische Ablösung der Email und Ersatz durch GradidoID
13. User-Beziehungen und Favoritenverwaltung
14. Lastschriften-Link
15. Contribution-Categories
16. Statistics / Analysen

View File

@ -0,0 +1,60 @@
<mxfile host="65bd71144e">
<diagram id="CdUoMVivL2xThNJutTjM" name="Seite-1">
<mxGraphModel dx="1022" 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="14" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="2" target="7">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="160" y="100"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="2" value="capturing alias" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="3" value="Manuelle User-Registrierung für Admin (10.12.2022)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="200" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="4" value="Zeitzone" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="280" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="5" value="User-Beziehungen und Favoritenverwaltung" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="360" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="6" value="Layout" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="440" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="15" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="7" target="12">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="440" y="140"/>
</Array>
</mxGeometry>
</mxCell>
<mxCell id="7" value="Passwort-Verschlüsselung" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="320" y="80" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="8" value="Subgruppierung / Subcommunities" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="520" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="9" value="Contribution-Categories" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="600" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="10" value="backend access layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="680" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="11" value="Statistics / Analysen" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="760" width="440" height="40" as="geometry"/>
</mxCell>
<mxCell id="12" value="Ablösung der Email und Ersatz durch GradidoID" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="600" y="120" width="360" height="40" as="geometry"/>
</mxCell>
<mxCell id="13" value="Dezentralisierung / Federation" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
<mxGeometry x="40" y="840" width="440" height="40" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -1,6 +1,6 @@
{
"name": "bootstrap-vue-gradido-wallet",
"version": "1.14.1",
"version": "1.15.0",
"private": true,
"scripts": {
"start": "node run/server.js",

View File

@ -38,10 +38,12 @@ export default {
form: {
text: '',
},
isSubmitting: false,
}
},
methods: {
onSubmit() {
this.isSubmitting = true
this.$apollo
.mutate({
mutation: createContributionMessage,
@ -55,9 +57,11 @@ export default {
this.$emit('update-state', this.contributionId)
this.form.text = ''
this.toastSuccess(this.$t('message.reply'))
this.isSubmitting = false
})
.catch((error) => {
this.toastError(error.message)
this.isSubmitting = false
})
},
onReset() {
@ -66,10 +70,7 @@ export default {
},
computed: {
disabled() {
if (this.form.text !== '') {
return false
}
return true
return this.form.text === '' || this.isSubmitting
},
},
}

View File

@ -1,6 +1,6 @@
{
"name": "gradido",
"version": "1.14.1",
"version": "1.15.0",
"description": "Gradido",
"main": "index.js",
"repository": "git@github.com:gradido/gradido.git",