mirror of
https://github.com/IT4Change/gradido.git
synced 2025-12-13 07:45:54 +00:00
Merge branch 'master' into 1216-SEO-Vorschau-Links
This commit is contained in:
commit
3aeb59b7fb
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -422,7 +422,7 @@ jobs:
|
||||
report_name: Coverage Admin Interface
|
||||
type: lcov
|
||||
result_path: ./coverage/lcov.info
|
||||
min_coverage: 94
|
||||
min_coverage: 95
|
||||
token: ${{ github.token }}
|
||||
|
||||
##############################################################################
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@ -4,6 +4,36 @@ 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.6.6](https://github.com/gradido/gradido/compare/1.6.5...1.6.6)
|
||||
|
||||
- Fix: Upper case email on register breaks account [`#1542`](https://github.com/gradido/gradido/pull/1542)
|
||||
- 1106 first transaction cannot be expanded [`#1432`](https://github.com/gradido/gradido/pull/1432)
|
||||
- added missing bootstrap scss. bootstrap/scss/bootstrap, plus more mis… [`#1540`](https://github.com/gradido/gradido/pull/1540)
|
||||
- feat: Seed Deleted User [`#1533`](https://github.com/gradido/gradido/pull/1533)
|
||||
- fix: No Creations for Deleted Users [`#1534`](https://github.com/gradido/gradido/pull/1534)
|
||||
- fix: Wrong Key Name for Recover User [`#1535`](https://github.com/gradido/gradido/pull/1535)
|
||||
- [Feature] : user deleted and undeleted functions for adminarea [`#1520`](https://github.com/gradido/gradido/pull/1520)
|
||||
- fix: Possible SQL Exception in User Search [`#1530`](https://github.com/gradido/gradido/pull/1530)
|
||||
- Feature: Make lint warnings unwanted [`#1529`](https://github.com/gradido/gradido/pull/1529)
|
||||
- 1459 list data again on confirm creation [`#1467`](https://github.com/gradido/gradido/pull/1467)
|
||||
- fix: Return Empty Array When No Pending Creations Are Present [`#1526`](https://github.com/gradido/gradido/pull/1526)
|
||||
- Fix: Correct path of index.js in production [`#1525`](https://github.com/gradido/gradido/pull/1525)
|
||||
- refactor: Get Open Creations by One Query [`#1524`](https://github.com/gradido/gradido/pull/1524)
|
||||
- Admin: Langsame Benutzer-Suche [`#1472`](https://github.com/gradido/gradido/pull/1472)
|
||||
- fix: Backend Unit Tests Running Again [`#1513`](https://github.com/gradido/gradido/pull/1513)
|
||||
- Refactor: Combine transaction tables [`#1523`](https://github.com/gradido/gradido/pull/1523)
|
||||
- Refactor: User resolver [`#1522`](https://github.com/gradido/gradido/pull/1522)
|
||||
- feature: Soft-Delete for users (backend) [`#1521`](https://github.com/gradido/gradido/pull/1521)
|
||||
- feature: Soft-Delete for users (database only) [`#1516`](https://github.com/gradido/gradido/pull/1516)
|
||||
- refactor: Improve Decay Display [`#1517`](https://github.com/gradido/gradido/pull/1517)
|
||||
- 404 page needs back to login button [`#1515`](https://github.com/gradido/gradido/pull/1515)
|
||||
- feature: show current version in admin footer [`#1514`](https://github.com/gradido/gradido/pull/1514)
|
||||
- fix: Never Sent Email Text [`#1512`](https://github.com/gradido/gradido/pull/1512)
|
||||
- refactor: static decay block [`#1405`](https://github.com/gradido/gradido/pull/1405)
|
||||
- refactor: Use Bootstrap Vue Toast [`#1499`](https://github.com/gradido/gradido/pull/1499)
|
||||
- fix: Catch GDT Server Errors [`#1479`](https://github.com/gradido/gradido/pull/1479)
|
||||
- Fix: Autochangelog - no commits [`#1498`](https://github.com/gradido/gradido/pull/1498)
|
||||
|
||||
#### [1.6.5](https://github.com/gradido/gradido/compare/1.6.4...1.6.5)
|
||||
|
||||
> 15 February 2022
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"description": "Administraion Interface for Gradido",
|
||||
"main": "index.js",
|
||||
"author": "Moriz Wahl",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.6",
|
||||
"license": "MIT",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
@ -11,7 +11,7 @@
|
||||
"serve": "vue-cli-service serve --open",
|
||||
"dev": "yarn run serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "eslint --ext .js,.vue .",
|
||||
"lint": "eslint --max-warnings=0 --ext .js,.vue .",
|
||||
"test": "TZ=UTC jest --coverage",
|
||||
"locales": "scripts/missing-keys.sh && scripts/sort.sh"
|
||||
},
|
||||
@ -36,6 +36,7 @@
|
||||
"graphql": "^15.6.1",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "26.6.3",
|
||||
"portal-vue": "^2.1.7",
|
||||
"regenerator-runtime": "^0.13.9",
|
||||
"stats-webpack-plugin": "^0.7.0",
|
||||
"vue": "^2.6.11",
|
||||
@ -43,7 +44,6 @@
|
||||
"vue-i18n": "^8.26.5",
|
||||
"vue-jest": "^3.0.7",
|
||||
"vue-router": "^3.5.3",
|
||||
"vue-toasted": "^1.1.28",
|
||||
"vuex": "^3.6.2",
|
||||
"vuex-persistedstate": "^4.1.0"
|
||||
},
|
||||
|
||||
BIN
admin/public/img/elopage_favicon.png
Normal file
BIN
admin/public/img/elopage_favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@ -1,21 +1,17 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ConfirmRegisterMailFormular from './ConfirmRegisterMailFormular.vue'
|
||||
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue()
|
||||
const toastSuccessMock = jest.fn()
|
||||
const toastErrorMock = jest.fn()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
$toasted: {
|
||||
success: toastSuccessMock,
|
||||
error: toastErrorMock,
|
||||
},
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
@ -54,7 +50,7 @@ describe('ConfirmRegisterMailFormular', () => {
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessMock).toBeCalledWith('unregister_mail.success')
|
||||
expect(toastSuccessSpy).toBeCalledWith('unregister_mail.success')
|
||||
})
|
||||
})
|
||||
|
||||
@ -66,7 +62,7 @@ describe('ConfirmRegisterMailFormular', () => {
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorMock).toBeCalledWith('unregister_mail.error')
|
||||
expect(toastErrorSpy).toBeCalledWith('unregister_mail.error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -48,10 +48,10 @@ export default {
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.$toasted.success(this.$t('unregister_mail.success', { email: this.email }))
|
||||
this.toastSuccess(this.$t('unregister_mail.success', { email: this.email }))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(this.$t('unregister_mail.error', { message: error.message }))
|
||||
this.toastError(this.$t('unregister_mail.error', { message: error.message }))
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'
|
||||
import CreationFormular from './CreationFormular.vue'
|
||||
import { createPendingCreation } from '../graphql/createPendingCreation'
|
||||
import { createPendingCreations } from '../graphql/createPendingCreations'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
@ -11,8 +12,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({
|
||||
},
|
||||
})
|
||||
const stateCommitMock = jest.fn()
|
||||
const toastedErrorMock = jest.fn()
|
||||
const toastedSuccessMock = jest.fn()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t, options) => (options ? [t, options] : t)),
|
||||
@ -32,10 +31,6 @@ const mocks = {
|
||||
},
|
||||
},
|
||||
},
|
||||
$toasted: {
|
||||
error: toastedErrorMock,
|
||||
success: toastedSuccessMock,
|
||||
},
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
@ -140,7 +135,7 @@ describe('CreationFormular', () => {
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastedSuccessMock).toBeCalledWith([
|
||||
expect(toastSuccessSpy).toBeCalledWith([
|
||||
'creation_form.toasted',
|
||||
{ email: 'benjamin@bluemchen.de', value: '90' },
|
||||
])
|
||||
@ -162,7 +157,7 @@ describe('CreationFormular', () => {
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastedErrorMock).toBeCalledWith('Ouch!')
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouch!')
|
||||
})
|
||||
})
|
||||
|
||||
@ -292,7 +287,7 @@ describe('CreationFormular', () => {
|
||||
})
|
||||
|
||||
it('toast success message', () => {
|
||||
expect(toastedSuccessMock).toBeCalled()
|
||||
expect(toastSuccessSpy).toBeCalled()
|
||||
})
|
||||
|
||||
it('store commit openCreationPlus', () => {
|
||||
@ -426,13 +421,14 @@ describe('CreationFormular', () => {
|
||||
expect(stateCommitMock).toBeCalledWith('openCreationsPlus', 0)
|
||||
})
|
||||
|
||||
it('toasts two errors', () => {
|
||||
expect(toastedErrorMock).toBeCalledWith(
|
||||
'Could not created PendingCreation for bob@baumeister.de',
|
||||
)
|
||||
expect(toastedErrorMock).toBeCalledWith(
|
||||
'Could not created PendingCreation for bibi@bloxberg.de',
|
||||
)
|
||||
it('emits remove all bookmarks', () => {
|
||||
expect(wrapper.emitted('remove-all-bookmark')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('emits toast failed creations with two emails', () => {
|
||||
expect(wrapper.emitted('toast-failed-creations')).toEqual([
|
||||
[['bob@baumeister.de', 'bibi@bloxberg.de']],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -454,7 +450,7 @@ describe('CreationFormular', () => {
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastedErrorMock).toBeCalledWith('Oh no!')
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -166,20 +166,21 @@ export default {
|
||||
fetchPolicy: 'no-cache',
|
||||
})
|
||||
.then((result) => {
|
||||
const failedCreations = []
|
||||
this.$store.commit(
|
||||
'openCreationsPlus',
|
||||
result.data.createPendingCreations.successfulCreation.length,
|
||||
)
|
||||
if (result.data.createPendingCreations.failedCreation.length > 0) {
|
||||
result.data.createPendingCreations.failedCreation.forEach((failed) => {
|
||||
// TODO: Please localize this error message
|
||||
this.$toasted.error('Could not created PendingCreation for ' + failed)
|
||||
result.data.createPendingCreations.failedCreation.forEach((email) => {
|
||||
failedCreations.push(email)
|
||||
})
|
||||
}
|
||||
this.$emit('remove-all-bookmark')
|
||||
this.$emit('toast-failed-creations', failedCreations)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
})
|
||||
} else if (this.type === 'singleCreation') {
|
||||
submitObj = {
|
||||
@ -196,19 +197,19 @@ export default {
|
||||
})
|
||||
.then((result) => {
|
||||
this.$emit('update-user-data', this.item, result.data.createPendingCreation)
|
||||
this.$toasted.success(
|
||||
this.$store.commit('openCreationsPlus', 1)
|
||||
this.toastSuccess(
|
||||
this.$t('creation_form.toasted', {
|
||||
value: this.value,
|
||||
email: this.item.email,
|
||||
}),
|
||||
)
|
||||
this.$store.commit('openCreationsPlus', 1)
|
||||
// what is this? Tests says that this.text is not reseted
|
||||
this.$refs.creationForm.reset()
|
||||
this.value = 0
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
this.$refs.creationForm.reset()
|
||||
this.value = 0
|
||||
})
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CreationTransactionListFormular from './CreationTransactionListFormular.vue'
|
||||
import { toastErrorSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
@ -8,41 +9,25 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
transactionList: {
|
||||
transactions: [
|
||||
{
|
||||
type: 'creation',
|
||||
balance: 100,
|
||||
decayStart: 0,
|
||||
decayEnd: 0,
|
||||
decayDuration: 0,
|
||||
id: 1,
|
||||
amount: 100,
|
||||
balanceDate: 0,
|
||||
creationDate: new Date(),
|
||||
memo: 'Testing',
|
||||
transactionId: 1,
|
||||
name: 'Gradido Akademie',
|
||||
email: 'bibi@bloxberg.de',
|
||||
date: new Date(),
|
||||
decay: {
|
||||
balance: 0.01,
|
||||
decayStart: 0,
|
||||
decayEnd: 0,
|
||||
decayDuration: 0,
|
||||
decayStartBlock: 0,
|
||||
linkedUser: {
|
||||
firstName: 'Gradido',
|
||||
lastName: 'Akademie',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'creation',
|
||||
balance: 200,
|
||||
decayStart: 0,
|
||||
decayEnd: 0,
|
||||
decayDuration: 0,
|
||||
id: 2,
|
||||
amount: 200,
|
||||
balanceDate: 0,
|
||||
creationDate: new Date(),
|
||||
memo: 'Testing 2',
|
||||
transactionId: 2,
|
||||
name: 'Gradido Akademie',
|
||||
email: 'bibi@bloxberg.de',
|
||||
date: new Date(),
|
||||
decay: {
|
||||
balance: 0.01,
|
||||
decayStart: 0,
|
||||
decayEnd: 0,
|
||||
decayDuration: 0,
|
||||
decayStartBlock: 0,
|
||||
linkedUser: {
|
||||
firstName: 'Gradido',
|
||||
lastName: 'Akademie',
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -50,17 +35,12 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
},
|
||||
})
|
||||
|
||||
const toastedErrorMock = jest.fn()
|
||||
|
||||
const mocks = {
|
||||
$d: jest.fn((t) => t),
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
query: apolloQueryMock,
|
||||
},
|
||||
$toasted: {
|
||||
error: toastedErrorMock,
|
||||
},
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
@ -109,7 +89,7 @@ describe('CreationTransactionListFormular', () => {
|
||||
})
|
||||
|
||||
it('toast error', () => {
|
||||
expect(toastedErrorMock).toBeCalledWith('OUCH!')
|
||||
expect(toastErrorSpy).toBeCalledWith('OUCH!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -15,30 +15,32 @@ export default {
|
||||
return {
|
||||
fields: [
|
||||
{
|
||||
key: 'date',
|
||||
key: 'creationDate',
|
||||
label: this.$t('transactionlist.date'),
|
||||
formatter: (value, key, item) => {
|
||||
return this.$d(new Date(value))
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'balance',
|
||||
key: 'amount',
|
||||
label: this.$t('transactionlist.amount'),
|
||||
formatter: (value, key, item) => {
|
||||
return `${value} GDD`
|
||||
},
|
||||
},
|
||||
{ key: 'name', label: this.$t('transactionlist.community') },
|
||||
{
|
||||
key: 'linkedUser',
|
||||
label: this.$t('transactionlist.community'),
|
||||
formatter: (value, key, item) => {
|
||||
return `${value.firstName} ${value.lastName}`
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: this.$t('transactionlist.memo') },
|
||||
{
|
||||
key: 'decay',
|
||||
label: this.$t('transactionlist.decay'),
|
||||
key: 'balanceDate',
|
||||
label: this.$t('transactionlist.balanceDate'),
|
||||
formatter: (value, key, item) => {
|
||||
if (value && value.balance >= 0) {
|
||||
return value.balance
|
||||
} else {
|
||||
return '0'
|
||||
}
|
||||
return this.$d(new Date(value))
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -59,10 +61,10 @@ export default {
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.items = result.data.transactionList.transactions.filter((t) => t.type === 'creation')
|
||||
this.items = result.data.transactionList.transactions
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
248
admin/src/components/DeletedUserFormular.spec.js
Normal file
248
admin/src/components/DeletedUserFormular.spec.js
Normal file
@ -0,0 +1,248 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import DeletedUserFormular from './DeletedUserFormular.vue'
|
||||
import { deleteUser } from '../graphql/deleteUser'
|
||||
import { unDeleteUser } from '../graphql/unDeleteUser'
|
||||
import { toastErrorSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const date = new Date()
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
deleteUser: date,
|
||||
},
|
||||
})
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
$store: {
|
||||
state: {
|
||||
moderator: {
|
||||
id: 0,
|
||||
name: 'test moderator',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
item: {},
|
||||
}
|
||||
|
||||
describe('DeletedUserFormular', () => {
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(DeletedUserFormular, { localVue, mocks, propsData })
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a DIV element with the class.delete-user-formular', () => {
|
||||
expect(wrapper.find('.deleted-user-formular').exists()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete self', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({
|
||||
item: {
|
||||
userId: 0,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a text that you cannot delete yourself', () => {
|
||||
expect(wrapper.text()).toBe('removeNotSelf')
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete other user', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({
|
||||
item: {
|
||||
userId: 1,
|
||||
deletedAt: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('has a checkbox', () => {
|
||||
expect(wrapper.find('input[type="checkbox"]').exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the text "delete_user"', () => {
|
||||
expect(wrapper.text()).toBe('delete_user')
|
||||
})
|
||||
|
||||
describe('click on checkbox', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input[type="checkbox"]').setChecked()
|
||||
})
|
||||
|
||||
it('has a confirmation button', () => {
|
||||
expect(wrapper.find('button').exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has the button text "delete_user"', () => {
|
||||
expect(wrapper.find('button').text()).toBe('delete_user')
|
||||
})
|
||||
|
||||
describe('confirm delete with success', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('button').trigger('click')
|
||||
})
|
||||
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: deleteUser,
|
||||
variables: {
|
||||
userId: 1,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits update deleted At', () => {
|
||||
expect(wrapper.emitted('updateDeletedAt')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
deletedAt: date,
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('unchecks the checkbox', () => {
|
||||
expect(wrapper.find('input').attributes('checked')).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm delete with error', () => {
|
||||
beforeEach(async () => {
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('click on checkbox again', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input[type="checkbox"]').setChecked(false)
|
||||
})
|
||||
|
||||
it('has no confirmation button anymore', () => {
|
||||
expect(wrapper.find('button').exists()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('recover user', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({
|
||||
item: {
|
||||
userId: 1,
|
||||
deletedAt: date,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('has a checkbox', () => {
|
||||
expect(wrapper.find('input[type="checkbox"]').exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the text "undelete_user"', () => {
|
||||
expect(wrapper.text()).toBe('undelete_user')
|
||||
})
|
||||
|
||||
describe('click on checkbox', () => {
|
||||
beforeEach(async () => {
|
||||
apolloMutateMock.mockResolvedValue({
|
||||
data: {
|
||||
unDeleteUser: null,
|
||||
},
|
||||
})
|
||||
await wrapper.find('input[type="checkbox"]').setChecked()
|
||||
})
|
||||
|
||||
it('has a confirmation button', () => {
|
||||
expect(wrapper.find('button').exists()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('has the button text "undelete_user"', () => {
|
||||
expect(wrapper.find('button').text()).toBe('undelete_user')
|
||||
})
|
||||
|
||||
describe('confirm recover with success', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('button').trigger('click')
|
||||
})
|
||||
|
||||
it('calls the API', () => {
|
||||
expect(apolloMutateMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
mutation: unDeleteUser,
|
||||
variables: {
|
||||
userId: 1,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits update deleted At', () => {
|
||||
expect(wrapper.emitted('updateDeletedAt')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: 1,
|
||||
deletedAt: null,
|
||||
},
|
||||
]),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('unchecks the checkbox', () => {
|
||||
expect(wrapper.find('input').attributes('checked')).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm recover with error', () => {
|
||||
beforeEach(async () => {
|
||||
apolloMutateMock.mockRejectedValue({ message: 'Oh no!' })
|
||||
await wrapper.find('button').trigger('click')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('click on checkbox again', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input[type="checkbox"]').setChecked(false)
|
||||
})
|
||||
|
||||
it('has no confirmation button anymore', () => {
|
||||
expect(wrapper.find('button').exists()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
85
admin/src/components/DeletedUserFormular.vue
Normal file
85
admin/src/components/DeletedUserFormular.vue
Normal file
@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="deleted-user-formular">
|
||||
<div v-if="item.userId === $store.state.moderator.id" class="mt-5 mb-5">
|
||||
{{ $t('removeNotSelf') }}
|
||||
</div>
|
||||
<div v-else class="mt-5">
|
||||
<b-form-checkbox switch size="lg" v-model="checked">
|
||||
<div>{{ item.deletedAt ? $t('undelete_user') : $t('delete_user') }}</div>
|
||||
</b-form-checkbox>
|
||||
|
||||
<div class="mt-3 mb-5">
|
||||
<b-button v-if="checked && item.deletedAt === null" variant="danger" @click="deleteUser">
|
||||
{{ $t('delete_user') }}
|
||||
</b-button>
|
||||
<b-button v-if="checked && item.deletedAt !== null" variant="success" @click="unDeleteUser">
|
||||
{{ $t('undelete_user') }}
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { deleteUser } from '../graphql/deleteUser'
|
||||
import { unDeleteUser } from '../graphql/unDeleteUser'
|
||||
|
||||
export default {
|
||||
name: 'DeletedUser',
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
checked: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
deleteUser() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: deleteUser,
|
||||
variables: {
|
||||
userId: this.item.userId,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.$emit('updateDeletedAt', {
|
||||
userId: this.item.userId,
|
||||
deletedAt: result.data.deleteUser,
|
||||
})
|
||||
this.checked = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
unDeleteUser() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: unDeleteUser,
|
||||
variables: {
|
||||
userId: this.item.userId,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.toastSuccess(this.$t('user_recovered'))
|
||||
this.$emit('updateDeletedAt', {
|
||||
userId: this.item.userId,
|
||||
deletedAt: result.data.unDeleteUser,
|
||||
})
|
||||
this.checked = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.input-group-text {
|
||||
background-color: rgb(255, 252, 205);
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import EditCreationFormular from './EditCreationFormular.vue'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
@ -16,8 +17,6 @@ const apolloMutateMock = jest.fn().mockResolvedValue({
|
||||
})
|
||||
|
||||
const stateCommitMock = jest.fn()
|
||||
const toastedErrorMock = jest.fn()
|
||||
const toastedSuccessMock = jest.fn()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
@ -37,10 +36,6 @@ const mocks = {
|
||||
},
|
||||
commit: stateCommitMock,
|
||||
},
|
||||
$toasted: {
|
||||
error: toastedErrorMock,
|
||||
success: toastedSuccessMock,
|
||||
},
|
||||
}
|
||||
|
||||
const now = new Date(Date.now())
|
||||
@ -142,7 +137,7 @@ describe('EditCreationFormular', () => {
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_update')
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_update')
|
||||
})
|
||||
})
|
||||
|
||||
@ -155,7 +150,7 @@ describe('EditCreationFormular', () => {
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastedErrorMock).toBeCalledWith('Oh no!')
|
||||
expect(toastErrorSpy).toBeCalledWith('Oh no!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -132,7 +132,7 @@ export default {
|
||||
moderator: Number(result.data.updatePendingCreation.moderator),
|
||||
row: this.row,
|
||||
})
|
||||
this.$toasted.success(
|
||||
this.toastSuccess(
|
||||
this.$t('creation_form.toasted_update', {
|
||||
value: this.value,
|
||||
email: this.item.email,
|
||||
@ -144,7 +144,7 @@ export default {
|
||||
this.value = 0
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
// das creation Formular reseten
|
||||
this.$refs.updateCreationForm.reset()
|
||||
// Den geschöpften Wert auf o setzen
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<b-card class="shadow-lg pl-3 pr-3 mb-5 bg-white rounded">
|
||||
<b-row class="mb-2">
|
||||
<b-col></b-col>
|
||||
</b-row>
|
||||
<slot :name="slotName" />
|
||||
<b-button size="sm" @click="$emit('row-toogle-details', row, index)">
|
||||
<b-button size="sm" @click="$emit('row-toggle-details', row, index)">
|
||||
<b-icon
|
||||
:icon="type === 'PageCreationConfirm' ? 'x' : 'eye-slash-fill'"
|
||||
aria-label="Help"
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
</b-button>
|
||||
</template>
|
||||
<template #cell(edit_creation)="row">
|
||||
<b-button variant="info" size="md" @click="rowToogleDetails(row, 0)" class="mr-2">
|
||||
<b-button variant="info" size="md" @click="rowToggleDetails(row, 0)" class="mr-2">
|
||||
<b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon>
|
||||
</b-button>
|
||||
</template>
|
||||
@ -27,7 +27,7 @@
|
||||
type="show-creation"
|
||||
slotName="show-creation"
|
||||
:index="0"
|
||||
@row-toogle-details="rowToogleDetails"
|
||||
@row-toggle-details="rowToggleDetails"
|
||||
>
|
||||
<template #show-creation>
|
||||
<div>
|
||||
|
||||
125
admin/src/components/Tables/SearchUserTable.spec.js
Normal file
125
admin/src/components/Tables/SearchUserTable.spec.js
Normal file
@ -0,0 +1,125 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SearchUserTable from './SearchUserTable.vue'
|
||||
|
||||
const date = new Date()
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue({})
|
||||
const apolloQueryMock = jest.fn().mockResolvedValue({})
|
||||
|
||||
const propsData = {
|
||||
items: [
|
||||
{
|
||||
userId: 1,
|
||||
firstName: 'Bibi',
|
||||
lastName: 'Bloxberg',
|
||||
email: 'bibi@bloxberg.de',
|
||||
creation: [200, 400, 600],
|
||||
emailChecked: true,
|
||||
},
|
||||
{
|
||||
userId: 2,
|
||||
firstName: 'Benjamin',
|
||||
lastName: 'Blümchen',
|
||||
email: 'benjamin@bluemchen.de',
|
||||
creation: [1000, 1000, 1000],
|
||||
emailChecked: true,
|
||||
},
|
||||
{
|
||||
userId: 3,
|
||||
firstName: 'Peter',
|
||||
lastName: 'Lustig',
|
||||
email: 'peter@lustig.de',
|
||||
creation: [0, 0, 0],
|
||||
emailChecked: true,
|
||||
},
|
||||
{
|
||||
userId: 4,
|
||||
firstName: 'New',
|
||||
lastName: 'User',
|
||||
email: 'new@user.ch',
|
||||
creation: [1000, 1000, 1000],
|
||||
emailChecked: false,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{ key: 'email', label: 'e_mail' },
|
||||
{ key: 'firstName', label: 'firstname' },
|
||||
{ key: 'lastName', label: 'lastname' },
|
||||
{
|
||||
key: 'creation',
|
||||
label: 'creationLabel',
|
||||
formatter: (value, key, item) => {
|
||||
return value.join(' | ')
|
||||
},
|
||||
},
|
||||
{ key: 'status', label: 'status' },
|
||||
],
|
||||
}
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$apollo: {
|
||||
mutate: apolloMutateMock,
|
||||
query: apolloQueryMock,
|
||||
},
|
||||
$store: {
|
||||
state: {
|
||||
moderator: {
|
||||
id: 0,
|
||||
name: 'test moderator',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
describe('SearchUserTable', () => {
|
||||
let wrapper
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(SearchUserTable, { localVue, mocks, propsData })
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('has a table with four rows', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(4)
|
||||
})
|
||||
|
||||
describe('show row details', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('tbody > tr').at(1).trigger('click')
|
||||
})
|
||||
|
||||
describe('deleted at', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('div.deleted-user-formular').vm.$emit('updateDeletedAt', {
|
||||
userId: 1,
|
||||
deletedAt: date,
|
||||
})
|
||||
})
|
||||
|
||||
it('emits updateDeletedAt', () => {
|
||||
expect(wrapper.emitted('updateDeletedAt')).toEqual([[1, date]])
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUserData', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper
|
||||
.find('div.component-creation-formular')
|
||||
.vm.$emit('update-user-data', propsData.items[1], [250, 500, 750])
|
||||
})
|
||||
|
||||
it('updates the item', () => {
|
||||
expect(wrapper.vm.items[1].creation).toEqual([250, 500, 750])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,100 +1,97 @@
|
||||
<template>
|
||||
<div class="search-user-table">
|
||||
<b-table-lite :items="items" :fields="fields" caption-top striped hover stacked="md">
|
||||
<b-table
|
||||
tbody-tr-class="pointer"
|
||||
:items="myItems"
|
||||
:fields="fields"
|
||||
caption-top
|
||||
striped
|
||||
hover
|
||||
stacked="md"
|
||||
select-mode="single"
|
||||
selectableonRowSelected
|
||||
@row-clicked="onRowClicked"
|
||||
>
|
||||
<template #cell(creation)="data">
|
||||
<div v-html="data.value"></div>
|
||||
</template>
|
||||
<template #cell(show_details)="row">
|
||||
<b-button
|
||||
variant="info"
|
||||
size="md"
|
||||
v-if="row.item.emailChecked"
|
||||
@click="rowToogleDetails(row, 0)"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon :icon="row.detailsShowing ? 'eye-slash-fill' : 'eye-fill'"></b-icon>
|
||||
</b-button>
|
||||
</template>
|
||||
<template #cell(confirm_mail)="row">
|
||||
<b-button
|
||||
:variant="row.item.emailChecked ? 'success' : 'danger'"
|
||||
size="md"
|
||||
@click="rowToogleDetails(row, 1)"
|
||||
class="mr-2"
|
||||
>
|
||||
|
||||
<template #cell(status)="row">
|
||||
<div class="text-right">
|
||||
<b-avatar v-if="row.item.deletedAt" class="mr-3" variant="light">
|
||||
<b-iconstack font-scale="2">
|
||||
<b-icon stacked icon="person" variant="info" scale="0.75"></b-icon>
|
||||
<b-icon stacked icon="slash-circle" variant="danger"></b-icon>
|
||||
</b-iconstack>
|
||||
</b-avatar>
|
||||
<span v-if="!row.item.deletedAt">
|
||||
<b-avatar
|
||||
v-if="!row.item.emailChecked"
|
||||
icon="envelope"
|
||||
class="align-center mr-3"
|
||||
variant="danger"
|
||||
></b-avatar>
|
||||
|
||||
<b-avatar
|
||||
v-if="!row.item.hasElopage"
|
||||
variant="danger"
|
||||
class="mr-3"
|
||||
src="img/elopage_favicon.png"
|
||||
></b-avatar>
|
||||
</span>
|
||||
<b-icon
|
||||
:icon="row.item.emailChecked ? 'envelope-open' : 'envelope'"
|
||||
aria-label="Help"
|
||||
variant="dark"
|
||||
:icon="row.detailsShowing ? 'caret-up-fill' : 'caret-down'"
|
||||
:title="row.item.enabled ? $t('enabled') : $t('deleted')"
|
||||
></b-icon>
|
||||
</b-button>
|
||||
</template>
|
||||
<template #cell(has_elopage)="row">
|
||||
<b-icon
|
||||
:variant="row.item.hasElopage ? 'success' : 'danger'"
|
||||
:icon="row.item.hasElopage ? 'check-circle' : 'x-circle'"
|
||||
></b-icon>
|
||||
</template>
|
||||
<template #cell(transactions_list)="row">
|
||||
<b-button variant="warning" size="md" @click="rowToogleDetails(row, 2)" class="mr-2">
|
||||
<b-icon icon="list"></b-icon>
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #row-details="row">
|
||||
<row-details
|
||||
:row="row"
|
||||
type="singleCreation"
|
||||
:slotName="slotName"
|
||||
:index="slotIndex"
|
||||
@row-toogle-details="rowToogleDetails"
|
||||
>
|
||||
<template #show-creation>
|
||||
<div>
|
||||
<creation-formular
|
||||
type="singleCreation"
|
||||
pagetype="singleCreation"
|
||||
:creation="row.item.creation"
|
||||
:item="row.item"
|
||||
:creationUserData="creationUserData"
|
||||
@update-user-data="updateUserData"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #show-register-mail>
|
||||
<confirm-register-mail-formular
|
||||
:checked="row.item.emailChecked"
|
||||
:email="row.item.email"
|
||||
:dateLastSend="
|
||||
row.item.emailConfirmationSend
|
||||
? $d(new Date(row.item.emailConfirmationSend), 'long')
|
||||
: ''
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #show-transaction-list>
|
||||
<creation-transaction-list-formular :userId="row.item.userId" />
|
||||
</template>
|
||||
</row-details>
|
||||
<b-card ref="rowDetails" class="shadow-lg pl-3 pr-3 mb-5 bg-white rounded">
|
||||
<creation-formular
|
||||
v-if="!row.item.deletedAt"
|
||||
type="singleCreation"
|
||||
pagetype="singleCreation"
|
||||
:creation="row.item.creation"
|
||||
:item="row.item"
|
||||
:creationUserData="creationUserData"
|
||||
@update-user-data="updateUserData"
|
||||
/>
|
||||
<div v-else>{{ $t('userIsDeleted') }}</div>
|
||||
<confirm-register-mail-formular
|
||||
v-if="!row.item.deletedAt"
|
||||
:checked="row.item.emailChecked"
|
||||
:email="row.item.email"
|
||||
:dateLastSend="
|
||||
row.item.emailConfirmationSend
|
||||
? $d(new Date(row.item.emailConfirmationSend), 'long')
|
||||
: ''
|
||||
"
|
||||
/>
|
||||
<creation-transaction-list-formular
|
||||
v-if="!row.item.deletedAt"
|
||||
:userId="row.item.userId"
|
||||
/>
|
||||
<deleted-user-formular :item="row.item" @updateDeletedAt="updateDeletedAt" />
|
||||
</b-card>
|
||||
</template>
|
||||
</b-table-lite>
|
||||
</b-table>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import CreationFormular from '../CreationFormular.vue'
|
||||
import ConfirmRegisterMailFormular from '../ConfirmRegisterMailFormular.vue'
|
||||
import RowDetails from '../RowDetails.vue'
|
||||
import CreationTransactionListFormular from '../CreationTransactionListFormular.vue'
|
||||
import { toggleRowDetails } from '../../mixins/toggleRowDetails'
|
||||
|
||||
const slotNames = ['show-creation', 'show-register-mail', 'show-transaction-list']
|
||||
import DeletedUserFormular from '../DeletedUserFormular.vue'
|
||||
|
||||
export default {
|
||||
name: 'SearchUserTable',
|
||||
mixins: [toggleRowDetails],
|
||||
components: {
|
||||
CreationFormular,
|
||||
ConfirmRegisterMailFormular,
|
||||
CreationTransactionListFormular,
|
||||
RowDetails,
|
||||
DeletedUserFormular,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
@ -115,10 +112,29 @@ export default {
|
||||
updateUserData(rowItem, newCreation) {
|
||||
rowItem.creation = newCreation
|
||||
},
|
||||
updateDeletedAt({ userId, deletedAt }) {
|
||||
this.$emit('updateDeletedAt', userId, deletedAt)
|
||||
},
|
||||
async onRowClicked(item) {
|
||||
const status = this.myItems.find((obj) => obj === item)._showDetails
|
||||
this.myItems.forEach((obj) => {
|
||||
if (obj === item) {
|
||||
obj._showDetails = !status
|
||||
} else {
|
||||
obj._showDetails = false
|
||||
}
|
||||
})
|
||||
await this.$nextTick()
|
||||
if (!status && this.$refs.rowDetails) {
|
||||
this.$refs.rowDetails.focus()
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
slotName() {
|
||||
return slotNames[this.slotIndex]
|
||||
myItems() {
|
||||
return this.items.map((item) => {
|
||||
return { ...item, _showDetails: false }
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
7
admin/src/graphql/deleteUser.js
Normal file
7
admin/src/graphql/deleteUser.js
Normal file
@ -0,0 +1,7 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const deleteUser = gql`
|
||||
mutation ($userId: Float!) {
|
||||
deleteUser(userId: $userId)
|
||||
}
|
||||
`
|
||||
@ -1,12 +1,19 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const searchUsers = gql`
|
||||
query ($searchText: String!, $currentPage: Int, $pageSize: Int, $notActivated: Boolean) {
|
||||
query (
|
||||
$searchText: String!
|
||||
$currentPage: Int
|
||||
$pageSize: Int
|
||||
$notActivated: Boolean
|
||||
$isDeleted: Boolean
|
||||
) {
|
||||
searchUsers(
|
||||
searchText: $searchText
|
||||
currentPage: $currentPage
|
||||
pageSize: $pageSize
|
||||
notActivated: $notActivated
|
||||
isDeleted: $isDeleted
|
||||
) {
|
||||
userCount
|
||||
userList {
|
||||
@ -18,6 +25,7 @@ export const searchUsers = gql`
|
||||
emailChecked
|
||||
hasElopage
|
||||
emailConfirmationSend
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,28 +15,15 @@ export const transactionList = gql`
|
||||
onlyCreations: $onlyCreations
|
||||
userId: $userId
|
||||
) {
|
||||
gdtSum
|
||||
count
|
||||
balance
|
||||
decay
|
||||
decayDate
|
||||
transactions {
|
||||
type
|
||||
balance
|
||||
decayStart
|
||||
decayEnd
|
||||
decayDuration
|
||||
id
|
||||
amount
|
||||
balanceDate
|
||||
creationDate
|
||||
memo
|
||||
transactionId
|
||||
name
|
||||
email
|
||||
date
|
||||
decay {
|
||||
balance
|
||||
decayStart
|
||||
decayEnd
|
||||
decayDuration
|
||||
decayStartBlock
|
||||
linkedUser {
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7
admin/src/graphql/unDeleteUser.js
Normal file
7
admin/src/graphql/unDeleteUser.js
Normal file
@ -0,0 +1,7 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const unDeleteUser = gql`
|
||||
mutation ($userId: Float!) {
|
||||
unDeleteUser(userId: $userId)
|
||||
}
|
||||
`
|
||||
@ -5,6 +5,7 @@
|
||||
"confirmed": "bestätigt",
|
||||
"creation": "Schöpfung",
|
||||
"creation_form": {
|
||||
"creation_failed": "Ausstehende Schöpfung für {email} konnte nicht erzeugt werden.",
|
||||
"creation_for": "Aktives Grundeinkommen für",
|
||||
"enter_text": "Text eintragen",
|
||||
"form": "Schöpfungsformular",
|
||||
@ -23,12 +24,16 @@
|
||||
"creation_for_month": "Schöpfung für Monat",
|
||||
"date": "Datum",
|
||||
"delete": "Löschen",
|
||||
"deleted": "gelöscht",
|
||||
"deleted_user": "Alle gelöschten Nutzer",
|
||||
"delete_user": "Nutzer löschen",
|
||||
"details": "Details",
|
||||
"edit": "Bearbeiten",
|
||||
"error": "Fehler",
|
||||
"e_mail": "E-Mail",
|
||||
"firstname": "Vorname",
|
||||
"gradido_admin_footer": "Gradido Akademie Adminkonsole",
|
||||
"hide_details": "Details verbergen von",
|
||||
"hide_details": "Details verbergen",
|
||||
"lastname": "Nachname",
|
||||
"moderator": "Moderator",
|
||||
"multiple_creation_text": "Bitte wähle ein oder mehrere Mitglieder aus für die du Schöpfen möchtest.",
|
||||
@ -61,18 +66,23 @@
|
||||
}
|
||||
},
|
||||
"remove": "Entfernen",
|
||||
"removeNotSelf": "Als Admin / Moderator kannst du dich nicht selber löschen.",
|
||||
"remove_all": "alle Nutzer entfernen",
|
||||
"save": "Speichern",
|
||||
"status": "Status",
|
||||
"success": "Erfolg",
|
||||
"text": "Text",
|
||||
"transaction": "Transaktion",
|
||||
"transactionlist": {
|
||||
"amount": "Betrag",
|
||||
"balanceDate": "Schöpfungsdatum",
|
||||
"community": "Gemeinschaft",
|
||||
"date": "Datum",
|
||||
"decay": "Vergänglichkeit",
|
||||
"memo": "Nachricht",
|
||||
"title": "Alle geschöpften Transaktionen für den Nutzer"
|
||||
},
|
||||
"undelete_user": "Nutzer wiederherstellen",
|
||||
"unregistered_emails": "Nur unregistrierte Nutzer",
|
||||
"unregister_mail": {
|
||||
"button": "Registrierungs-Email bestätigen, jetzt senden",
|
||||
@ -83,5 +93,8 @@
|
||||
"text_false": " Die letzte Email wurde am {date} Uhr an das Mitglied ({email}) gesendet.",
|
||||
"text_true": " Die Email wurde bestätigt."
|
||||
},
|
||||
"userIsDeleted": "Der Nutzer ist gelöscht. Es können keine GDD mehr geschöpft werden.",
|
||||
"user_deleted": "Nutzer ist gelöscht.",
|
||||
"user_recovered": "Nutzer ist wiederhergestellt.",
|
||||
"user_search": "Nutzer-Suche"
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"confirmed": "confirmed",
|
||||
"creation": "Creation",
|
||||
"creation_form": {
|
||||
"creation_failed": "Could not create pending creation for {email}",
|
||||
"creation_for": "Active Basic Income for",
|
||||
"enter_text": "Enter text",
|
||||
"form": "Creation form",
|
||||
@ -23,12 +24,16 @@
|
||||
"creation_for_month": "Creation for month",
|
||||
"date": "Date",
|
||||
"delete": "Delete",
|
||||
"deleted": "deleted",
|
||||
"deleted_user": "All deleted user",
|
||||
"delete_user": "Delete user",
|
||||
"details": "Details",
|
||||
"edit": "Edit",
|
||||
"error": "Error",
|
||||
"e_mail": "E-mail",
|
||||
"firstname": "Firstname",
|
||||
"gradido_admin_footer": "Gradido Academy Admin Console",
|
||||
"hide_details": "Hide details from",
|
||||
"hide_details": "Hide details",
|
||||
"lastname": "Lastname",
|
||||
"moderator": "Moderator",
|
||||
"multiple_creation_text": "Please select one or more members for which you would like to perform creations.",
|
||||
@ -61,18 +66,23 @@
|
||||
}
|
||||
},
|
||||
"remove": "Remove",
|
||||
"removeNotSelf": "As admin / moderator you cannot delete yourself.",
|
||||
"remove_all": "Remove all users",
|
||||
"save": "Speichern",
|
||||
"status": "Status",
|
||||
"success": "Success",
|
||||
"text": "Text",
|
||||
"transaction": "Transaction",
|
||||
"transactionlist": {
|
||||
"amount": "Amount",
|
||||
"balanceDate": "Creation date",
|
||||
"community": "Community",
|
||||
"date": "Date",
|
||||
"decay": "Decay",
|
||||
"memo": "Message",
|
||||
"title": "All creation-transactions for the user"
|
||||
},
|
||||
"undelete_user": "Undelete User",
|
||||
"unregistered_emails": "Only unregistered users",
|
||||
"unregister_mail": {
|
||||
"button": "Confirm registration email, send now",
|
||||
@ -83,5 +93,8 @@
|
||||
"text_false": "The last email was sent to the member ({email}) on {date}.",
|
||||
"text_true": "The email was confirmed."
|
||||
},
|
||||
"userIsDeleted": "The user is deleted. No more GDD can be created.",
|
||||
"user_deleted": "User is deleted.",
|
||||
"user_recovered": "User is recovered.",
|
||||
"user_search": "User search"
|
||||
}
|
||||
|
||||
@ -13,31 +13,24 @@ import i18n from './i18n'
|
||||
|
||||
import VueApollo from 'vue-apollo'
|
||||
|
||||
import PortalVue from 'portal-vue'
|
||||
|
||||
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
|
||||
import 'bootstrap/dist/css/bootstrap.css'
|
||||
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||
|
||||
import Toasted from 'vue-toasted'
|
||||
import { toasters } from './mixins/toaster'
|
||||
|
||||
import { apolloProvider } from './plugins/apolloProvider'
|
||||
|
||||
Vue.use(PortalVue)
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
Vue.use(IconsPlugin)
|
||||
|
||||
Vue.use(VueApollo)
|
||||
|
||||
Vue.use(Toasted, {
|
||||
position: 'top-center',
|
||||
duration: 5000,
|
||||
fullWidth: true,
|
||||
action: {
|
||||
text: 'x',
|
||||
onClick: (e, toastObject) => {
|
||||
toastObject.goAway(0)
|
||||
},
|
||||
},
|
||||
})
|
||||
Vue.mixin(toasters)
|
||||
|
||||
addNavigationGuards(router, store, apolloProvider.defaultClient, i18n)
|
||||
|
||||
|
||||
30
admin/src/mixins/toaster.js
Normal file
30
admin/src/mixins/toaster.js
Normal file
@ -0,0 +1,30 @@
|
||||
export const toasters = {
|
||||
methods: {
|
||||
toastSuccess(message) {
|
||||
this.toast(message, {
|
||||
title: this.$t('success'),
|
||||
variant: 'success',
|
||||
})
|
||||
},
|
||||
toastError(message) {
|
||||
this.toast(message, {
|
||||
title: this.$t('error'),
|
||||
variant: 'danger',
|
||||
})
|
||||
},
|
||||
toast(message, options) {
|
||||
// for unit tests, check that replace is present
|
||||
if (message.replace) message = message.replace(/^GraphQL error: /, '')
|
||||
this.$bvToast.toast(message, {
|
||||
autoHideDelay: 5000,
|
||||
appendToast: true,
|
||||
solid: true,
|
||||
toaster: 'b-toaster-top-right',
|
||||
headerClass: 'gdd-toaster-title',
|
||||
bodyClass: 'gdd-toaster-body',
|
||||
toastClass: 'gdd-toaster',
|
||||
...options,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -7,7 +7,7 @@ export const toggleRowDetails = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
rowToogleDetails(row, index) {
|
||||
rowToggleDetails(row, index) {
|
||||
if (this.openRow) {
|
||||
if (this.openRow.index === row.index) {
|
||||
if (index === this.slotIndex) {
|
||||
|
||||
@ -35,7 +35,7 @@ describe('toggleRowDetails', () => {
|
||||
|
||||
describe('no open row', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.rowToogleDetails(row, 2)
|
||||
wrapper.vm.rowToggleDetails(row, 2)
|
||||
})
|
||||
|
||||
it('calls toggleDetails', () => {
|
||||
@ -70,7 +70,7 @@ describe('toggleRowDetails', () => {
|
||||
describe('row index is open row index', () => {
|
||||
describe('index is slot index', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.rowToogleDetails(row, 0)
|
||||
wrapper.vm.rowToggleDetails(row, 0)
|
||||
})
|
||||
|
||||
it('calls toggleDetails', () => {
|
||||
@ -84,7 +84,7 @@ describe('toggleRowDetails', () => {
|
||||
|
||||
describe('index is not slot index', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.rowToogleDetails(row, 2)
|
||||
wrapper.vm.rowToggleDetails(row, 2)
|
||||
})
|
||||
|
||||
it('does not call toggleDetails', () => {
|
||||
@ -99,7 +99,7 @@ describe('toggleRowDetails', () => {
|
||||
|
||||
describe('row index is not open row index', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.rowToogleDetails(
|
||||
wrapper.vm.rowToggleDetails(
|
||||
{
|
||||
toggleDetails: secondToggleDetailsMock,
|
||||
index: 2,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Creation from './Creation.vue'
|
||||
import { toastErrorSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
@ -29,18 +30,14 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
},
|
||||
})
|
||||
|
||||
const toastErrorMock = jest.fn()
|
||||
const storeCommitMock = jest.fn()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$t: jest.fn((t, options) => (options ? [t, options] : t)),
|
||||
$d: jest.fn((d) => d),
|
||||
$apollo: {
|
||||
query: apolloQueryMock,
|
||||
},
|
||||
$toasted: {
|
||||
error: toastErrorMock,
|
||||
},
|
||||
$store: {
|
||||
commit: storeCommitMock,
|
||||
state: {
|
||||
@ -236,6 +233,25 @@ describe('Creation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('failed creations', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper
|
||||
.findComponent({ name: 'CreationFormular' })
|
||||
.vm.$emit('toast-failed-creations', ['bibi@bloxberg.de', 'benjamin@bluemchen.de'])
|
||||
})
|
||||
|
||||
it('toasts two error messages', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith([
|
||||
'creation_form.creation_failed',
|
||||
{ email: 'bibi@bloxberg.de' },
|
||||
])
|
||||
expect(toastErrorSpy).toBeCalledWith([
|
||||
'creation_form.creation_failed',
|
||||
{ email: 'benjamin@bluemchen.de' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
@ -298,7 +314,7 @@ describe('Creation', () => {
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorMock).toBeCalledWith('Ouch')
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouch')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -56,6 +56,7 @@
|
||||
:creation="creation"
|
||||
:items="itemsMassCreation"
|
||||
@remove-all-bookmark="removeAllBookmarks"
|
||||
@toast-failed-creations="toastFailedCreations"
|
||||
/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
@ -118,7 +119,7 @@ export default {
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
pushItem(selectedItem) {
|
||||
@ -144,6 +145,11 @@ export default {
|
||||
this.$store.commit('setUserSelectedInMassCreation', [])
|
||||
this.getUsers()
|
||||
},
|
||||
toastFailedCreations(failedCreations) {
|
||||
failedCreations.forEach((email) =>
|
||||
this.toastError(this.$t('creation_form.creation_failed', { email })),
|
||||
)
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
Searchfields() {
|
||||
|
||||
@ -2,12 +2,11 @@ import { mount } from '@vue/test-utils'
|
||||
import CreationConfirm from './CreationConfirm.vue'
|
||||
import { deletePendingCreation } from '../graphql/deletePendingCreation'
|
||||
import { confirmPendingCreation } from '../graphql/confirmPendingCreation'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const storeCommitMock = jest.fn()
|
||||
const toastedErrorMock = jest.fn()
|
||||
const toastedSuccessMock = jest.fn()
|
||||
const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
data: {
|
||||
getPendingCreations: [
|
||||
@ -47,10 +46,6 @@ const mocks = {
|
||||
query: apolloQueryMock,
|
||||
mutate: apolloMutateMock,
|
||||
},
|
||||
$toasted: {
|
||||
error: toastedErrorMock,
|
||||
success: toastedSuccessMock,
|
||||
},
|
||||
}
|
||||
|
||||
describe('CreationConfirm', () => {
|
||||
@ -101,7 +96,7 @@ describe('CreationConfirm', () => {
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_delete')
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
|
||||
})
|
||||
})
|
||||
|
||||
@ -112,7 +107,7 @@ describe('CreationConfirm', () => {
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastedErrorMock).toBeCalledWith('Ouchhh!')
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
|
||||
})
|
||||
})
|
||||
|
||||
@ -158,7 +153,7 @@ describe('CreationConfirm', () => {
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_created')
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_created')
|
||||
})
|
||||
|
||||
it('has 1 item left in the table', () => {
|
||||
@ -173,7 +168,7 @@ describe('CreationConfirm', () => {
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastedErrorMock).toBeCalledWith('Ouchhh!')
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -189,7 +184,7 @@ describe('CreationConfirm', () => {
|
||||
})
|
||||
|
||||
it('toast an error message', () => {
|
||||
expect(toastedErrorMock).toBeCalledWith('Ouch!')
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouch!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -43,10 +43,10 @@ export default {
|
||||
})
|
||||
.then((result) => {
|
||||
this.updatePendingCreations(item.id)
|
||||
this.$toasted.success(this.$t('creation_form.toasted_delete'))
|
||||
this.toastSuccess(this.$t('creation_form.toasted_delete'))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
confirmCreation() {
|
||||
@ -60,11 +60,11 @@ export default {
|
||||
.then((result) => {
|
||||
this.overlay = false
|
||||
this.updatePendingCreations(this.item.id)
|
||||
this.$toasted.success(this.$t('creation_form.toasted_created'))
|
||||
this.toastSuccess(this.$t('creation_form.toasted_created'))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.overlay = false
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
getPendingCreations() {
|
||||
@ -79,7 +79,7 @@ export default {
|
||||
this.$store.commit('setOpenCreations', result.data.getPendingCreations.length)
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
updatePendingCreations(id) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import UserSearch from './UserSearch.vue'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
@ -15,6 +16,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
email: 'bibi@bloxberg.de',
|
||||
creation: [200, 400, 600],
|
||||
emailChecked: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
userId: 2,
|
||||
@ -23,6 +25,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
email: 'benjamin@bluemchen.de',
|
||||
creation: [1000, 1000, 1000],
|
||||
emailChecked: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
userId: 3,
|
||||
@ -31,6 +34,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
email: 'peter@lustig.de',
|
||||
creation: [0, 0, 0],
|
||||
emailChecked: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
{
|
||||
userId: 4,
|
||||
@ -39,23 +43,19 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
|
||||
email: 'new@user.ch',
|
||||
creation: [1000, 1000, 1000],
|
||||
emailChecked: false,
|
||||
deletedAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const toastErrorMock = jest.fn()
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => String(d)),
|
||||
$apollo: {
|
||||
query: apolloQueryMock,
|
||||
},
|
||||
$toasted: {
|
||||
error: toastErrorMock,
|
||||
},
|
||||
}
|
||||
|
||||
describe('UserSearch', () => {
|
||||
@ -83,6 +83,7 @@ describe('UserSearch', () => {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
notActivated: false,
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ -90,7 +91,7 @@ describe('UserSearch', () => {
|
||||
|
||||
describe('unconfirmed emails', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('button.btn-block').trigger('click')
|
||||
await wrapper.find('button.unconfirmedRegisterMails').trigger('click')
|
||||
})
|
||||
|
||||
it('calls API with filter', () => {
|
||||
@ -101,6 +102,27 @@ describe('UserSearch', () => {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
notActivated: true,
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleted Users', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('button.deletedUserSearch').trigger('click')
|
||||
})
|
||||
|
||||
it('calls API with filter', () => {
|
||||
expect(apolloQueryMock).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: {
|
||||
searchText: '',
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
notActivated: false,
|
||||
isDeleted: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ -120,6 +142,7 @@ describe('UserSearch', () => {
|
||||
currentPage: 2,
|
||||
pageSize: 25,
|
||||
notActivated: false,
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ -139,6 +162,7 @@ describe('UserSearch', () => {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
notActivated: false,
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ -155,6 +179,7 @@ describe('UserSearch', () => {
|
||||
currentPage: 1,
|
||||
pageSize: 25,
|
||||
notActivated: false,
|
||||
isDeleted: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ -162,6 +187,21 @@ describe('UserSearch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete user', () => {
|
||||
const now = new Date()
|
||||
beforeEach(async () => {
|
||||
wrapper.findComponent({ name: 'SearchUserTable' }).vm.$emit('updateDeletedAt', 4, now)
|
||||
})
|
||||
|
||||
it('marks the user as deleted', () => {
|
||||
expect(wrapper.vm.searchResult.find((obj) => obj.userId === 4).deletedAt).toEqual(now)
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('user_deleted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('apollo returns error', () => {
|
||||
beforeEach(() => {
|
||||
apolloQueryMock.mockRejectedValue({
|
||||
@ -171,7 +211,7 @@ describe('UserSearch', () => {
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorMock).toBeCalledWith('Ouch')
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouch')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
<template>
|
||||
<div class="user-search">
|
||||
<div style="text-align: right">
|
||||
<b-button block variant="danger" @click="unconfirmedRegisterMails">
|
||||
<b-icon icon="envelope" variant="light"></b-icon>
|
||||
<b-button class="unconfirmedRegisterMails" variant="light" @click="unconfirmedRegisterMails">
|
||||
<b-icon icon="envelope" variant="danger"></b-icon>
|
||||
{{ filterCheckedEmails ? $t('all_emails') : $t('unregistered_emails') }}
|
||||
</b-button>
|
||||
<b-button class="deletedUserSearch" variant="light" @click="deletedUserSearch">
|
||||
<b-icon icon="x-circle" variant="danger"></b-icon>
|
||||
{{ filterDeletedUser ? $t('all_emails') : $t('deleted_user') }}
|
||||
</b-button>
|
||||
</div>
|
||||
<label>{{ $t('user_search') }}</label>
|
||||
<div>
|
||||
@ -22,7 +26,12 @@
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</div>
|
||||
<search-user-table type="PageUserSearch" :items="searchResult" :fields="fields" />
|
||||
<search-user-table
|
||||
type="PageUserSearch"
|
||||
:items="searchResult"
|
||||
:fields="fields"
|
||||
@updateDeletedAt="updateDeletedAt"
|
||||
/>
|
||||
<b-pagination
|
||||
pills
|
||||
size="lg"
|
||||
@ -52,6 +61,7 @@ export default {
|
||||
massCreation: [],
|
||||
criteria: '',
|
||||
filterCheckedEmails: false,
|
||||
filterDeletedUser: false,
|
||||
rows: 0,
|
||||
currentPage: 1,
|
||||
perPage: 25,
|
||||
@ -63,6 +73,10 @@ export default {
|
||||
this.filterCheckedEmails = !this.filterCheckedEmails
|
||||
this.getUsers()
|
||||
},
|
||||
deletedUserSearch() {
|
||||
this.filterDeletedUser = !this.filterDeletedUser
|
||||
this.getUsers()
|
||||
},
|
||||
getUsers() {
|
||||
this.$apollo
|
||||
.query({
|
||||
@ -72,6 +86,7 @@ export default {
|
||||
currentPage: this.currentPage,
|
||||
pageSize: this.perPage,
|
||||
notActivated: this.filterCheckedEmails,
|
||||
isDeleted: this.filterDeletedUser,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
@ -79,9 +94,13 @@ export default {
|
||||
this.searchResult = result.data.searchUsers.userList
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toasted.error(error.message)
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
updateDeletedAt(userId, deletedAt) {
|
||||
this.searchResult.find((obj) => obj.userId === userId).deletedAt = deletedAt
|
||||
this.toastSuccess(this.$t('user_deleted'))
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentPage() {
|
||||
@ -104,10 +123,11 @@ export default {
|
||||
return value.join(' | ')
|
||||
},
|
||||
},
|
||||
{ key: 'show_details', label: this.$t('details') },
|
||||
{ key: 'confirm_mail', label: this.$t('confirmed') },
|
||||
{ key: 'has_elopage', label: 'elopage' },
|
||||
{ key: 'transactions_list', label: this.$t('transaction') },
|
||||
// { key: 'show_details', label: this.$t('details') },
|
||||
// { key: 'confirm_mail', label: this.$t('confirmed') },
|
||||
// { key: 'has_elopage', label: 'elopage' },
|
||||
// { key: 'transactions_list', label: this.$t('transaction') },
|
||||
{ key: 'status', label: this.$t('status') },
|
||||
]
|
||||
},
|
||||
},
|
||||
|
||||
@ -5,11 +5,18 @@ import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
|
||||
// without this async calls are not working
|
||||
import 'regenerator-runtime'
|
||||
|
||||
import { toasters } from '../src/mixins/toaster'
|
||||
|
||||
export const toastErrorSpy = jest.spyOn(toasters.methods, 'toastError')
|
||||
export const toastSuccessSpy = jest.spyOn(toasters.methods, 'toastSuccess')
|
||||
|
||||
global.localVue = createLocalVue()
|
||||
|
||||
global.localVue.use(BootstrapVue)
|
||||
global.localVue.use(IconsPlugin)
|
||||
|
||||
global.localVue.mixin(toasters)
|
||||
|
||||
// throw errors for vue warnings to force the programmers to take care about warnings
|
||||
Vue.config.warnHandler = (w) => {
|
||||
throw new Error(w)
|
||||
|
||||
@ -12512,11 +12512,6 @@ vue-template-es2015-compiler@^1.6.0, vue-template-es2015-compiler@^1.9.0:
|
||||
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
|
||||
integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
|
||||
|
||||
vue-toasted@^1.1.28:
|
||||
version "1.1.28"
|
||||
resolved "https://registry.yarnpkg.com/vue-toasted/-/vue-toasted-1.1.28.tgz#dbabb83acc89f7a9e8765815e491d79f0dc65c26"
|
||||
integrity sha512-UUzr5LX51UbbiROSGZ49GOgSzFxaMHK6L00JV8fir/CYNJCpIIvNZ5YmS4Qc8Y2+Z/4VVYRpeQL2UO0G800Raw==
|
||||
|
||||
vue@^2.6.11:
|
||||
version "2.6.14"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235"
|
||||
|
||||
@ -6,6 +6,12 @@ module.exports = {
|
||||
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'],
|
||||
setupFiles: ['<rootDir>/test/testSetup.ts'],
|
||||
moduleNameMapper: {
|
||||
'@/(.*)': '<rootDir>/src/$1',
|
||||
'@model/(.*)': '<rootDir>/src/graphql/model/$1',
|
||||
'@arg/(.*)': '<rootDir>/src/graphql/arg/$1',
|
||||
'@enum/(.*)': '<rootDir>/src/graphql/enum/$1',
|
||||
'@repository/(.*)': '<rootDir>/src/typeorm/repository/$1',
|
||||
'@test/(.*)': '<rootDir>/test/$1',
|
||||
'@entity/(.*)':
|
||||
process.env.NODE_ENV === 'development'
|
||||
? '<rootDir>/../database/entity/$1'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido-backend",
|
||||
"version": "1.6.5",
|
||||
"version": "1.6.6",
|
||||
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/gradido/gradido/backend",
|
||||
@ -12,7 +12,7 @@
|
||||
"clean": "tsc --build --clean",
|
||||
"start": "node build/src/index.js",
|
||||
"dev": "nodemon -w src --ext ts --exec ts-node src/index.ts",
|
||||
"lint": "eslint . --ext .js,.ts",
|
||||
"lint": "eslint --max-warnings=0 --ext .js,.ts .",
|
||||
"test": "TZ=UTC NODE_ENV=development jest --runInBand --coverage --forceExit --detectOpenHandles"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -24,6 +24,7 @@
|
||||
"axios": "^0.21.1",
|
||||
"class-validator": "^0.13.1",
|
||||
"cors": "^2.8.5",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"dotenv": "^10.0.0",
|
||||
"express": "^4.17.1",
|
||||
"graphql": "^15.5.1",
|
||||
@ -59,7 +60,12 @@
|
||||
"typescript": "^4.3.4"
|
||||
},
|
||||
"_moduleAliases": {
|
||||
"@": "./src",
|
||||
"@arg": "./src/graphql/arg",
|
||||
"@dbTools": "../database/build/src",
|
||||
"@entity": "../database/build/entity",
|
||||
"@dbTools": "../database/build/src"
|
||||
"@enum": "./src/graphql/enum",
|
||||
"@model": "./src/graphql/model",
|
||||
"@repository": "./src/typeorm/repository"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { KlicktippConnector } from './klicktippConnector'
|
||||
import CONFIG from '../config'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
const klicktippConnector = new KlicktippConnector()
|
||||
|
||||
|
||||
@ -7,4 +7,5 @@ export const INALIENABLE_RIGHTS = [
|
||||
RIGHTS.CREATE_USER,
|
||||
RIGHTS.SEND_RESET_PASSWORD_EMAIL,
|
||||
RIGHTS.SET_PASSWORD,
|
||||
RIGHTS.QUERY_TRANSACTION_LINK,
|
||||
]
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import CONFIG from '../config/'
|
||||
import CONFIG from '@/config/'
|
||||
import { CustomJwtPayload } from './CustomJwtPayload'
|
||||
|
||||
export const decode = (token: string): CustomJwtPayload | null => {
|
||||
|
||||
@ -18,6 +18,9 @@ export enum RIGHTS {
|
||||
SET_PASSWORD = 'SET_PASSWORD',
|
||||
UPDATE_USER_INFOS = 'UPDATE_USER_INFOS',
|
||||
HAS_ELOPAGE = 'HAS_ELOPAGE',
|
||||
CREATE_TRANSACTION_LINK = 'CREATE_TRANSACTION_LINK',
|
||||
QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK',
|
||||
|
||||
// Admin
|
||||
SEARCH_USERS = 'SEARCH_USERS',
|
||||
CREATE_PENDING_CREATION = 'CREATE_PENDING_CREATION',
|
||||
@ -26,4 +29,6 @@ export enum RIGHTS {
|
||||
DELETE_PENDING_CREATION = 'DELETE_PENDING_CREATION',
|
||||
CONFIRM_PENDING_CREATION = 'CONFIRM_PENDING_CREATION',
|
||||
SEND_ACTIVATION_EMAIL = 'SEND_ACTIVATION_EMAIL',
|
||||
DELETE_USER = 'DELETE_USER',
|
||||
UNDELETE_USER = 'UNDELETE_USER',
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ export const ROLE_USER = new Role('user', [
|
||||
RIGHTS.LOGOUT,
|
||||
RIGHTS.UPDATE_USER_INFOS,
|
||||
RIGHTS.HAS_ELOPAGE,
|
||||
RIGHTS.CREATE_TRANSACTION_LINK,
|
||||
])
|
||||
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights
|
||||
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
import Decimal from 'decimal.js-light'
|
||||
dotenv.config()
|
||||
|
||||
Decimal.set({
|
||||
precision: 25,
|
||||
rounding: Decimal.ROUND_HALF_UP,
|
||||
})
|
||||
|
||||
const constants = {
|
||||
DB_VERSION: '0024-combine_transaction_tables',
|
||||
DB_VERSION: '0030-transaction_link',
|
||||
DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0
|
||||
}
|
||||
|
||||
@ -59,7 +65,8 @@ const email = {
|
||||
EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT || '587',
|
||||
EMAIL_LINK_VERIFICATION:
|
||||
process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/checkEmail/{code}',
|
||||
EMAIL_LINK_SETPASSWORD: process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/reset/{code}',
|
||||
EMAIL_LINK_SETPASSWORD:
|
||||
process.env.EMAIL_LINK_SETPASSWORD || 'http://localhost/reset-password/{code}',
|
||||
RESEND_TIME: isNaN(resendTime) ? 10 : resendTime,
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { ArgsType, Field, Int } from 'type-graphql'
|
||||
import { Order } from '../enum/Order'
|
||||
import { Order } from '@enum/Order'
|
||||
|
||||
@ArgsType()
|
||||
export default class Paginated {
|
||||
|
||||
10
backend/src/graphql/arg/QueryTransactionLinkArgs.ts
Normal file
10
backend/src/graphql/arg/QueryTransactionLinkArgs.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ArgsType, Field, Int } from 'type-graphql'
|
||||
|
||||
@ArgsType()
|
||||
export default class QueryTransactionLinkArgs {
|
||||
@Field(() => String)
|
||||
code: string
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
redeemUserId?: number
|
||||
}
|
||||
@ -13,4 +13,7 @@ export default class SearchUsersArgs {
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
notActivated?: boolean
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
14
backend/src/graphql/arg/TransactionLinkArgs.ts
Normal file
14
backend/src/graphql/arg/TransactionLinkArgs.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ArgsType, Field } from 'type-graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
@ArgsType()
|
||||
export default class TransactionLinkArgs {
|
||||
@Field(() => Decimal)
|
||||
amount: Decimal
|
||||
|
||||
@Field(() => String)
|
||||
memo: string
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
showEmail?: boolean
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
import { ArgsType, Field } from 'type-graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
@ArgsType()
|
||||
export default class TransactionSendArgs {
|
||||
@Field(() => String)
|
||||
email: string
|
||||
|
||||
@Field(() => Number)
|
||||
amount: number
|
||||
@Field(() => Decimal)
|
||||
amount: Decimal
|
||||
|
||||
@Field(() => String)
|
||||
memo: string
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
|
||||
import { AuthChecker } from 'type-graphql'
|
||||
|
||||
import { decode, encode } from '../../auth/JWT'
|
||||
import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN } from '../../auth/ROLES'
|
||||
import { RIGHTS } from '../../auth/RIGHTS'
|
||||
import { decode, encode } from '@/auth/JWT'
|
||||
import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN } from '@/auth/ROLES'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
import { UserRepository } from '../../typeorm/repository/User'
|
||||
import { INALIENABLE_RIGHTS } from '../../auth/INALIENABLE_RIGHTS'
|
||||
import { UserRepository } from '@repository/User'
|
||||
import { INALIENABLE_RIGHTS } from '@/auth/INALIENABLE_RIGHTS'
|
||||
import { ServerUser } from '@entity/ServerUser'
|
||||
|
||||
const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
|
||||
|
||||
@ -3,6 +3,9 @@ import { registerEnumType } from 'type-graphql'
|
||||
export enum TransactionTypeId {
|
||||
CREATION = 1,
|
||||
SEND = 2,
|
||||
RECEIVE = 3,
|
||||
// This is a virtual property, never occurring on the database
|
||||
DECAY = 4,
|
||||
}
|
||||
|
||||
registerEnumType(TransactionTypeId, {
|
||||
|
||||
@ -1,21 +1,22 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
@ObjectType()
|
||||
export class Balance {
|
||||
constructor(json: any) {
|
||||
this.balance = Number(json.balance)
|
||||
this.decay = Number(json.decay)
|
||||
this.balance = json.balance
|
||||
this.decay = json.decay
|
||||
this.decayDate = json.decay_date
|
||||
}
|
||||
|
||||
@Field(() => Number)
|
||||
balance: number
|
||||
@Field(() => Decimal)
|
||||
balance: Decimal
|
||||
|
||||
@Field(() => Number)
|
||||
decay: number
|
||||
@Field(() => Decimal)
|
||||
decay: Decimal
|
||||
|
||||
@Field(() => String)
|
||||
decayDate: string
|
||||
@Field(() => Date)
|
||||
decayDate: Date
|
||||
}
|
||||
|
||||
@ -1,33 +1,34 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { ObjectType, Field, Int } from 'type-graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
@ObjectType()
|
||||
export class Decay {
|
||||
constructor(json?: any) {
|
||||
if (json) {
|
||||
this.balance = Number(json.balance)
|
||||
this.decayStart = json.decay_start
|
||||
this.decayEnd = json.decay_end
|
||||
this.decayDuration = json.decay_duration
|
||||
this.decayStartBlock = json.decay_start_block
|
||||
}
|
||||
constructor(
|
||||
balance: Decimal,
|
||||
decay: Decimal,
|
||||
start: Date | null,
|
||||
end: Date | null,
|
||||
duration: number | null,
|
||||
) {
|
||||
this.balance = balance
|
||||
this.decay = decay
|
||||
this.start = start
|
||||
this.end = end
|
||||
this.duration = duration
|
||||
}
|
||||
|
||||
@Field(() => Number)
|
||||
balance: number
|
||||
@Field(() => Decimal)
|
||||
balance: Decimal
|
||||
|
||||
// timestamp in seconds
|
||||
@Field(() => Int, { nullable: true })
|
||||
decayStart: string
|
||||
@Field(() => Decimal)
|
||||
decay: Decimal
|
||||
|
||||
// timestamp in seconds
|
||||
@Field(() => Int, { nullable: true })
|
||||
decayEnd: string
|
||||
@Field(() => Date, { nullable: true })
|
||||
start: Date | null
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
decayDuration?: number
|
||||
@Field(() => Date, { nullable: true })
|
||||
end: Date | null
|
||||
|
||||
@Field(() => Int, { nullable: true })
|
||||
decayStartBlock?: string
|
||||
duration: number | null
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
import { GdtEntryType } from '../enum/GdtEntryType'
|
||||
import { GdtEntryType } from '@enum/GdtEntryType'
|
||||
|
||||
@ObjectType()
|
||||
export class GdtEntry {
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
/*
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
|
||||
@ObjectType()
|
||||
export class GdtSumPerEmail {
|
||||
constructor(email: string, summe: number) {
|
||||
this.email = email
|
||||
this.summe = summe
|
||||
}
|
||||
|
||||
@Field(() => String)
|
||||
email: string
|
||||
|
||||
@Field(() => Number)
|
||||
summe: number
|
||||
}
|
||||
*/
|
||||
@ -1,55 +1,70 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
import { Decay } from './Decay'
|
||||
|
||||
// we need a better solution for the decay block:
|
||||
// the first transaction on the first page shows the decay since the last transaction
|
||||
// the format is actually a Decay and not a Transaction.
|
||||
// Therefore we have a lot of nullable fields, which should be always present
|
||||
import { Transaction as dbTransaction } from '@entity/Transaction'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import { User } from './User'
|
||||
|
||||
@ObjectType()
|
||||
export class Transaction {
|
||||
constructor() {
|
||||
this.type = ''
|
||||
this.balance = 0
|
||||
this.totalBalance = 0
|
||||
this.memo = ''
|
||||
constructor(transaction: dbTransaction, user: User, linkedUser: User | null = null) {
|
||||
this.id = transaction.id
|
||||
this.user = user
|
||||
this.previous = transaction.previous
|
||||
this.typeId = transaction.typeId
|
||||
this.amount = transaction.amount
|
||||
this.balance = transaction.balance
|
||||
this.balanceDate = transaction.balanceDate
|
||||
if (!transaction.decayStart) {
|
||||
this.decay = new Decay(transaction.balance, new Decimal(0), null, null, null)
|
||||
} else {
|
||||
this.decay = new Decay(
|
||||
transaction.balance,
|
||||
transaction.decay,
|
||||
transaction.decayStart,
|
||||
transaction.balanceDate,
|
||||
Math.round((transaction.balanceDate.getTime() - transaction.decayStart.getTime()) / 1000),
|
||||
)
|
||||
}
|
||||
this.memo = transaction.memo
|
||||
this.creationDate = transaction.creationDate
|
||||
this.linkedUser = linkedUser
|
||||
this.linkedTransactionId = transaction.linkedTransactionId
|
||||
}
|
||||
|
||||
@Field(() => String)
|
||||
type: string
|
||||
|
||||
@Field(() => Number)
|
||||
balance: number
|
||||
id: number
|
||||
|
||||
@Field(() => Number)
|
||||
totalBalance: number
|
||||
@Field(() => User)
|
||||
user: User
|
||||
|
||||
@Field({ nullable: true })
|
||||
decayStart?: string
|
||||
@Field(() => Number, { nullable: true })
|
||||
previous: number | null
|
||||
|
||||
@Field({ nullable: true })
|
||||
decayEnd?: string
|
||||
@Field(() => TransactionTypeId)
|
||||
typeId: TransactionTypeId
|
||||
|
||||
@Field({ nullable: true })
|
||||
decayDuration?: number
|
||||
@Field(() => Decimal)
|
||||
amount: Decimal
|
||||
|
||||
@Field(() => Decimal)
|
||||
balance: Decimal
|
||||
|
||||
@Field(() => Date)
|
||||
balanceDate: Date
|
||||
|
||||
@Field(() => Decay)
|
||||
decay: Decay
|
||||
|
||||
@Field(() => String)
|
||||
memo: string
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
creationDate: Date | null
|
||||
|
||||
@Field(() => User, { nullable: true })
|
||||
linkedUser: User | null
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
transactionId?: number
|
||||
|
||||
@Field({ nullable: true })
|
||||
name?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
email?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
date?: string
|
||||
|
||||
@Field({ nullable: true })
|
||||
decay?: Decay
|
||||
linkedTransactionId?: number | null
|
||||
}
|
||||
|
||||
58
backend/src/graphql/model/TransactionLink.ts
Normal file
58
backend/src/graphql/model/TransactionLink.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
import { User } from './User'
|
||||
|
||||
@ObjectType()
|
||||
export class TransactionLink {
|
||||
constructor(transactionLink: dbTransactionLink, user: User, redeemedBy: User | null = null) {
|
||||
this.id = transactionLink.id
|
||||
this.user = user
|
||||
this.amount = transactionLink.amount
|
||||
this.holdAvailableAmount = transactionLink.holdAvailableAmount
|
||||
this.memo = transactionLink.memo
|
||||
this.code = transactionLink.code
|
||||
this.createdAt = transactionLink.createdAt
|
||||
this.validUntil = transactionLink.validUntil
|
||||
this.showEmail = transactionLink.showEmail
|
||||
this.deletedAt = transactionLink.deletedAt
|
||||
this.redeemedAt = transactionLink.redeemedAt
|
||||
this.redeemedBy = redeemedBy
|
||||
}
|
||||
|
||||
@Field(() => Number)
|
||||
id: number
|
||||
|
||||
@Field(() => User)
|
||||
user: User
|
||||
|
||||
@Field(() => Decimal)
|
||||
amount: Decimal
|
||||
|
||||
@Field(() => Decimal)
|
||||
holdAvailableAmount: Decimal
|
||||
|
||||
@Field(() => String)
|
||||
memo: string
|
||||
|
||||
@Field(() => String)
|
||||
code: string
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt: Date
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
deletedAt: Date | null
|
||||
|
||||
@Field(() => Date)
|
||||
validUntil: Date
|
||||
|
||||
@Field(() => Boolean)
|
||||
showEmail: boolean
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
redeemedAt: Date | null
|
||||
|
||||
@Field(() => User, { nullable: true })
|
||||
redeemedBy: User | null
|
||||
}
|
||||
@ -1,32 +1,35 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
import CONFIG from '@/config'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { Transaction } from './Transaction'
|
||||
|
||||
@ObjectType()
|
||||
export class TransactionList {
|
||||
constructor() {
|
||||
this.gdtSum = 0
|
||||
this.count = 0
|
||||
this.balance = 0
|
||||
this.decay = 0
|
||||
this.decayDate = ''
|
||||
constructor(
|
||||
balance: Decimal,
|
||||
transactions: Transaction[],
|
||||
count: number,
|
||||
balanceGDT?: number | null,
|
||||
decayStartBlock: Date = CONFIG.DECAY_START_TIME,
|
||||
) {
|
||||
this.balance = balance
|
||||
this.transactions = transactions
|
||||
this.count = count
|
||||
this.balanceGDT = balanceGDT || null
|
||||
this.decayStartBlock = decayStartBlock
|
||||
}
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
gdtSum: number | null
|
||||
balanceGDT: number | null
|
||||
|
||||
@Field(() => Number)
|
||||
count: number
|
||||
|
||||
@Field(() => Number)
|
||||
balance: number
|
||||
@Field(() => Decimal)
|
||||
balance: Decimal
|
||||
|
||||
@Field(() => Number)
|
||||
decay: number
|
||||
|
||||
@Field(() => String)
|
||||
decayDate: string
|
||||
@Field(() => Date)
|
||||
decayStartBlock: Date
|
||||
|
||||
@Field(() => [Transaction])
|
||||
transactions: Transaction[]
|
||||
|
||||
@ -1,75 +1,74 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import { ObjectType, Field, Int } from 'type-graphql'
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
import { KlickTipp } from './KlickTipp'
|
||||
import { User as dbUser } from '@entity/User'
|
||||
|
||||
@ObjectType()
|
||||
export class User {
|
||||
/*
|
||||
@Field(() => ID)
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number
|
||||
*/
|
||||
constructor(json?: any) {
|
||||
if (json) {
|
||||
this.id = json.id
|
||||
this.email = json.email
|
||||
this.firstName = json.first_name
|
||||
this.lastName = json.last_name
|
||||
this.pubkey = json.public_hex
|
||||
this.language = json.language
|
||||
this.publisherId = json.publisher_id
|
||||
this.isAdmin = json.isAdmin
|
||||
}
|
||||
constructor(user: dbUser) {
|
||||
this.id = user.id
|
||||
this.email = user.email
|
||||
this.firstName = user.firstName
|
||||
this.lastName = user.lastName
|
||||
this.deletedAt = user.deletedAt
|
||||
this.createdAt = user.createdAt
|
||||
this.emailChecked = user.emailChecked
|
||||
this.language = user.language
|
||||
this.publisherId = user.publisherId
|
||||
// TODO
|
||||
this.isAdmin = null
|
||||
this.coinanimation = null
|
||||
this.klickTipp = null
|
||||
this.hasElopage = null
|
||||
}
|
||||
|
||||
@Field(() => Number)
|
||||
id: number
|
||||
|
||||
// `public_key` binary(32) DEFAULT NULL,
|
||||
// `privkey` binary(80) DEFAULT NULL,
|
||||
|
||||
// TODO privacy issue here
|
||||
@Field(() => String)
|
||||
email: string
|
||||
|
||||
@Field(() => String)
|
||||
firstName: string
|
||||
@Field(() => String, { nullable: true })
|
||||
firstName: string | null
|
||||
|
||||
@Field(() => String)
|
||||
lastName: string
|
||||
@Field(() => String, { nullable: true })
|
||||
lastName: string | null
|
||||
|
||||
@Field(() => String)
|
||||
pubkey: string
|
||||
/*
|
||||
@Field(() => String)
|
||||
pubkey: string
|
||||
@Field(() => Date, { nullable: true })
|
||||
deletedAt: Date | null
|
||||
|
||||
// not sure about the type here. Maybe better to have a string
|
||||
@Field(() => number)
|
||||
created: number
|
||||
// `password` bigint(20) unsigned DEFAULT 0,
|
||||
// `email_hash` binary(32) DEFAULT NULL,
|
||||
|
||||
@Field(() =>>> Boolean)
|
||||
@Field(() => Date)
|
||||
createdAt: Date
|
||||
|
||||
@Field(() => Boolean)
|
||||
emailChecked: boolean
|
||||
*/
|
||||
|
||||
@Field(() => String)
|
||||
language: string
|
||||
|
||||
/*
|
||||
@Field(() => Boolean)
|
||||
disabled: boolean
|
||||
*/
|
||||
// This is not the users publisherId, but the one of the users who recommend him
|
||||
@Field(() => Number, { nullable: true })
|
||||
publisherId: number | null
|
||||
|
||||
// what is publisherId?
|
||||
@Field(() => Int, { nullable: true })
|
||||
publisherId?: number
|
||||
// `passphrase` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
|
||||
@Field(() => Boolean)
|
||||
isAdmin: boolean
|
||||
|
||||
@Field(() => Boolean)
|
||||
coinanimation: boolean
|
||||
|
||||
@Field(() => KlickTipp)
|
||||
klickTipp: KlickTipp
|
||||
// TODO this is a bit inconsistent with what we query from the database
|
||||
// therefore all those fields are now nullable with default value null
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
isAdmin: boolean | null
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
hasElopage?: boolean
|
||||
coinanimation: boolean | null
|
||||
|
||||
@Field(() => KlickTipp, { nullable: true })
|
||||
klickTipp: KlickTipp | null
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
hasElopage: boolean | null
|
||||
}
|
||||
|
||||
@ -1,7 +1,20 @@
|
||||
import { User } from '@entity/User'
|
||||
import { ObjectType, Field, Int } from 'type-graphql'
|
||||
|
||||
@ObjectType()
|
||||
export class UserAdmin {
|
||||
constructor(user: User, creation: number[], hasElopage: boolean, emailConfirmationSend: string) {
|
||||
this.userId = user.id
|
||||
this.email = user.email
|
||||
this.firstName = user.firstName
|
||||
this.lastName = user.lastName
|
||||
this.creation = creation
|
||||
this.emailChecked = user.emailChecked
|
||||
this.hasElopage = hasElopage
|
||||
this.deletedAt = user.deletedAt
|
||||
this.emailConfirmationSend = emailConfirmationSend
|
||||
}
|
||||
|
||||
@Field(() => Number)
|
||||
userId: number
|
||||
|
||||
@ -23,6 +36,9 @@ export class UserAdmin {
|
||||
@Field(() => Boolean)
|
||||
hasElopage: boolean
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
deletedAt?: Date | null
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
emailConfirmationSend?: string
|
||||
}
|
||||
|
||||
@ -2,36 +2,52 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { Resolver, Query, Arg, Args, Authorized, Mutation, Ctx } from 'type-graphql'
|
||||
import { getCustomRepository, ObjectLiteral, getConnection, In } from '@dbTools/typeorm'
|
||||
import { UserAdmin, SearchUsersResult } from '../model/UserAdmin'
|
||||
import { PendingCreation } from '../model/PendingCreation'
|
||||
import { CreatePendingCreations } from '../model/CreatePendingCreations'
|
||||
import { UpdatePendingCreation } from '../model/UpdatePendingCreation'
|
||||
import { RIGHTS } from '../../auth/RIGHTS'
|
||||
import { UserRepository } from '../../typeorm/repository/User'
|
||||
import CreatePendingCreationArgs from '../arg/CreatePendingCreationArgs'
|
||||
import UpdatePendingCreationArgs from '../arg/UpdatePendingCreationArgs'
|
||||
import SearchUsersArgs from '../arg/SearchUsersArgs'
|
||||
import {
|
||||
getCustomRepository,
|
||||
IsNull,
|
||||
Not,
|
||||
ObjectLiteral,
|
||||
getConnection,
|
||||
In,
|
||||
} from '@dbTools/typeorm'
|
||||
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
|
||||
import { PendingCreation } from '@model/PendingCreation'
|
||||
import { CreatePendingCreations } from '@model/CreatePendingCreations'
|
||||
import { UpdatePendingCreation } from '@model/UpdatePendingCreation'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { UserRepository } from '@repository/User'
|
||||
import CreatePendingCreationArgs from '@arg/CreatePendingCreationArgs'
|
||||
import UpdatePendingCreationArgs from '@arg/UpdatePendingCreationArgs'
|
||||
import SearchUsersArgs from '@arg/SearchUsersArgs'
|
||||
import { Transaction } from '@entity/Transaction'
|
||||
import { UserTransaction } from '@entity/UserTransaction'
|
||||
import { UserTransactionRepository } from '../../typeorm/repository/UserTransaction'
|
||||
import { calculateDecay } from '../../util/decay'
|
||||
import { TransactionRepository } from '@repository/Transaction'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
import { AdminPendingCreation } from '@entity/AdminPendingCreation'
|
||||
import { hasElopageBuys } from '../../util/hasElopageBuys'
|
||||
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
||||
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
|
||||
import { User } from '@entity/User'
|
||||
import { TransactionTypeId } from '../enum/TransactionTypeId'
|
||||
import { Balance } from '@entity/Balance'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { Decay } from '@model/Decay'
|
||||
|
||||
// const EMAIL_OPT_IN_REGISTER = 1
|
||||
// const EMAIL_OPT_UNKNOWN = 3 // elopage?
|
||||
const MAX_CREATION_AMOUNT = 1000
|
||||
const FULL_CREATION_AVAILABLE = [MAX_CREATION_AMOUNT, MAX_CREATION_AMOUNT, MAX_CREATION_AMOUNT]
|
||||
|
||||
@Resolver()
|
||||
export class AdminResolver {
|
||||
@Authorized([RIGHTS.SEARCH_USERS])
|
||||
@Query(() => SearchUsersResult)
|
||||
async searchUsers(
|
||||
@Args() { searchText, currentPage = 1, pageSize = 25, notActivated = false }: SearchUsersArgs,
|
||||
@Args()
|
||||
{
|
||||
searchText,
|
||||
currentPage = 1,
|
||||
pageSize = 25,
|
||||
notActivated = false,
|
||||
isDeleted = false,
|
||||
}: SearchUsersArgs,
|
||||
): Promise<SearchUsersResult> {
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
|
||||
@ -40,7 +56,11 @@ export class AdminResolver {
|
||||
filterCriteria.push({ emailChecked: false })
|
||||
}
|
||||
|
||||
const userFields = ['id', 'firstName', 'lastName', 'email', 'emailChecked']
|
||||
if (isDeleted) {
|
||||
filterCriteria.push({ deletedAt: Not(IsNull()) })
|
||||
}
|
||||
|
||||
const userFields = ['id', 'firstName', 'lastName', 'email', 'emailChecked', 'deletedAt']
|
||||
const [users, count] = await userRepository.findBySearchCriteriaPagedFiltered(
|
||||
userFields.map((fieldName) => {
|
||||
return 'user.' + fieldName
|
||||
@ -51,19 +71,18 @@ export class AdminResolver {
|
||||
pageSize,
|
||||
)
|
||||
|
||||
if (users.length === 0) {
|
||||
return {
|
||||
userCount: 0,
|
||||
userList: [],
|
||||
}
|
||||
}
|
||||
|
||||
const creations = await getUserCreations(users.map((u) => u.id))
|
||||
|
||||
const adminUsers = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const adminUser = new UserAdmin()
|
||||
adminUser.userId = user.id
|
||||
adminUser.firstName = user.firstName
|
||||
adminUser.lastName = user.lastName
|
||||
adminUser.email = user.email
|
||||
const userCreations = creations.find((c) => c.id === user.id)
|
||||
adminUser.creation = userCreations ? userCreations.creations : [1000, 1000, 1000]
|
||||
adminUser.emailChecked = user.emailChecked
|
||||
adminUser.hasElopage = await hasElopageBuys(user.email)
|
||||
let emailConfirmationSend = ''
|
||||
if (!user.emailChecked) {
|
||||
const emailOptIn = await LoginEmailOptIn.findOne(
|
||||
{
|
||||
@ -79,12 +98,19 @@ export class AdminResolver {
|
||||
)
|
||||
if (emailOptIn) {
|
||||
if (emailOptIn.updatedAt) {
|
||||
adminUser.emailConfirmationSend = emailOptIn.updatedAt.toISOString()
|
||||
emailConfirmationSend = emailOptIn.updatedAt.toISOString()
|
||||
} else {
|
||||
adminUser.emailConfirmationSend = emailOptIn.createdAt.toISOString()
|
||||
emailConfirmationSend = emailOptIn.createdAt.toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
const userCreations = creations.find((c) => c.id === user.id)
|
||||
const adminUser = new UserAdmin(
|
||||
user,
|
||||
userCreations ? userCreations.creations : FULL_CREATION_AVAILABLE,
|
||||
await hasElopageBuys(user.email),
|
||||
emailConfirmationSend,
|
||||
)
|
||||
return adminUser
|
||||
}),
|
||||
)
|
||||
@ -94,6 +120,39 @@ export class AdminResolver {
|
||||
}
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.DELETE_USER])
|
||||
@Mutation(() => Date, { nullable: true })
|
||||
async deleteUser(@Arg('userId') userId: number, @Ctx() context: any): Promise<Date | null> {
|
||||
const user = await User.findOne({ id: userId })
|
||||
// user exists ?
|
||||
if (!user) {
|
||||
throw new Error(`Could not find user with userId: ${userId}`)
|
||||
}
|
||||
// moderator user disabled own account?
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
const moderatorUser = await userRepository.findByPubkeyHex(context.pubKey)
|
||||
if (moderatorUser.id === userId) {
|
||||
throw new Error('Moderator can not delete his own account!')
|
||||
}
|
||||
// soft-delete user
|
||||
await user.softRemove()
|
||||
const newUser = await User.findOne({ id: userId }, { withDeleted: true })
|
||||
return newUser ? newUser.deletedAt : null
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.UNDELETE_USER])
|
||||
@Mutation(() => Date, { nullable: true })
|
||||
async unDeleteUser(@Arg('userId') userId: number): Promise<Date | null> {
|
||||
const user = await User.findOne({ id: userId }, { withDeleted: true })
|
||||
// user exists ?
|
||||
if (!user) {
|
||||
throw new Error(`Could not find user with userId: ${userId}`)
|
||||
}
|
||||
// recover user account
|
||||
await user.recover()
|
||||
return null
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.CREATE_PENDING_CREATION])
|
||||
@Mutation(() => [Number])
|
||||
async createPendingCreation(
|
||||
@ -114,7 +173,7 @@ export class AdminResolver {
|
||||
if (isCreationValid(creations, amount, creationDateObj)) {
|
||||
const adminPendingCreation = AdminPendingCreation.create()
|
||||
adminPendingCreation.userId = user.id
|
||||
adminPendingCreation.amount = BigInt(amount * 10000)
|
||||
adminPendingCreation.amount = BigInt(amount)
|
||||
adminPendingCreation.created = new Date()
|
||||
adminPendingCreation.date = creationDateObj
|
||||
adminPendingCreation.memo = memo
|
||||
@ -179,7 +238,7 @@ export class AdminResolver {
|
||||
if (!isCreationValid(creations, amount, creationDateObj)) {
|
||||
throw new Error('Creation is not valid')
|
||||
}
|
||||
pendingCreationToUpdate.amount = BigInt(amount * 10000)
|
||||
pendingCreationToUpdate.amount = BigInt(amount)
|
||||
pendingCreationToUpdate.memo = memo
|
||||
pendingCreationToUpdate.date = new Date(creationDate)
|
||||
pendingCreationToUpdate.moderator = moderator
|
||||
@ -206,7 +265,7 @@ export class AdminResolver {
|
||||
|
||||
const userIds = pendingCreations.map((p) => p.userId)
|
||||
const userCreations = await getUserCreations(userIds)
|
||||
const users = await User.find({ id: In(userIds) })
|
||||
const users = await User.find({ where: { id: In(userIds) }, withDeleted: true })
|
||||
|
||||
return pendingCreations.map((pendingCreation) => {
|
||||
const user = users.find((u) => u.id === pendingCreation.userId)
|
||||
@ -214,11 +273,11 @@ export class AdminResolver {
|
||||
|
||||
return {
|
||||
...pendingCreation,
|
||||
amount: Number(parseInt(pendingCreation.amount.toString()) / 10000),
|
||||
amount: Number(pendingCreation.amount.toString()),
|
||||
firstName: user ? user.firstName : '',
|
||||
lastName: user ? user.lastName : '',
|
||||
email: user ? user.email : '',
|
||||
creation: creation ? creation.creations : [1000, 1000, 1000],
|
||||
creation: creation ? creation.creations : FULL_CREATION_AVAILABLE,
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -240,58 +299,42 @@ export class AdminResolver {
|
||||
if (moderatorUser.id === pendingCreation.userId)
|
||||
throw new Error('Moderator can not confirm own pending creation')
|
||||
|
||||
const user = await User.findOneOrFail({ id: pendingCreation.userId }, { withDeleted: true })
|
||||
if (user.deletedAt) throw new Error('This user was deleted. Cannot confirm a creation.')
|
||||
|
||||
const creations = await getUserCreation(pendingCreation.userId, false)
|
||||
if (!isCreationValid(creations, Number(pendingCreation.amount) / 10000, pendingCreation.date)) {
|
||||
if (!isCreationValid(creations, Number(pendingCreation.amount), pendingCreation.date)) {
|
||||
throw new Error('Creation is not valid!!')
|
||||
}
|
||||
|
||||
const receivedCallDate = new Date()
|
||||
let transaction = new Transaction()
|
||||
transaction.transactionTypeId = TransactionTypeId.CREATION
|
||||
|
||||
const transactionRepository = getCustomRepository(TransactionRepository)
|
||||
const lastTransaction = await transactionRepository.findLastForUser(pendingCreation.userId)
|
||||
|
||||
let newBalance = new Decimal(0)
|
||||
let decay: Decay | null = null
|
||||
if (lastTransaction) {
|
||||
decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, receivedCallDate)
|
||||
newBalance = decay.balance
|
||||
}
|
||||
// TODO pending creations decimal
|
||||
newBalance = newBalance.add(new Decimal(Number(pendingCreation.amount)).toString())
|
||||
|
||||
const transaction = new Transaction()
|
||||
transaction.typeId = TransactionTypeId.CREATION
|
||||
transaction.memo = pendingCreation.memo
|
||||
transaction.received = receivedCallDate
|
||||
transaction.userId = pendingCreation.userId
|
||||
transaction.amount = BigInt(parseInt(pendingCreation.amount.toString()))
|
||||
transaction.previous = lastTransaction ? lastTransaction.id : null
|
||||
// TODO pending creations decimal
|
||||
transaction.amount = new Decimal(Number(pendingCreation.amount))
|
||||
transaction.creationDate = pendingCreation.date
|
||||
transaction = await transaction.save()
|
||||
if (!transaction) throw new Error('Could not create transaction')
|
||||
transaction.balance = newBalance
|
||||
transaction.balanceDate = receivedCallDate
|
||||
transaction.decay = decay ? decay.decay : new Decimal(0)
|
||||
transaction.decayStart = decay ? decay.start : null
|
||||
await transaction.save()
|
||||
|
||||
const userTransactionRepository = getCustomRepository(UserTransactionRepository)
|
||||
const lastUserTransaction = await userTransactionRepository.findLastForUser(
|
||||
pendingCreation.userId,
|
||||
)
|
||||
let newBalance = 0
|
||||
if (!lastUserTransaction) {
|
||||
newBalance = 0
|
||||
} else {
|
||||
newBalance = calculateDecay(
|
||||
lastUserTransaction.balance,
|
||||
lastUserTransaction.balanceDate,
|
||||
receivedCallDate,
|
||||
).balance
|
||||
}
|
||||
newBalance = Number(newBalance) + Number(parseInt(pendingCreation.amount.toString()))
|
||||
|
||||
const newUserTransaction = new UserTransaction()
|
||||
newUserTransaction.userId = pendingCreation.userId
|
||||
newUserTransaction.transactionId = transaction.id
|
||||
newUserTransaction.transactionTypeId = transaction.transactionTypeId
|
||||
newUserTransaction.balance = Number(newBalance)
|
||||
newUserTransaction.balanceDate = transaction.received
|
||||
|
||||
await userTransactionRepository.save(newUserTransaction).catch((error) => {
|
||||
throw new Error('Error saving user transaction: ' + error)
|
||||
})
|
||||
|
||||
let userBalance = await Balance.findOne({ userId: pendingCreation.userId })
|
||||
if (!userBalance) {
|
||||
userBalance = new Balance()
|
||||
userBalance.userId = pendingCreation.userId
|
||||
}
|
||||
userBalance.amount = Number(newBalance)
|
||||
userBalance.modified = receivedCallDate
|
||||
userBalance.recordDate = receivedCallDate
|
||||
await userBalance.save()
|
||||
await AdminPendingCreation.delete(pendingCreation)
|
||||
|
||||
return true
|
||||
@ -305,7 +348,7 @@ interface CreationMap {
|
||||
|
||||
async function getUserCreation(id: number, includePending = true): Promise<number[]> {
|
||||
const creations = await getUserCreations([id], includePending)
|
||||
return creations[0] ? creations[0].creations : [1000, 1000, 1000]
|
||||
return creations[0] ? creations[0].creations : FULL_CREATION_AVAILABLE
|
||||
}
|
||||
|
||||
async function getUserCreations(ids: number[], includePending = true): Promise<CreationMap[]> {
|
||||
@ -328,7 +371,7 @@ async function getUserCreations(ids: number[], includePending = true): Promise<C
|
||||
SELECT MONTH(date) AS month, sum(amount) AS sum, userId AS id FROM
|
||||
(SELECT creation_date AS date, amount AS amount, user_id AS userId FROM transactions
|
||||
WHERE user_id IN (${ids.toString()})
|
||||
AND transaction_type_id = ${TransactionTypeId.CREATION}
|
||||
AND type_id = ${TransactionTypeId.CREATION}
|
||||
AND creation_date >= ${dateFilter}
|
||||
${unionString}) AS result
|
||||
GROUP BY month, userId
|
||||
@ -345,7 +388,7 @@ async function getUserCreations(ids: number[], includePending = true): Promise<C
|
||||
(raw: { month: string; id: string; creation: number[] }) =>
|
||||
parseInt(raw.month) === month && parseInt(raw.id) === id,
|
||||
)
|
||||
return 1000 - (creation ? Number(creation.sum) / 10000 : 0)
|
||||
return MAX_CREATION_AMOUNT - (creation ? Number(creation.sum) : 0)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@ -3,12 +3,12 @@
|
||||
|
||||
import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
import { Balance } from '../model/Balance'
|
||||
import { UserRepository } from '../../typeorm/repository/User'
|
||||
import { calculateDecay } from '../../util/decay'
|
||||
import { roundFloorFrom4 } from '../../util/round'
|
||||
import { RIGHTS } from '../../auth/RIGHTS'
|
||||
import { Balance as dbBalance } from '@entity/Balance'
|
||||
import { Balance } from '@model/Balance'
|
||||
import { UserRepository } from '@repository/User'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { Transaction } from '@entity/Transaction'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
@Resolver()
|
||||
export class BalanceResolver {
|
||||
@ -18,24 +18,26 @@ export class BalanceResolver {
|
||||
// load user and balance
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
|
||||
const userEntity = await userRepository.findByPubkeyHex(context.pubKey)
|
||||
const balanceEntity = await dbBalance.findOne({ userId: userEntity.id })
|
||||
const user = await userRepository.findByPubkeyHex(context.pubKey)
|
||||
const now = new Date()
|
||||
|
||||
const lastTransaction = await Transaction.findOne(
|
||||
{ userId: user.id },
|
||||
{ order: { balanceDate: 'DESC' } },
|
||||
)
|
||||
|
||||
// No balance found
|
||||
if (!balanceEntity) {
|
||||
if (!lastTransaction) {
|
||||
return new Balance({
|
||||
balance: 0,
|
||||
decay: 0,
|
||||
balance: new Decimal(0),
|
||||
decay: new Decimal(0),
|
||||
decay_date: now.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
return new Balance({
|
||||
balance: roundFloorFrom4(balanceEntity.amount),
|
||||
decay: roundFloorFrom4(
|
||||
calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now).balance,
|
||||
),
|
||||
balance: lastTransaction.balance,
|
||||
decay: calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance,
|
||||
decay_date: now.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import createServer from '../../server/createServer'
|
||||
import CONFIG from '../../config'
|
||||
import createServer from '@/server/createServer'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
jest.mock('../../config')
|
||||
jest.mock('@/config')
|
||||
|
||||
let query: any
|
||||
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { Resolver, Query, Authorized } from 'type-graphql'
|
||||
import { RIGHTS } from '../../auth/RIGHTS'
|
||||
import CONFIG from '../../config'
|
||||
import { Community } from '../model/Community'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import CONFIG from '@/config'
|
||||
import { Community } from '@model/Community'
|
||||
|
||||
@Resolver()
|
||||
export class CommunityResolver {
|
||||
|
||||
@ -3,13 +3,13 @@
|
||||
|
||||
import { Resolver, Query, Args, Ctx, Authorized, Arg } from 'type-graphql'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
import CONFIG from '../../config'
|
||||
import { GdtEntryList } from '../model/GdtEntryList'
|
||||
import Paginated from '../arg/Paginated'
|
||||
import { apiGet } from '../../apis/HttpRequest'
|
||||
import { UserRepository } from '../../typeorm/repository/User'
|
||||
import { Order } from '../enum/Order'
|
||||
import { RIGHTS } from '../../auth/RIGHTS'
|
||||
import CONFIG from '@/config'
|
||||
import { GdtEntryList } from '@model/GdtEntryList'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import { apiGet } from '@/apis/HttpRequest'
|
||||
import { UserRepository } from '@repository/User'
|
||||
import { Order } from '@enum/Order'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
|
||||
@Resolver()
|
||||
export class GdtResolver {
|
||||
|
||||
@ -7,9 +7,9 @@ import {
|
||||
getKlicktippTagMap,
|
||||
unsubscribe,
|
||||
klicktippSignIn,
|
||||
} from '../../apis/KlicktippController'
|
||||
import { RIGHTS } from '../../auth/RIGHTS'
|
||||
import SubscribeNewsletterArgs from '../arg/SubscribeNewsletterArgs'
|
||||
} from '@/apis/KlicktippController'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import SubscribeNewsletterArgs from '@arg/SubscribeNewsletterArgs'
|
||||
|
||||
@Resolver()
|
||||
export class KlicktippResolver {
|
||||
|
||||
14
backend/src/graphql/resolver/TransactionLinkResolver.test.ts
Normal file
14
backend/src/graphql/resolver/TransactionLinkResolver.test.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { transactionLinkCode } from './TransactionLinkResolver'
|
||||
|
||||
describe('transactionLinkCode', () => {
|
||||
const date = new Date()
|
||||
|
||||
it('returns a string of length 24', () => {
|
||||
expect(transactionLinkCode(date)).toHaveLength(24)
|
||||
})
|
||||
|
||||
it('returns a string that ends with the hex value of date', () => {
|
||||
const regexp = new RegExp(date.getTime().toString(16) + '$')
|
||||
expect(transactionLinkCode(date)).toEqual(expect.stringMatching(regexp))
|
||||
})
|
||||
})
|
||||
100
backend/src/graphql/resolver/TransactionLinkResolver.ts
Normal file
100
backend/src/graphql/resolver/TransactionLinkResolver.ts
Normal file
@ -0,0 +1,100 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { Resolver, Args, Authorized, Ctx, Mutation, Query } from 'type-graphql'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
import { TransactionLink } from '@model/TransactionLink'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
import TransactionLinkArgs from '@arg/TransactionLinkArgs'
|
||||
import QueryTransactionLinkArgs from '@arg/QueryTransactionLinkArgs'
|
||||
import { UserRepository } from '@repository/User'
|
||||
import { calculateBalance } from '@/util/validate'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { User } from '@model/User'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
|
||||
// TODO: do not export, test it inside the resolver
|
||||
export const transactionLinkCode = (date: Date): string => {
|
||||
const time = date.getTime().toString(16)
|
||||
return (
|
||||
randomBytes(12)
|
||||
.toString('hex')
|
||||
.substring(0, 24 - time.length) + time
|
||||
)
|
||||
}
|
||||
|
||||
const CODE_VALID_DAYS_DURATION = 14
|
||||
|
||||
const transactionLinkExpireDate = (date: Date): Date => {
|
||||
const validUntil = new Date(date)
|
||||
return new Date(validUntil.setDate(date.getDate() + CODE_VALID_DAYS_DURATION))
|
||||
}
|
||||
|
||||
@Resolver()
|
||||
export class TransactionLinkResolver {
|
||||
@Authorized([RIGHTS.CREATE_TRANSACTION_LINK])
|
||||
@Mutation(() => TransactionLink)
|
||||
async createTransactionLink(
|
||||
@Args() { amount, memo, showEmail = false }: TransactionLinkArgs,
|
||||
@Ctx() context: any,
|
||||
): Promise<TransactionLink> {
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
const user = await userRepository.findByPubkeyHex(context.pubKey)
|
||||
|
||||
const createdDate = new Date()
|
||||
const validUntil = transactionLinkExpireDate(createdDate)
|
||||
|
||||
const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay)
|
||||
|
||||
// validate amount
|
||||
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
|
||||
transactionLink.amount = amount
|
||||
transactionLink.memo = memo
|
||||
transactionLink.holdAvailableAmount = holdAvailableAmount
|
||||
transactionLink.code = transactionLinkCode(createdDate)
|
||||
transactionLink.createdAt = createdDate
|
||||
transactionLink.validUntil = validUntil
|
||||
transactionLink.showEmail = showEmail
|
||||
await dbTransactionLink.save(transactionLink).catch(() => {
|
||||
throw new Error('Unable to save transaction link')
|
||||
})
|
||||
|
||||
return new TransactionLink(transactionLink, new User(user))
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.QUERY_TRANSACTION_LINK])
|
||||
@Query(() => TransactionLink)
|
||||
async queryTransactionLink(
|
||||
@Args() { code, redeemUserId }: QueryTransactionLinkArgs,
|
||||
): Promise<TransactionLink> {
|
||||
const transactionLink = await dbTransactionLink.findOneOrFail({ code })
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
const user = await userRepository.findOneOrFail({ id: transactionLink.userId })
|
||||
let userRedeem = null
|
||||
if (redeemUserId && !transactionLink.redeemedBy) {
|
||||
const redeemedByUser = await userRepository.findOne({ id: redeemUserId })
|
||||
if (!redeemedByUser) {
|
||||
throw new Error('Unable to find user that redeem the link')
|
||||
}
|
||||
userRedeem = new User(redeemedByUser)
|
||||
transactionLink.redeemedBy = userRedeem.id
|
||||
await dbTransactionLink.save(transactionLink).catch(() => {
|
||||
throw new Error('Unable to save transaction link')
|
||||
})
|
||||
} else if (transactionLink.redeemedBy) {
|
||||
const redeemedByUser = await userRepository.findOne({ id: redeemUserId })
|
||||
if (!redeemedByUser) {
|
||||
throw new Error('Unable to find user that has redeemed the link')
|
||||
}
|
||||
userRedeem = new User(redeemedByUser)
|
||||
}
|
||||
return new TransactionLink(transactionLink, new User(user), userRedeem)
|
||||
}
|
||||
}
|
||||
@ -1,215 +1,38 @@
|
||||
/* eslint-disable new-cap */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
|
||||
import { getCustomRepository, getConnection, QueryRunner, In } from '@dbTools/typeorm'
|
||||
import { getCustomRepository, getConnection } from '@dbTools/typeorm'
|
||||
|
||||
import CONFIG from '../../config'
|
||||
import { sendTransactionReceivedEmail } from '../../mailer/sendTransactionReceivedEmail'
|
||||
import CONFIG from '@/config'
|
||||
import { sendTransactionReceivedEmail } from '@/mailer/sendTransactionReceivedEmail'
|
||||
|
||||
import { Transaction } from '../model/Transaction'
|
||||
import { TransactionList } from '../model/TransactionList'
|
||||
import { Transaction } from '@model/Transaction'
|
||||
import { TransactionList } from '@model/TransactionList'
|
||||
|
||||
import TransactionSendArgs from '../arg/TransactionSendArgs'
|
||||
import Paginated from '../arg/Paginated'
|
||||
import TransactionSendArgs from '@arg/TransactionSendArgs'
|
||||
import Paginated from '@arg/Paginated'
|
||||
|
||||
import { Order } from '../enum/Order'
|
||||
import { Order } from '@enum/Order'
|
||||
|
||||
import { UserRepository } from '../../typeorm/repository/User'
|
||||
import { UserTransactionRepository } from '../../typeorm/repository/UserTransaction'
|
||||
import { UserRepository } from '@repository/User'
|
||||
import { TransactionRepository } from '@repository/Transaction'
|
||||
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
||||
|
||||
import { User as dbUser } from '@entity/User'
|
||||
import { UserTransaction as dbUserTransaction } from '@entity/UserTransaction'
|
||||
import { Transaction as dbTransaction } from '@entity/Transaction'
|
||||
import { Balance as dbBalance } from '@entity/Balance'
|
||||
|
||||
import { apiPost } from '../../apis/HttpRequest'
|
||||
import { roundFloorFrom4, roundCeilFrom4 } from '../../util/round'
|
||||
import { calculateDecay } from '../../util/decay'
|
||||
import { TransactionTypeId } from '../enum/TransactionTypeId'
|
||||
import { TransactionType } from '../enum/TransactionType'
|
||||
import { hasUserAmount, isHexPublicKey } from '../../util/validate'
|
||||
import { RIGHTS } from '../../auth/RIGHTS'
|
||||
|
||||
// Helper function
|
||||
async function calculateAndAddDecayTransactions(
|
||||
userTransactions: dbUserTransaction[],
|
||||
user: dbUser,
|
||||
decay: boolean,
|
||||
skipFirstTransaction: boolean,
|
||||
): Promise<Transaction[]> {
|
||||
const finalTransactions: Transaction[] = []
|
||||
const transactionIds: number[] = []
|
||||
const involvedUserIds: number[] = []
|
||||
|
||||
userTransactions.forEach((userTransaction: dbUserTransaction) => {
|
||||
transactionIds.push(userTransaction.transactionId)
|
||||
})
|
||||
|
||||
const transactions = await dbTransaction.find({ where: { id: In(transactionIds) } })
|
||||
const transactionIndiced: dbTransaction[] = []
|
||||
transactions.forEach((transaction: dbTransaction) => {
|
||||
transactionIndiced[transaction.id] = transaction
|
||||
involvedUserIds.push(transaction.userId)
|
||||
if (transaction.transactionTypeId === TransactionTypeId.SEND) {
|
||||
involvedUserIds.push(transaction.sendReceiverUserId!) // TODO ensure not null properly
|
||||
}
|
||||
})
|
||||
// remove duplicates
|
||||
// https://stackoverflow.com/questions/1960473/get-all-unique-values-in-a-javascript-array-remove-duplicates
|
||||
const involvedUsersUnique = involvedUserIds.filter((v, i, a) => a.indexOf(v) === i)
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
const userIndiced = await userRepository.getUsersIndiced(involvedUsersUnique)
|
||||
|
||||
for (let i = 0; i < userTransactions.length; i++) {
|
||||
const userTransaction = userTransactions[i]
|
||||
const transaction = transactionIndiced[userTransaction.transactionId]
|
||||
const finalTransaction = new Transaction()
|
||||
finalTransaction.transactionId = transaction.id
|
||||
finalTransaction.date = transaction.received.toISOString()
|
||||
finalTransaction.memo = transaction.memo
|
||||
finalTransaction.totalBalance = roundFloorFrom4(userTransaction.balance)
|
||||
const previousTransaction = i > 0 ? userTransactions[i - 1] : null
|
||||
|
||||
if (previousTransaction) {
|
||||
const currentTransaction = userTransaction
|
||||
const decay = calculateDecay(
|
||||
previousTransaction.balance,
|
||||
previousTransaction.balanceDate,
|
||||
currentTransaction.balanceDate,
|
||||
)
|
||||
const balance = previousTransaction.balance - decay.balance
|
||||
|
||||
if (CONFIG.DECAY_START_TIME < currentTransaction.balanceDate) {
|
||||
finalTransaction.decay = decay
|
||||
finalTransaction.decay.balance = roundFloorFrom4(balance)
|
||||
if (
|
||||
previousTransaction.balanceDate < CONFIG.DECAY_START_TIME &&
|
||||
currentTransaction.balanceDate > CONFIG.DECAY_START_TIME
|
||||
) {
|
||||
finalTransaction.decay.decayStartBlock = (
|
||||
CONFIG.DECAY_START_TIME.getTime() / 1000
|
||||
).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sender or receiver when user has sent money
|
||||
// group name if creation
|
||||
// type: gesendet / empfangen / geschöpft
|
||||
// transaktion nr / id
|
||||
// date
|
||||
// balance
|
||||
if (userTransaction.transactionTypeId === TransactionTypeId.CREATION) {
|
||||
// creation
|
||||
finalTransaction.name = 'Gradido Akademie'
|
||||
finalTransaction.type = TransactionType.CREATION
|
||||
// finalTransaction.targetDate = creation.targetDate
|
||||
finalTransaction.balance = roundFloorFrom4(Number(transaction.amount)) // Todo unsafe conversion
|
||||
} else if (userTransaction.transactionTypeId === TransactionTypeId.SEND) {
|
||||
// send coin
|
||||
let otherUser: dbUser | undefined
|
||||
finalTransaction.balance = roundFloorFrom4(Number(transaction.amount)) // Todo unsafe conversion
|
||||
if (transaction.userId === user.id) {
|
||||
finalTransaction.type = TransactionType.SEND
|
||||
otherUser = userIndiced.find((u) => u.id === transaction.sendReceiverUserId)
|
||||
// finalTransaction.pubkey = sendCoin.recipiantPublic
|
||||
} else if (transaction.sendReceiverUserId === user.id) {
|
||||
finalTransaction.type = TransactionType.RECIEVE
|
||||
otherUser = userIndiced.find((u) => u.id === transaction.userId)
|
||||
// finalTransaction.pubkey = sendCoin.senderPublic
|
||||
} else {
|
||||
throw new Error('invalid transaction')
|
||||
}
|
||||
if (otherUser) {
|
||||
finalTransaction.name = otherUser.firstName + ' ' + otherUser.lastName
|
||||
finalTransaction.email = otherUser.email
|
||||
}
|
||||
}
|
||||
if (i > 0 || !skipFirstTransaction) {
|
||||
finalTransactions.push(finalTransaction)
|
||||
}
|
||||
|
||||
if (i === userTransactions.length - 1 && decay) {
|
||||
const now = new Date()
|
||||
const decay = calculateDecay(userTransaction.balance, userTransaction.balanceDate, now)
|
||||
const balance = userTransaction.balance - decay.balance
|
||||
|
||||
const decayTransaction = new Transaction()
|
||||
decayTransaction.type = 'decay'
|
||||
decayTransaction.balance = roundCeilFrom4(balance)
|
||||
decayTransaction.decayDuration = decay.decayDuration
|
||||
decayTransaction.decayStart = decay.decayStart
|
||||
decayTransaction.decayEnd = decay.decayEnd
|
||||
finalTransactions.push(decayTransaction)
|
||||
}
|
||||
}
|
||||
return finalTransactions
|
||||
}
|
||||
|
||||
// helper helper function
|
||||
async function updateStateBalance(
|
||||
user: dbUser,
|
||||
centAmount: number,
|
||||
received: Date,
|
||||
queryRunner: QueryRunner,
|
||||
): Promise<dbBalance> {
|
||||
let balance = await dbBalance.findOne({ userId: user.id })
|
||||
if (!balance) {
|
||||
balance = new dbBalance()
|
||||
balance.userId = user.id
|
||||
balance.amount = centAmount
|
||||
balance.modified = received
|
||||
} else {
|
||||
const decayedBalance = calculateDecay(balance.amount, balance.recordDate, received).balance
|
||||
balance.amount = Number(decayedBalance) + centAmount
|
||||
balance.modified = new Date()
|
||||
}
|
||||
if (balance.amount <= 0) {
|
||||
throw new Error('error new balance <= 0')
|
||||
}
|
||||
balance.recordDate = received
|
||||
return queryRunner.manager.save(balance).catch((error) => {
|
||||
throw new Error('error saving balance:' + error)
|
||||
})
|
||||
}
|
||||
|
||||
// helper helper function
|
||||
async function addUserTransaction(
|
||||
user: dbUser,
|
||||
transaction: dbTransaction,
|
||||
centAmount: number,
|
||||
queryRunner: QueryRunner,
|
||||
): Promise<dbUserTransaction> {
|
||||
let newBalance = centAmount
|
||||
const userTransactionRepository = getCustomRepository(UserTransactionRepository)
|
||||
const lastUserTransaction = await userTransactionRepository.findLastForUser(user.id)
|
||||
if (lastUserTransaction) {
|
||||
newBalance += Number(
|
||||
calculateDecay(
|
||||
Number(lastUserTransaction.balance),
|
||||
lastUserTransaction.balanceDate,
|
||||
transaction.received,
|
||||
).balance,
|
||||
)
|
||||
}
|
||||
|
||||
if (newBalance <= 0) {
|
||||
throw new Error('error new balance <= 0')
|
||||
}
|
||||
|
||||
const newUserTransaction = new dbUserTransaction()
|
||||
newUserTransaction.userId = user.id
|
||||
newUserTransaction.transactionId = transaction.id
|
||||
newUserTransaction.transactionTypeId = transaction.transactionTypeId
|
||||
newUserTransaction.balance = newBalance
|
||||
newUserTransaction.balanceDate = transaction.received
|
||||
|
||||
return queryRunner.manager.save(newUserTransaction).catch((error) => {
|
||||
throw new Error('Error saving user transaction: ' + error)
|
||||
})
|
||||
}
|
||||
import { apiPost } from '@/apis/HttpRequest'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import { calculateBalance, isHexPublicKey } from '@/util/validate'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { User } from '@model/User'
|
||||
import { communityUser } from '@/util/communityUser'
|
||||
import { virtualDecayTransaction } from '@/util/virtualDecayTransaction'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
|
||||
@Resolver()
|
||||
export class TransactionResolver {
|
||||
@ -226,68 +49,97 @@ export class TransactionResolver {
|
||||
}: Paginated,
|
||||
@Ctx() context: any,
|
||||
): Promise<TransactionList> {
|
||||
// load user
|
||||
const now = new Date()
|
||||
// find user
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
// TODO: separate those usecases - this is a security issue
|
||||
const user = userId
|
||||
? await userRepository.findOneOrFail({ id: userId }, { withDeleted: true })
|
||||
: await userRepository.findByPubkeyHex(context.pubKey)
|
||||
let limit = pageSize
|
||||
let offset = 0
|
||||
let skipFirstTransaction = false
|
||||
if (currentPage > 1) {
|
||||
offset = (currentPage - 1) * pageSize - 1
|
||||
limit++
|
||||
}
|
||||
if (offset && order === Order.ASC) {
|
||||
offset--
|
||||
}
|
||||
const userTransactionRepository = getCustomRepository(UserTransactionRepository)
|
||||
const [userTransactions, userTransactionsCount] =
|
||||
await userTransactionRepository.findByUserPaged(user.id, limit, offset, order, onlyCreations)
|
||||
skipFirstTransaction = userTransactionsCount > offset + limit
|
||||
const decay = !(currentPage > 1)
|
||||
let transactions: Transaction[] = []
|
||||
if (userTransactions.length) {
|
||||
if (order === Order.DESC) {
|
||||
userTransactions.reverse()
|
||||
}
|
||||
transactions = await calculateAndAddDecayTransactions(
|
||||
userTransactions,
|
||||
user,
|
||||
decay,
|
||||
skipFirstTransaction,
|
||||
)
|
||||
if (order === Order.DESC) {
|
||||
transactions.reverse()
|
||||
}
|
||||
}
|
||||
|
||||
const transactionList = new TransactionList()
|
||||
transactionList.count = userTransactionsCount
|
||||
transactionList.transactions = transactions
|
||||
// find current balance
|
||||
const lastTransaction = await dbTransaction.findOne(
|
||||
{ userId: user.id },
|
||||
{ order: { balanceDate: 'DESC' } },
|
||||
)
|
||||
|
||||
// get gdt sum
|
||||
transactionList.gdtSum = null
|
||||
// get GDT
|
||||
let balanceGDT = null
|
||||
try {
|
||||
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
|
||||
email: user.email,
|
||||
})
|
||||
if (resultGDTSum.success) transactionList.gdtSum = Number(resultGDTSum.data.sum) || 0
|
||||
} catch (err: any) {}
|
||||
|
||||
// get balance
|
||||
const balanceEntity = await dbBalance.findOne({ userId: user.id })
|
||||
if (balanceEntity) {
|
||||
const now = new Date()
|
||||
transactionList.balance = roundFloorFrom4(balanceEntity.amount)
|
||||
// TODO: Add a decay object here instead of static data representing the decay.
|
||||
transactionList.decay = roundFloorFrom4(
|
||||
calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now).balance,
|
||||
)
|
||||
transactionList.decayDate = now.toString()
|
||||
if (!resultGDTSum.success) {
|
||||
throw new Error('Call not successful')
|
||||
}
|
||||
balanceGDT = Number(resultGDTSum.data.sum) || 0
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Could not query GDT Server', err)
|
||||
}
|
||||
|
||||
return transactionList
|
||||
if (!lastTransaction) {
|
||||
return new TransactionList(new Decimal(0), [], 0, balanceGDT)
|
||||
}
|
||||
|
||||
// find transactions
|
||||
// first page can contain 26 due to virtual decay transaction
|
||||
const offset = (currentPage - 1) * pageSize
|
||||
const transactionRepository = getCustomRepository(TransactionRepository)
|
||||
const [userTransactions, userTransactionsCount] = await transactionRepository.findByUserPaged(
|
||||
user.id,
|
||||
pageSize,
|
||||
offset,
|
||||
order,
|
||||
onlyCreations,
|
||||
)
|
||||
|
||||
// find involved users; I am involved
|
||||
const involvedUserIds: number[] = [user.id]
|
||||
userTransactions.forEach((transaction: dbTransaction) => {
|
||||
if (transaction.linkedUserId && !involvedUserIds.includes(transaction.linkedUserId)) {
|
||||
involvedUserIds.push(transaction.linkedUserId)
|
||||
}
|
||||
})
|
||||
// We need to show the name for deleted users for old transactions
|
||||
const involvedDbUsers = await dbUser
|
||||
.createQueryBuilder()
|
||||
.withDeleted()
|
||||
.where('id IN (:...userIds)', { userIds: involvedUserIds })
|
||||
.getMany()
|
||||
const involvedUsers = involvedDbUsers.map((u) => new User(u))
|
||||
|
||||
const self = new User(user)
|
||||
const transactions: Transaction[] = []
|
||||
|
||||
// decay transaction
|
||||
if (!onlyCreations && currentPage === 1 && order === Order.DESC) {
|
||||
transactions.push(
|
||||
virtualDecayTransaction(lastTransaction.balance, lastTransaction.balanceDate, now, self),
|
||||
)
|
||||
}
|
||||
|
||||
// transactions
|
||||
userTransactions.forEach((userTransaction) => {
|
||||
const linkedUser =
|
||||
userTransaction.typeId === TransactionTypeId.CREATION
|
||||
? communityUser
|
||||
: involvedUsers.find((u) => u.id === userTransaction.linkedUserId)
|
||||
transactions.push(new Transaction(userTransaction, self, linkedUser))
|
||||
})
|
||||
|
||||
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
|
||||
const toHoldAvailable = await transactionLinkRepository.sumAmountToHoldAvailable(user.id, now)
|
||||
|
||||
// Construct Result
|
||||
return new TransactionList(
|
||||
calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance.minus(
|
||||
toHoldAvailable.toString(),
|
||||
),
|
||||
transactions,
|
||||
userTransactionsCount,
|
||||
balanceGDT,
|
||||
)
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.SEND_COINS])
|
||||
@ -303,7 +155,9 @@ export class TransactionResolver {
|
||||
throw new Error('invalid sender public key')
|
||||
}
|
||||
// validate amount
|
||||
if (!hasUserAmount(senderUser, amount)) {
|
||||
const receivedCallDate = new Date()
|
||||
const sendBalance = await calculateBalance(senderUser.id, amount.mul(-1), receivedCallDate)
|
||||
if (!sendBalance) {
|
||||
throw new Error("user hasn't enough GDD or amount is < 0")
|
||||
}
|
||||
|
||||
@ -319,90 +173,51 @@ export class TransactionResolver {
|
||||
throw new Error('invalid recipient public key')
|
||||
}
|
||||
|
||||
const centAmount = Math.trunc(amount * 10000)
|
||||
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
await queryRunner.startTransaction('READ UNCOMMITTED')
|
||||
try {
|
||||
// transaction
|
||||
const transaction = new dbTransaction()
|
||||
transaction.transactionTypeId = TransactionTypeId.SEND
|
||||
transaction.memo = memo
|
||||
transaction.userId = senderUser.id
|
||||
transaction.pubkey = senderUser.pubKey
|
||||
transaction.sendReceiverUserId = recipientUser.id
|
||||
transaction.sendReceiverPublicKey = recipientUser.pubKey
|
||||
transaction.amount = BigInt(centAmount)
|
||||
const transactionSend = new dbTransaction()
|
||||
transactionSend.typeId = TransactionTypeId.SEND
|
||||
transactionSend.memo = memo
|
||||
transactionSend.userId = senderUser.id
|
||||
transactionSend.linkedUserId = recipientUser.id
|
||||
transactionSend.amount = amount.mul(-1)
|
||||
transactionSend.balance = sendBalance.balance
|
||||
transactionSend.balanceDate = receivedCallDate
|
||||
transactionSend.decay = sendBalance.decay.decay
|
||||
transactionSend.decayStart = sendBalance.decay.start
|
||||
transactionSend.previous = sendBalance.lastTransactionId
|
||||
await queryRunner.manager.insert(dbTransaction, transactionSend)
|
||||
|
||||
await queryRunner.manager.insert(dbTransaction, transaction)
|
||||
const transactionReceive = new dbTransaction()
|
||||
transactionReceive.typeId = TransactionTypeId.RECEIVE
|
||||
transactionReceive.memo = memo
|
||||
transactionReceive.userId = recipientUser.id
|
||||
transactionReceive.linkedUserId = senderUser.id
|
||||
transactionReceive.amount = amount
|
||||
const receiveBalance = await calculateBalance(recipientUser.id, amount, receivedCallDate)
|
||||
transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount
|
||||
transactionReceive.balanceDate = receivedCallDate
|
||||
transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0)
|
||||
transactionReceive.decayStart = receiveBalance ? receiveBalance.decay.start : null
|
||||
transactionReceive.previous = receiveBalance ? receiveBalance.lastTransactionId : null
|
||||
transactionReceive.linkedTransactionId = transactionSend.id
|
||||
await queryRunner.manager.insert(dbTransaction, transactionReceive)
|
||||
|
||||
// Insert Transaction: sender - amount
|
||||
const senderUserTransactionBalance = await addUserTransaction(
|
||||
senderUser,
|
||||
transaction,
|
||||
-centAmount,
|
||||
queryRunner,
|
||||
)
|
||||
|
||||
// Insert Transaction: recipient + amount
|
||||
const recipiantUserTransactionBalance = await addUserTransaction(
|
||||
recipientUser,
|
||||
transaction,
|
||||
centAmount,
|
||||
queryRunner,
|
||||
)
|
||||
|
||||
// Update Balance: sender - amount
|
||||
const senderStateBalance = await updateStateBalance(
|
||||
senderUser,
|
||||
-centAmount,
|
||||
transaction.received,
|
||||
queryRunner,
|
||||
)
|
||||
|
||||
// Update Balance: recipiant + amount
|
||||
const recipiantStateBalance = await updateStateBalance(
|
||||
recipientUser,
|
||||
centAmount,
|
||||
transaction.received,
|
||||
queryRunner,
|
||||
)
|
||||
|
||||
if (senderStateBalance.amount !== senderUserTransactionBalance.balance) {
|
||||
throw new Error('db data corrupted, sender')
|
||||
}
|
||||
if (recipiantStateBalance.amount !== recipiantUserTransactionBalance.balance) {
|
||||
throw new Error('db data corrupted, recipiant')
|
||||
}
|
||||
|
||||
// TODO: WTF?
|
||||
// I just assume that due to implicit type conversion the decimal places were cut.
|
||||
// Using `Math.trunc` to simulate this behaviour
|
||||
transaction.sendSenderFinalBalance = BigInt(Math.trunc(senderStateBalance.amount))
|
||||
|
||||
await queryRunner.manager.save(transaction).catch((error) => {
|
||||
throw new Error('error saving transaction with tx hash: ' + error)
|
||||
})
|
||||
// Save linked transaction id for send
|
||||
transactionSend.linkedTransactionId = transactionReceive.id
|
||||
await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend)
|
||||
|
||||
await queryRunner.commitTransaction()
|
||||
} catch (e) {
|
||||
await queryRunner.rollbackTransaction()
|
||||
// TODO: This is broken code - we should never correct an autoincrement index in production
|
||||
// according to dario it is required tho to properly work. The index of the table is used as
|
||||
// index for the transaction which requires a chain without gaps
|
||||
const count = await queryRunner.manager.count(dbTransaction)
|
||||
// fix autoincrement value which seems not effected from rollback
|
||||
await queryRunner
|
||||
.query('ALTER TABLE `transactions` auto_increment = ?', [count])
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('problems with reset auto increment: %o', error)
|
||||
})
|
||||
throw e
|
||||
throw new Error(`Transaction was not successful: ${e}`)
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
}
|
||||
|
||||
// send notification email
|
||||
// TODO: translate
|
||||
await sendTransactionReceivedEmail({
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import { testEnvironment, resetEntities, createUser } from '@test/helpers'
|
||||
import { createUserMutation, setPasswordMutation } from '@test/graphql'
|
||||
import gql from 'graphql-tag'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import createServer from '../../server/createServer'
|
||||
import { resetDB, initialize } from '@dbTools/helpers'
|
||||
import { resetDB } from '@dbTools/helpers'
|
||||
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
|
||||
import { User } from '@entity/User'
|
||||
import CONFIG from '../../config'
|
||||
import { sendAccountActivationEmail } from '../../mailer/sendAccountActivationEmail'
|
||||
// import { klicktippSignIn } from '../../apis/KlicktippController'
|
||||
import CONFIG from '@/config'
|
||||
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
|
||||
// import { klicktippSignIn } from '@/apis/KlicktippController'
|
||||
|
||||
jest.setTimeout(10000)
|
||||
jest.setTimeout(1000000)
|
||||
|
||||
jest.mock('../../mailer/sendAccountActivationEmail', () => {
|
||||
jest.mock('@/mailer/sendAccountActivationEmail', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
sendAccountActivationEmail: jest.fn(),
|
||||
@ -22,7 +22,7 @@ jest.mock('../../mailer/sendAccountActivationEmail', () => {
|
||||
})
|
||||
|
||||
/*
|
||||
jest.mock('../../apis/KlicktippController', () => {
|
||||
jest.mock('@/apis/KlicktippController', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
klicktippSignIn: jest.fn(),
|
||||
@ -30,15 +30,25 @@ jest.mock('../../apis/KlicktippController', () => {
|
||||
})
|
||||
*/
|
||||
|
||||
let mutate: any
|
||||
let con: any
|
||||
let token: string
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const headerPushMock = jest.fn((t) => (token = t.value))
|
||||
|
||||
const context = {
|
||||
setHeaders: {
|
||||
push: headerPushMock,
|
||||
forEach: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
let mutate: any, query: any, con: any
|
||||
|
||||
beforeAll(async () => {
|
||||
const server = await createServer({})
|
||||
con = server.con
|
||||
mutate = createTestClient(server.apollo).mutate
|
||||
await initialize()
|
||||
await resetDB()
|
||||
const testEnv = await testEnvironment(context)
|
||||
mutate = testEnv.mutate
|
||||
query = testEnv.query
|
||||
con = testEnv.con
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@ -56,33 +66,16 @@ describe('UserResolver', () => {
|
||||
publisherId: 1234,
|
||||
}
|
||||
|
||||
const mutation = gql`
|
||||
mutation (
|
||||
$email: String!
|
||||
$firstName: String!
|
||||
$lastName: String!
|
||||
$language: String!
|
||||
$publisherId: Int
|
||||
) {
|
||||
createUser(
|
||||
email: $email
|
||||
firstName: $firstName
|
||||
lastName: $lastName
|
||||
language: $language
|
||||
publisherId: $publisherId
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
let result: any
|
||||
let emailOptIn: string
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await mutate({ mutation, variables })
|
||||
jest.clearAllMocks()
|
||||
result = await mutate({ mutation: createUserMutation, variables })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await resetDB()
|
||||
await resetEntities([User, LoginEmailOptIn])
|
||||
})
|
||||
|
||||
it('returns success', () => {
|
||||
@ -150,7 +143,7 @@ describe('UserResolver', () => {
|
||||
|
||||
describe('email already exists', () => {
|
||||
it('throws an error', async () => {
|
||||
await expect(mutate({ mutation, variables })).resolves.toEqual(
|
||||
await expect(mutate({ mutation: createUserMutation, variables })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('User already exists.')],
|
||||
}),
|
||||
@ -161,7 +154,7 @@ describe('UserResolver', () => {
|
||||
describe('unknown language', () => {
|
||||
it('sets "de" as default language', async () => {
|
||||
await mutate({
|
||||
mutation,
|
||||
mutation: createUserMutation,
|
||||
variables: { ...variables, email: 'bibi@bloxberg.de', language: 'es' },
|
||||
})
|
||||
await expect(User.find()).resolves.toEqual(
|
||||
@ -178,7 +171,7 @@ describe('UserResolver', () => {
|
||||
describe('no publisher id', () => {
|
||||
it('sets publisher id to null', async () => {
|
||||
await mutate({
|
||||
mutation,
|
||||
mutation: createUserMutation,
|
||||
variables: { ...variables, email: 'raeuber@hotzenplotz.de', publisherId: undefined },
|
||||
})
|
||||
await expect(User.find()).resolves.toEqual(
|
||||
@ -194,24 +187,6 @@ describe('UserResolver', () => {
|
||||
})
|
||||
|
||||
describe('setPassword', () => {
|
||||
const createUserMutation = gql`
|
||||
mutation (
|
||||
$email: String!
|
||||
$firstName: String!
|
||||
$lastName: String!
|
||||
$language: String!
|
||||
$publisherId: Int
|
||||
) {
|
||||
createUser(
|
||||
email: $email
|
||||
firstName: $firstName
|
||||
lastName: $lastName
|
||||
language: $language
|
||||
publisherId: $publisherId
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
const createUserVariables = {
|
||||
email: 'peter@lustig.de',
|
||||
firstName: 'Peter',
|
||||
@ -220,11 +195,6 @@ describe('UserResolver', () => {
|
||||
publisherId: 1234,
|
||||
}
|
||||
|
||||
const setPasswordMutation = gql`
|
||||
mutation ($code: String!, $password: String!) {
|
||||
setPassword(code: $code, password: $password)
|
||||
}
|
||||
`
|
||||
let result: any
|
||||
let emailOptIn: string
|
||||
|
||||
@ -243,7 +213,7 @@ describe('UserResolver', () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await resetDB()
|
||||
await resetEntities([User, LoginEmailOptIn])
|
||||
})
|
||||
|
||||
it('sets email checked to true', () => {
|
||||
@ -286,7 +256,7 @@ describe('UserResolver', () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await resetDB()
|
||||
await resetEntities([User, LoginEmailOptIn])
|
||||
})
|
||||
|
||||
it('throws an error', () => {
|
||||
@ -312,7 +282,7 @@ describe('UserResolver', () => {
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await resetDB()
|
||||
await resetEntities([User, LoginEmailOptIn])
|
||||
})
|
||||
|
||||
it('throws an error', () => {
|
||||
@ -324,4 +294,93 @@ describe('UserResolver', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
const loginQuery = gql`
|
||||
query ($email: String!, $password: String!, $publisherId: Int) {
|
||||
login(email: $email, password: $password, publisherId: $publisherId) {
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
language
|
||||
coinanimation
|
||||
klickTipp {
|
||||
newsletterState
|
||||
}
|
||||
hasElopage
|
||||
publisherId
|
||||
isAdmin
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const variables = {
|
||||
email: 'peter@lustig.de',
|
||||
password: 'Aa12345_',
|
||||
publisherId: 1234,
|
||||
}
|
||||
|
||||
let result: User
|
||||
|
||||
afterAll(async () => {
|
||||
await resetEntities([User, LoginEmailOptIn])
|
||||
})
|
||||
|
||||
describe('no users in database', () => {
|
||||
beforeAll(async () => {
|
||||
result = await query({ query: loginQuery, variables })
|
||||
})
|
||||
|
||||
it('throws an error', () => {
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('No user with this credentials')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user is in database', () => {
|
||||
beforeAll(async () => {
|
||||
await createUser(mutate, {
|
||||
email: 'peter@lustig.de',
|
||||
firstName: 'Peter',
|
||||
lastName: 'Lustig',
|
||||
language: 'de',
|
||||
publisherId: 1234,
|
||||
})
|
||||
result = await query({ query: loginQuery, variables })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await resetEntities([User, LoginEmailOptIn])
|
||||
})
|
||||
|
||||
it('returns the user object', () => {
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
login: {
|
||||
coinanimation: true,
|
||||
email: 'peter@lustig.de',
|
||||
firstName: 'Peter',
|
||||
hasElopage: false,
|
||||
isAdmin: false,
|
||||
klickTipp: {
|
||||
newsletterState: false,
|
||||
},
|
||||
language: 'de',
|
||||
lastName: 'Lustig',
|
||||
publisherId: 1234,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('sets the token in the header', () => {
|
||||
expect(headerPushMock).toBeCalledWith({ key: 'token', value: expect.any(String) })
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -4,24 +4,24 @@
|
||||
import fs from 'fs'
|
||||
import { Resolver, Query, Args, Arg, Authorized, Ctx, UseMiddleware, Mutation } from 'type-graphql'
|
||||
import { getConnection, getCustomRepository, QueryRunner } from '@dbTools/typeorm'
|
||||
import CONFIG from '../../config'
|
||||
import { User } from '../model/User'
|
||||
import CONFIG from '@/config'
|
||||
import { User } from '@model/User'
|
||||
import { User as DbUser } from '@entity/User'
|
||||
import { encode } from '../../auth/JWT'
|
||||
import CreateUserArgs from '../arg/CreateUserArgs'
|
||||
import UnsecureLoginArgs from '../arg/UnsecureLoginArgs'
|
||||
import UpdateUserInfosArgs from '../arg/UpdateUserInfosArgs'
|
||||
import { klicktippNewsletterStateMiddleware } from '../../middleware/klicktippMiddleware'
|
||||
import { UserSettingRepository } from '../../typeorm/repository/UserSettingRepository'
|
||||
import { Setting } from '../enum/Setting'
|
||||
import { UserRepository } from '../../typeorm/repository/User'
|
||||
import { encode } from '@/auth/JWT'
|
||||
import CreateUserArgs from '@arg/CreateUserArgs'
|
||||
import UnsecureLoginArgs from '@arg/UnsecureLoginArgs'
|
||||
import UpdateUserInfosArgs from '@arg/UpdateUserInfosArgs'
|
||||
import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddleware'
|
||||
import { UserSettingRepository } from '@repository/UserSettingRepository'
|
||||
import { Setting } from '@enum/Setting'
|
||||
import { UserRepository } from '@repository/User'
|
||||
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
|
||||
import { sendResetPasswordEmail } from '../../mailer/sendResetPasswordEmail'
|
||||
import { sendAccountActivationEmail } from '../../mailer/sendAccountActivationEmail'
|
||||
import { klicktippSignIn } from '../../apis/KlicktippController'
|
||||
import { RIGHTS } from '../../auth/RIGHTS'
|
||||
import { ROLE_ADMIN } from '../../auth/ROLES'
|
||||
import { hasElopageBuys } from '../../util/hasElopageBuys'
|
||||
import { sendResetPasswordEmail } from '@/mailer/sendResetPasswordEmail'
|
||||
import { sendAccountActivationEmail } from '@/mailer/sendAccountActivationEmail'
|
||||
import { klicktippSignIn } from '@/apis/KlicktippController'
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { ROLE_ADMIN } from '@/auth/ROLES'
|
||||
import { hasElopageBuys } from '@/util/hasElopageBuys'
|
||||
import { ServerUser } from '@entity/ServerUser'
|
||||
|
||||
const EMAIL_OPT_IN_RESET_PASSWORD = 2
|
||||
@ -216,14 +216,8 @@ export class UserResolver {
|
||||
// TODO refactor and do not have duplicate code with login(see below)
|
||||
const userRepository = getCustomRepository(UserRepository)
|
||||
const userEntity = await userRepository.findByPubkeyHex(context.pubKey)
|
||||
const user = new User()
|
||||
user.id = userEntity.id
|
||||
user.email = userEntity.email
|
||||
user.firstName = userEntity.firstName
|
||||
user.lastName = userEntity.lastName
|
||||
user.pubkey = userEntity.pubKey.toString('hex')
|
||||
user.language = userEntity.language
|
||||
|
||||
const user = new User(userEntity)
|
||||
// user.pubkey = userEntity.pubKey.toString('hex')
|
||||
// Elopage Status & Stored PublisherId
|
||||
user.hasElopage = await this.hasElopage(context)
|
||||
|
||||
@ -252,7 +246,7 @@ export class UserResolver {
|
||||
throw new Error('No user with this credentials')
|
||||
})
|
||||
if (dbUser.deletedAt) {
|
||||
throw new Error('This user was permanently disabled. Contact support for questions.')
|
||||
throw new Error('This user was permanently deleted. Contact support for questions.')
|
||||
}
|
||||
if (!dbUser.emailChecked) {
|
||||
throw new Error('User email not validated')
|
||||
@ -271,12 +265,9 @@ export class UserResolver {
|
||||
throw new Error('No user with this credentials')
|
||||
}
|
||||
|
||||
const user = new User()
|
||||
user.id = dbUser.id
|
||||
user.email = email
|
||||
user.firstName = dbUser.firstName
|
||||
user.lastName = dbUser.lastName
|
||||
user.pubkey = dbUser.pubKey.toString('hex')
|
||||
const user = new User(dbUser)
|
||||
// user.email = email
|
||||
// user.pubkey = dbUser.pubKey.toString('hex')
|
||||
user.language = dbUser.language
|
||||
|
||||
// Elopage Status & Stored PublisherId
|
||||
@ -335,7 +326,7 @@ export class UserResolver {
|
||||
}
|
||||
|
||||
// Validate email unique
|
||||
// TODO: i can register an email in upper/lower case twice
|
||||
email = email.trim().toLowerCase()
|
||||
// TODO we cannot use repository.count(), since it does not allow to specify if you want to include the soft deletes
|
||||
const userFound = await DbUser.findOne({ email }, { withDeleted: true })
|
||||
if (userFound) {
|
||||
@ -408,6 +399,7 @@ export class UserResolver {
|
||||
@Authorized([RIGHTS.SEND_ACTIVATION_EMAIL])
|
||||
@Mutation(() => Boolean)
|
||||
async sendActivationEmail(@Arg('email') email: string): Promise<boolean> {
|
||||
email = email.trim().toLowerCase()
|
||||
const user = await DbUser.findOneOrFail({ email: email })
|
||||
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
@ -448,7 +440,7 @@ export class UserResolver {
|
||||
@Query(() => Boolean)
|
||||
async sendResetPasswordEmail(@Arg('email') email: string): Promise<boolean> {
|
||||
// TODO: this has duplicate code with createUser
|
||||
|
||||
email = email.trim().toLowerCase()
|
||||
const user = await DbUser.findOneOrFail({ email })
|
||||
|
||||
const optInCode = await getOptInCode(user.id)
|
||||
@ -600,6 +592,13 @@ export class UserResolver {
|
||||
}
|
||||
|
||||
if (password && passwordNew) {
|
||||
// Validate Password
|
||||
if (!isPassword(passwordNew)) {
|
||||
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!',
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: This had some error cases defined - like missing private key. This is no longer checked.
|
||||
const oldPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, password)
|
||||
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) {
|
||||
|
||||
23
backend/src/graphql/scalar/Decimal.ts
Normal file
23
backend/src/graphql/scalar/Decimal.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { GraphQLScalarType, Kind } from 'graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
export default new GraphQLScalarType({
|
||||
name: 'Decimal',
|
||||
description: 'The `Decimal` scalar type to represent currency values',
|
||||
|
||||
serialize(value: Decimal) {
|
||||
return value.toString()
|
||||
},
|
||||
|
||||
parseValue(value) {
|
||||
return new Decimal(value)
|
||||
},
|
||||
|
||||
parseLiteral(ast) {
|
||||
if (ast.kind !== Kind.STRING) {
|
||||
throw new TypeError(`${String(ast)} is not a valid decimal value.`)
|
||||
}
|
||||
|
||||
return new Decimal(ast.value)
|
||||
},
|
||||
})
|
||||
@ -3,11 +3,14 @@ import { buildSchema } from 'type-graphql'
|
||||
import path from 'path'
|
||||
|
||||
import isAuthorized from './directive/isAuthorized'
|
||||
import DecimalScalar from './scalar/Decimal'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
const schema = async (): Promise<GraphQLSchema> => {
|
||||
return buildSchema({
|
||||
resolvers: [path.join(__dirname, 'resolver', `!(*.test).{js,ts}`)],
|
||||
authChecker: isAuthorized,
|
||||
scalarsMap: [{ type: Decimal, scalar: DecimalScalar }],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { sendEMail } from './sendEMail'
|
||||
import { createTransport } from 'nodemailer'
|
||||
import CONFIG from '../config'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
CONFIG.EMAIL = false
|
||||
CONFIG.EMAIL_SMTP_URL = 'EMAIL_SMTP_URL'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { createTransport } from 'nodemailer'
|
||||
|
||||
import CONFIG from '../config'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
export const sendEMail = async (emailDef: {
|
||||
to: string
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { sendTransactionReceivedEmail } from './sendTransactionReceivedEmail'
|
||||
import { sendEMail } from './sendEMail'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
jest.mock('./sendEMail', () => {
|
||||
return {
|
||||
@ -16,7 +17,7 @@ describe('sendTransactionReceivedEmail', () => {
|
||||
recipientFirstName: 'Peter',
|
||||
recipientLastName: 'Lustig',
|
||||
email: 'peter@lustig.de',
|
||||
amount: 42.0,
|
||||
amount: new Decimal(42.0),
|
||||
memo: 'Vielen herzlichen Dank für den neuen Hexenbesen!',
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { sendEMail } from './sendEMail'
|
||||
import { transactionReceived } from './text/transactionReceived'
|
||||
|
||||
@ -7,7 +8,7 @@ export const sendTransactionReceivedEmail = (data: {
|
||||
recipientFirstName: string
|
||||
recipientLastName: string
|
||||
email: string
|
||||
amount: number
|
||||
amount: Decimal
|
||||
memo: string
|
||||
}): Promise<boolean> => {
|
||||
return sendEMail({
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
export const transactionReceived = {
|
||||
de: {
|
||||
subject: 'Gradido Überweisung',
|
||||
@ -7,7 +9,7 @@ export const transactionReceived = {
|
||||
recipientFirstName: string
|
||||
recipientLastName: string
|
||||
email: string
|
||||
amount: number
|
||||
amount: Decimal
|
||||
memo: string
|
||||
}): string =>
|
||||
`Hallo ${data.recipientFirstName} ${data.recipientLastName}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { MiddlewareFn } from 'type-graphql'
|
||||
import { /* klicktippSignIn, */ getKlickTippUser } from '../apis/KlicktippController'
|
||||
import { KlickTipp } from '../graphql/model/KlickTipp'
|
||||
import CONFIG from '../config/index'
|
||||
import { /* klicktippSignIn, */ getKlickTippUser } from '@/apis/KlicktippController'
|
||||
import { KlickTipp } from '@model/KlickTipp'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
// export const klicktippRegistrationMiddleware: MiddlewareFn = async (
|
||||
// // Only for demo
|
||||
|
||||
@ -5,8 +5,8 @@ import { ApolloServer } from 'apollo-server-express'
|
||||
import express, { Express } from 'express'
|
||||
|
||||
// database
|
||||
import connection from '../typeorm/connection'
|
||||
import { checkDBVersion } from '../typeorm/DBVersion'
|
||||
import connection from '@/typeorm/connection'
|
||||
import { checkDBVersion } from '@/typeorm/DBVersion'
|
||||
|
||||
// server
|
||||
import cors from './cors'
|
||||
@ -14,13 +14,13 @@ import serverContext from './context'
|
||||
import plugins from './plugins'
|
||||
|
||||
// config
|
||||
import CONFIG from '../config'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
// graphql
|
||||
import schema from '../graphql/schema'
|
||||
import schema from '@/graphql/schema'
|
||||
|
||||
// webhooks
|
||||
import { elopageWebhook } from '../webhook/elopage'
|
||||
import { elopageWebhook } from '@/webhook/elopage'
|
||||
import { Connection } from '@dbTools/typeorm'
|
||||
|
||||
// TODO implement
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// TODO This is super weird - since the entities are defined in another project they have their own globals.
|
||||
// We cannot use our connection here, but must use the external typeorm installation
|
||||
import { Connection, createConnection, FileLogger } from '@dbTools/typeorm'
|
||||
import CONFIG from '../config'
|
||||
import CONFIG from '@/config'
|
||||
import { entities } from '@entity/index'
|
||||
|
||||
const connection = async (): Promise<Connection | null> => {
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { EntityRepository, Repository } from '@dbTools/typeorm'
|
||||
import { Order } from '../../graphql/enum/Order'
|
||||
import { UserTransaction } from '@entity/UserTransaction'
|
||||
import { TransactionTypeId } from '../../graphql/enum/TransactionTypeId'
|
||||
import { Transaction } from '@entity/Transaction'
|
||||
import { Order } from '@enum/Order'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
|
||||
@EntityRepository(UserTransaction)
|
||||
export class UserTransactionRepository extends Repository<UserTransaction> {
|
||||
@EntityRepository(Transaction)
|
||||
export class TransactionRepository extends Repository<Transaction> {
|
||||
findByUserPaged(
|
||||
userId: number,
|
||||
limit: number,
|
||||
offset: number,
|
||||
order: Order,
|
||||
onlyCreation?: boolean,
|
||||
): Promise<[UserTransaction[], number]> {
|
||||
): Promise<[Transaction[], number]> {
|
||||
if (onlyCreation) {
|
||||
return this.createQueryBuilder('userTransaction')
|
||||
.where('userTransaction.userId = :userId', { userId })
|
||||
.andWhere('userTransaction.transactionTypeId = :transactionTypeId', {
|
||||
transactionTypeId: TransactionTypeId.CREATION,
|
||||
.andWhere('userTransaction.typeId = :typeId', {
|
||||
typeId: TransactionTypeId.CREATION,
|
||||
})
|
||||
.orderBy('userTransaction.balanceDate', order)
|
||||
.limit(limit)
|
||||
@ -31,10 +31,10 @@ export class UserTransactionRepository extends Repository<UserTransaction> {
|
||||
.getManyAndCount()
|
||||
}
|
||||
|
||||
findLastForUser(userId: number): Promise<UserTransaction | undefined> {
|
||||
findLastForUser(userId: number): Promise<Transaction | undefined> {
|
||||
return this.createQueryBuilder('userTransaction')
|
||||
.where('userTransaction.userId = :userId', { userId })
|
||||
.orderBy('userTransaction.transactionId', 'DESC')
|
||||
.orderBy('userTransaction.balanceDate', 'DESC')
|
||||
.getOne()
|
||||
}
|
||||
}
|
||||
16
backend/src/typeorm/repository/TransactionLink.ts
Normal file
16
backend/src/typeorm/repository/TransactionLink.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Repository, EntityRepository } from '@dbTools/typeorm'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
@EntityRepository(dbTransactionLink)
|
||||
export class TransactionLinkRepository extends Repository<dbTransactionLink> {
|
||||
async sumAmountToHoldAvailable(userId: number, date: Date): Promise<Decimal> {
|
||||
const { sum } = await this.createQueryBuilder('transactionLinks')
|
||||
.select('SUM(transactionLinks.holdAvailableAmount)', 'sum')
|
||||
.where('transactionLinks.userId = :userId', { userId })
|
||||
.andWhere('transactionLinks.redeemedAt is NULL')
|
||||
.andWhere('transactionLinks.validUntil > :date', { date })
|
||||
.getRawOne()
|
||||
return sum ? new Decimal(sum) : new Decimal(0)
|
||||
}
|
||||
}
|
||||
@ -9,14 +9,6 @@ export class UserRepository extends Repository<User> {
|
||||
.getOneOrFail()
|
||||
}
|
||||
|
||||
async getUsersIndiced(userIds: number[]): Promise<User[]> {
|
||||
return this.createQueryBuilder('user')
|
||||
.withDeleted() // We need to show the name for deleted users for old transactions
|
||||
.select(['user.id', 'user.firstName', 'user.lastName', 'user.email'])
|
||||
.where('user.id IN (:...userIds)', { userIds })
|
||||
.getMany()
|
||||
}
|
||||
|
||||
async findBySearchCriteriaPagedFiltered(
|
||||
select: string[],
|
||||
searchCriteria: string,
|
||||
@ -24,7 +16,7 @@ export class UserRepository extends Repository<User> {
|
||||
currentPage: number,
|
||||
pageSize: number,
|
||||
): Promise<[User[], number]> {
|
||||
return await this.createQueryBuilder('user')
|
||||
const query = await this.createQueryBuilder('user')
|
||||
.select(select)
|
||||
.withDeleted()
|
||||
.where(
|
||||
@ -39,7 +31,10 @@ export class UserRepository extends Repository<User> {
|
||||
)
|
||||
}),
|
||||
)
|
||||
.andWhere(filterCriteria)
|
||||
filterCriteria.forEach((filter) => {
|
||||
query.andWhere(filter)
|
||||
})
|
||||
return query
|
||||
.take(pageSize)
|
||||
.skip((currentPage - 1) * pageSize)
|
||||
.getManyAndCount()
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { EntityRepository, Repository } from '@dbTools/typeorm'
|
||||
import { UserSetting } from '@entity/UserSetting'
|
||||
import { Setting } from '../../graphql/enum/Setting'
|
||||
import { isStringBoolean } from '../../util/validate'
|
||||
import { Setting } from '@enum/Setting'
|
||||
import { isStringBoolean } from '@/util/validate'
|
||||
|
||||
@EntityRepository(UserSetting)
|
||||
export class UserSettingRepository extends Repository<UserSetting> {
|
||||
|
||||
44
backend/src/util/communityUser.ts
Normal file
44
backend/src/util/communityUser.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
|
||||
import { User as dbUser } from '@entity/User'
|
||||
import { User } from '@model/User'
|
||||
|
||||
const communityDbUser: dbUser = {
|
||||
id: -1,
|
||||
email: 'support@gradido.net',
|
||||
firstName: 'Gradido',
|
||||
lastName: 'Akademie',
|
||||
pubKey: Buffer.from(''),
|
||||
privKey: Buffer.from(''),
|
||||
deletedAt: null,
|
||||
password: BigInt(0),
|
||||
emailHash: Buffer.from(''),
|
||||
createdAt: new Date(),
|
||||
emailChecked: false,
|
||||
language: '',
|
||||
publisherId: 0,
|
||||
passphrase: '',
|
||||
settings: [],
|
||||
hasId: function (): boolean {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
save: function (options?: SaveOptions): Promise<dbUser> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
remove: function (options?: RemoveOptions): Promise<dbUser> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
softRemove: function (options?: SaveOptions): Promise<dbUser> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
recover: function (options?: SaveOptions): Promise<dbUser> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
reload: function (): Promise<void> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
}
|
||||
const communityUser = new User(communityDbUser)
|
||||
|
||||
export { communityDbUser, communityUser }
|
||||
@ -1,41 +1,41 @@
|
||||
import Decimal from 'decimal.js-light'
|
||||
import 'reflect-metadata' // This might be wise to load in a test setup file
|
||||
import { decayFormula, calculateDecay } from './decay'
|
||||
|
||||
describe('utils/decay', () => {
|
||||
describe('decayFormula', () => {
|
||||
it('has base 0.99999997802044727', () => {
|
||||
const amount = 1.0
|
||||
const amount = new Decimal(1.0)
|
||||
const seconds = 1
|
||||
expect(decayFormula(amount, seconds)).toBe(0.99999997802044727)
|
||||
})
|
||||
// Not sure if the following skiped tests make sence!?
|
||||
it('has negative decay?', async () => {
|
||||
const amount = -1.0
|
||||
const seconds = 1
|
||||
expect(await decayFormula(amount, seconds)).toBe(-0.99999997802044727)
|
||||
// TODO: toString() was required, we could not compare two decimals
|
||||
expect(decayFormula(amount, seconds).toString()).toBe('0.999999978035040489732012')
|
||||
})
|
||||
it('has correct backward calculation', async () => {
|
||||
const amount = 1.0
|
||||
const amount = new Decimal(1.0)
|
||||
const seconds = -1
|
||||
expect(await decayFormula(amount, seconds)).toBe(1.0000000219795533)
|
||||
expect(decayFormula(amount, seconds).toString()).toBe('1.000000021964959992727444')
|
||||
})
|
||||
// not possible, nodejs hasn't enough accuracy
|
||||
it('has correct forward calculation', async () => {
|
||||
const amount = 1.0 / 0.99999997802044727
|
||||
// we get pretty close, but not exact here, skipping
|
||||
it.skip('has correct forward calculation', async () => {
|
||||
const amount = new Decimal(1.0).div(
|
||||
new Decimal('0.99999997803504048973201202316767079413460520837376'),
|
||||
)
|
||||
const seconds = 1
|
||||
expect(await decayFormula(amount, seconds)).toBe(1.0)
|
||||
expect(decayFormula(amount, seconds).toString()).toBe('1.0')
|
||||
})
|
||||
})
|
||||
it.skip('has base 0.99999997802044727', async () => {
|
||||
it('has base 0.99999997802044727', async () => {
|
||||
const now = new Date()
|
||||
now.setSeconds(1)
|
||||
const oneSecondAgo = new Date(now.getTime())
|
||||
oneSecondAgo.setSeconds(0)
|
||||
expect(await calculateDecay(1.0, oneSecondAgo, now)).toBe(0.99999997802044727)
|
||||
expect(calculateDecay(new Decimal(1.0), oneSecondAgo, now).balance.toString()).toBe(
|
||||
'0.999999978035040489732012',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns input amount when from and to is the same', async () => {
|
||||
const now = new Date()
|
||||
expect((await calculateDecay(100.0, now, now)).balance).toBe(100.0)
|
||||
expect(calculateDecay(new Decimal(100.0), now, now).balance.toString()).toBe('100')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,45 +1,57 @@
|
||||
import CONFIG from '../config'
|
||||
import { Decay } from '../graphql/model/Decay'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import CONFIG from '@/config'
|
||||
import { Decay } from '@model/Decay'
|
||||
|
||||
function decayFormula(amount: number, seconds: number): number {
|
||||
return amount * Math.pow(0.99999997802044727, seconds) // This number represents 50% decay a year
|
||||
// TODO: externalize all those definitions and functions into an external decay library
|
||||
|
||||
function decayFormula(value: Decimal, seconds: number): Decimal {
|
||||
// TODO why do we need to convert this here to a stting to work properly?
|
||||
return value.mul(
|
||||
new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
function calculateDecay(amount: number, from: Date, to: Date): Decay {
|
||||
function calculateDecay(
|
||||
amount: Decimal,
|
||||
from: Date,
|
||||
to: Date,
|
||||
startBlock: Date = CONFIG.DECAY_START_TIME,
|
||||
): Decay {
|
||||
const fromMs = from.getTime()
|
||||
const toMs = to.getTime()
|
||||
const decayStartBlockMs = CONFIG.DECAY_START_TIME.getTime()
|
||||
const startBlockMs = startBlock.getTime()
|
||||
|
||||
if (toMs < fromMs) {
|
||||
throw new Error('to < from, reverse decay calculation is invalid')
|
||||
}
|
||||
|
||||
// Initialize with no decay
|
||||
const decay = new Decay({
|
||||
const decay: Decay = {
|
||||
balance: amount,
|
||||
decayStart: null,
|
||||
decayEnd: null,
|
||||
decayDuration: 0,
|
||||
decayStartBlock: (decayStartBlockMs / 1000).toString(),
|
||||
})
|
||||
decay: new Decimal(0),
|
||||
start: null,
|
||||
end: null,
|
||||
duration: null,
|
||||
}
|
||||
|
||||
// decay started after end date; no decay
|
||||
if (decayStartBlockMs > toMs) {
|
||||
if (startBlockMs > toMs) {
|
||||
return decay
|
||||
}
|
||||
// decay started before start date; decay for full duration
|
||||
else if (decayStartBlockMs < fromMs) {
|
||||
decay.decayStart = (fromMs / 1000).toString()
|
||||
decay.decayDuration = (toMs - fromMs) / 1000
|
||||
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.decayStart = (decayStartBlockMs / 1000).toString()
|
||||
decay.decayDuration = (toMs - decayStartBlockMs) / 1000
|
||||
decay.start = startBlock
|
||||
decay.duration = (toMs - startBlockMs) / 1000
|
||||
}
|
||||
|
||||
decay.decayEnd = (toMs / 1000).toString()
|
||||
decay.balance = decayFormula(amount, decay.decayDuration)
|
||||
decay.end = to
|
||||
decay.balance = decayFormula(amount, decay.duration)
|
||||
decay.decay = decay.balance.minus(amount)
|
||||
return decay
|
||||
}
|
||||
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
import { roundCeilFrom4, roundFloorFrom4, roundCeilFrom2, roundFloorFrom2 } from './round'
|
||||
|
||||
describe('utils/round', () => {
|
||||
it('roundCeilFrom4', () => {
|
||||
const amount = 11617
|
||||
expect(roundCeilFrom4(amount)).toBe(1.17)
|
||||
})
|
||||
// Not sure if the following skiped tests make sence!?
|
||||
it('roundFloorFrom4', () => {
|
||||
const amount = 11617
|
||||
expect(roundFloorFrom4(amount)).toBe(1.16)
|
||||
})
|
||||
it('roundCeilFrom2', () => {
|
||||
const amount = 1216
|
||||
expect(roundCeilFrom2(amount)).toBe(13)
|
||||
})
|
||||
// not possible, nodejs hasn't enough accuracy
|
||||
it('roundFloorFrom2', () => {
|
||||
const amount = 1216
|
||||
expect(roundFloorFrom2(amount)).toBe(12)
|
||||
})
|
||||
})
|
||||
@ -1,17 +0,0 @@
|
||||
function roundCeilFrom4(decimal: number): number {
|
||||
return Math.ceil(decimal / 100) / 100
|
||||
}
|
||||
|
||||
function roundFloorFrom4(decimal: number): number {
|
||||
return Math.floor(decimal / 100) / 100
|
||||
}
|
||||
|
||||
function roundCeilFrom2(decimal: number): number {
|
||||
return Math.ceil(decimal / 100)
|
||||
}
|
||||
|
||||
function roundFloorFrom2(decimal: number): number {
|
||||
return Math.floor(decimal / 100)
|
||||
}
|
||||
|
||||
export { roundCeilFrom4, roundFloorFrom4, roundCeilFrom2, roundFloorFrom2 }
|
||||
@ -1,7 +1,9 @@
|
||||
import { User as dbUser } from '@entity/User'
|
||||
import { Balance as dbBalance } from '@entity/Balance'
|
||||
import { getRepository } from '@dbTools/typeorm'
|
||||
import { calculateDecay } from './decay'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { Transaction } from '@entity/Transaction'
|
||||
import { Decay } from '@model/Decay'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
||||
|
||||
function isStringBoolean(value: string): boolean {
|
||||
const lowerValue = value.toLowerCase()
|
||||
@ -15,14 +17,25 @@ function isHexPublicKey(publicKey: string): boolean {
|
||||
return /^[0-9A-Fa-f]{64}$/i.test(publicKey)
|
||||
}
|
||||
|
||||
async function hasUserAmount(user: dbUser, amount: number): Promise<boolean> {
|
||||
if (amount < 0) return false
|
||||
const balanceRepository = getRepository(dbBalance)
|
||||
const balance = await balanceRepository.findOne({ userId: user.id })
|
||||
if (!balance) return false
|
||||
async function calculateBalance(
|
||||
userId: number,
|
||||
amount: Decimal,
|
||||
time: Date,
|
||||
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> {
|
||||
const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } })
|
||||
if (!lastTransaction) return null
|
||||
|
||||
const decay = calculateDecay(balance.amount, balance.recordDate, new Date()).balance
|
||||
return decay > amount
|
||||
const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time)
|
||||
|
||||
// TODO why we have to use toString() here?
|
||||
const balance = decay.balance.add(amount.toString())
|
||||
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
|
||||
const toHoldAvailable = await transactionLinkRepository.sumAmountToHoldAvailable(userId, time)
|
||||
|
||||
if (balance.minus(toHoldAvailable.toString()).lessThan(0)) {
|
||||
return null
|
||||
}
|
||||
return { balance, lastTransactionId: lastTransaction.id, decay }
|
||||
}
|
||||
|
||||
export { isHexPublicKey, hasUserAmount, isStringBoolean }
|
||||
export { isHexPublicKey, calculateBalance, isStringBoolean }
|
||||
|
||||
52
backend/src/util/virtualDecayTransaction.ts
Normal file
52
backend/src/util/virtualDecayTransaction.ts
Normal file
@ -0,0 +1,52 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
|
||||
import { Transaction as dbTransaction } from '@entity/Transaction'
|
||||
import { calculateDecay } from './decay'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import { Transaction } from '@model/Transaction'
|
||||
import { User } from '@model/User'
|
||||
|
||||
const virtualDecayTransaction = (
|
||||
balance: Decimal,
|
||||
balanceDate: Date,
|
||||
time: Date = new Date(),
|
||||
user: User,
|
||||
): Transaction => {
|
||||
const decay = calculateDecay(balance, balanceDate, time)
|
||||
// const balance = decay.balance.minus(lastTransaction.balance)
|
||||
const decayDbTransaction: dbTransaction = {
|
||||
id: -1,
|
||||
userId: -1,
|
||||
previous: -1,
|
||||
typeId: TransactionTypeId.DECAY,
|
||||
amount: decay.decay ? decay.decay : new Decimal(0), // new Decimal(0), // this kinda is wrong, but helps with the frontend query
|
||||
balance: decay.balance,
|
||||
balanceDate: time,
|
||||
decay: decay.decay ? decay.decay : new Decimal(0),
|
||||
decayStart: decay.start,
|
||||
memo: '',
|
||||
creationDate: null,
|
||||
hasId: function (): boolean {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
save: function (options?: SaveOptions): Promise<dbTransaction> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
remove: function (options?: RemoveOptions): Promise<dbTransaction> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
softRemove: function (options?: SaveOptions): Promise<dbTransaction> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
recover: function (options?: SaveOptions): Promise<dbTransaction> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
reload: function (): Promise<void> {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
}
|
||||
return new Transaction(decayDbTransaction, user)
|
||||
}
|
||||
|
||||
export { virtualDecayTransaction }
|
||||
@ -28,7 +28,7 @@
|
||||
*/
|
||||
|
||||
import { LoginElopageBuys } from '@entity/LoginElopageBuys'
|
||||
import { UserResolver } from '../graphql/resolver/UserResolver'
|
||||
import { UserResolver } from '@/graphql/resolver/UserResolver'
|
||||
import { User as dbUser } from '@entity/User'
|
||||
|
||||
export const elopageWebhook = async (req: any, res: any): Promise<void> => {
|
||||
|
||||
25
backend/test/graphql.ts
Normal file
25
backend/test/graphql.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const createUserMutation = gql`
|
||||
mutation (
|
||||
$email: String!
|
||||
$firstName: String!
|
||||
$lastName: String!
|
||||
$language: String!
|
||||
$publisherId: Int
|
||||
) {
|
||||
createUser(
|
||||
email: $email
|
||||
firstName: $firstName
|
||||
lastName: $lastName
|
||||
language: $language
|
||||
publisherId: $publisherId
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
export const setPasswordMutation = gql`
|
||||
mutation ($code: String!, $password: String!) {
|
||||
setPassword(code: $code, password: $password)
|
||||
}
|
||||
`
|
||||
46
backend/test/helpers.ts
Normal file
46
backend/test/helpers.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { createTestClient } from 'apollo-server-testing'
|
||||
import createServer from '../src/server/createServer'
|
||||
import { resetDB, initialize } from '@dbTools/helpers'
|
||||
import { createUserMutation, setPasswordMutation } from './graphql'
|
||||
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
|
||||
import { User } from '@entity/User'
|
||||
|
||||
export const testEnvironment = async (context: any) => {
|
||||
const server = await createServer(context)
|
||||
const con = server.con
|
||||
const testClient = createTestClient(server.apollo)
|
||||
const mutate = testClient.mutate
|
||||
const query = testClient.query
|
||||
await initialize()
|
||||
await resetDB()
|
||||
return { mutate, query, con }
|
||||
}
|
||||
|
||||
export const resetEntity = async (entity: any) => {
|
||||
const items = await entity.find()
|
||||
if (items.length > 0) {
|
||||
const ids = items.map((i: any) => i.id)
|
||||
await entity.delete(ids)
|
||||
}
|
||||
}
|
||||
|
||||
export const resetEntities = async (entities: any[]) => {
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
await resetEntity(entities[i])
|
||||
}
|
||||
}
|
||||
|
||||
export const createUser = async (mutate: any, user: any) => {
|
||||
await mutate({ mutation: createUserMutation, variables: user })
|
||||
const dbUser = await User.findOne({ where: { email: user.email } })
|
||||
if (!dbUser) throw new Error('Ups, no user found')
|
||||
const optin = await LoginEmailOptIn.findOne(dbUser.id)
|
||||
if (!optin) throw new Error('Ups, no optin found')
|
||||
await mutate({
|
||||
mutation: setPasswordMutation,
|
||||
variables: { password: 'Aa12345_', code: optin.verificationCode },
|
||||
})
|
||||
}
|
||||
@ -47,8 +47,14 @@
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
"paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
"@/*": ["./src/*"],
|
||||
"@arg/*": ["./src/graphql/arg/*"],
|
||||
"@dbTools/*": ["../database/src/*"],
|
||||
"@entity/*": ["../database/entity/*"],
|
||||
"@dbTools/*": ["../database/src/*"]
|
||||
"@enum/*": ["./src/graphql/enum/*"],
|
||||
"@model/*": ["./src/graphql/model/*"],
|
||||
"@repository/*": ["./src/typeorm/repository/*"],
|
||||
"@test/*": ["./test/*"]
|
||||
},
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
|
||||
@ -1961,6 +1961,11 @@ debug@^3.2.6, debug@^3.2.7:
|
||||
dependencies:
|
||||
ms "^2.1.1"
|
||||
|
||||
decimal.js-light@^2.5.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934"
|
||||
integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==
|
||||
|
||||
decimal.js@^10.2.1:
|
||||
version "10.3.1"
|
||||
resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.3.1.tgz#d8c3a444a9c6774ba60ca6ad7261c3a94fd5e783"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm'
|
||||
import { Balance } from '../Balance'
|
||||
import { Balance } from './Balance'
|
||||
|
||||
// Moriz: I do not like the idea of having two user tables
|
||||
@Entity('state_users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
|
||||
|
||||
@Entity('transactions')
|
||||
export class Transaction extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('increment', { unsigned: true })
|
||||
id: number
|
||||
|
||||
@Column({ name: 'user_id', unsigned: true, nullable: false })
|
||||
userId: number
|
||||
|
||||
@Column({ name: 'transaction_id', unsigned: true, nullable: false })
|
||||
transactionId: number
|
||||
|
||||
@Column({ name: 'transaction_type_id', unsigned: true, nullable: false })
|
||||
transactionTypeId: number
|
||||
|
||||
@Column({ type: 'bigint', nullable: false })
|
||||
amount: BigInt
|
||||
|
||||
@Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' })
|
||||
memo: string
|
||||
|
||||
@Column({
|
||||
name: 'send_sender_final_balance',
|
||||
type: 'bigint',
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
sendSenderFinalBalance: BigInt | null
|
||||
|
||||
@Column({ name: 'balance', type: 'bigint', default: 0 })
|
||||
balance: BigInt
|
||||
|
||||
@Column({
|
||||
name: 'balance_date',
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP',
|
||||
nullable: false,
|
||||
})
|
||||
balanceDate: Date
|
||||
|
||||
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', nullable: false })
|
||||
received: Date
|
||||
|
||||
@Column({ name: 'creation_date', type: 'timestamp', nullable: true, default: null })
|
||||
creationDate: Date
|
||||
|
||||
@Column({
|
||||
name: 'linked_user_id',
|
||||
type: 'int',
|
||||
unsigned: true,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
linkedUserId?: number | null
|
||||
|
||||
@Column({
|
||||
name: 'linked_state_user_transaction_id',
|
||||
type: 'int',
|
||||
unsigned: true,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
linkedStateUserTransactionId?: number | null
|
||||
|
||||
@Column({ type: 'binary', length: 64, nullable: true, default: null })
|
||||
signature: Buffer
|
||||
|
||||
@Column({ name: 'tx_hash', type: 'binary', length: 48, default: null, nullable: true })
|
||||
txHash: Buffer
|
||||
|
||||
@Column({ type: 'binary', length: 32, nullable: true, default: null })
|
||||
pubkey: Buffer
|
||||
|
||||
@Column({
|
||||
name: 'creation_ident_hash',
|
||||
type: 'binary',
|
||||
length: 32,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
creationIdentHash: Buffer
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user