Merge branch 'master' into 1216-SEO-Vorschau-Links

This commit is contained in:
Ulf Gebhardt 2022-03-11 00:12:09 +01:00 committed by GitHub
commit 3aeb59b7fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
277 changed files with 6419 additions and 2104 deletions

View File

@ -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 }}
##############################################################################

View File

@ -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

View File

@ -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"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -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')
})
})
})

View File

@ -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 }))
})
},
},

View File

@ -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!')
})
})
})

View File

@ -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
})

View File

@ -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!')
})
})
})

View File

@ -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)
})
},
},

View 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()
})
})
})
})
})

View 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>

View File

@ -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!')
})
})
})

View File

@ -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

View File

@ -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"

View File

@ -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>

View 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])
})
})
})
})
})

View File

@ -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 }
})
},
},
}

View File

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

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

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

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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)

View 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,
})
},
},
}

View File

@ -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) {

View File

@ -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,

View File

@ -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')
})
})
})

View File

@ -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() {

View File

@ -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!')
})
})
})

View File

@ -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) {

View File

@ -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')
})
})
})

View File

@ -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') },
]
},
},

View File

@ -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)

View File

@ -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"

View File

@ -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'

View File

@ -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"
}
}

View File

@ -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()

View File

@ -7,4 +7,5 @@ export const INALIENABLE_RIGHTS = [
RIGHTS.CREATE_USER,
RIGHTS.SEND_RESET_PASSWORD_EMAIL,
RIGHTS.SET_PASSWORD,
RIGHTS.QUERY_TRANSACTION_LINK,
]

View File

@ -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 => {

View File

@ -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',
}

View File

@ -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

View File

@ -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,
}

View File

@ -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 {

View 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
}

View File

@ -13,4 +13,7 @@ export default class SearchUsersArgs {
@Field(() => Boolean, { nullable: true })
notActivated?: boolean
@Field(() => Boolean, { nullable: true })
isDeleted?: boolean
}

View 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
}

View File

@ -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

View File

@ -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) => {

View File

@ -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, {

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}
*/

View File

@ -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
}

View 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
}

View File

@ -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[]

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}),
}
})

View File

@ -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(),
})
}

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View 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))
})
})

View 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)
}
}

View File

@ -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({

View File

@ -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) })
})
})
})
})

View File

@ -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()) {

View 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)
},
})

View File

@ -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 }],
})
}

View File

@ -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'

View File

@ -1,6 +1,6 @@
import { createTransport } from 'nodemailer'
import CONFIG from '../config'
import CONFIG from '@/config'
export const sendEMail = async (emailDef: {
to: string

View File

@ -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!',
})
})

View File

@ -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({

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -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> => {

View File

@ -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()
}
}

View 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)
}
}

View File

@ -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()

View File

@ -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> {

View 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 }

View File

@ -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')
})
})

View File

@ -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
}

View File

@ -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)
})
})

View File

@ -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 }

View File

@ -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 }

View 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 }

View File

@ -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
View 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
View 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 },
})
}

View File

@ -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. */

View File

@ -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"

View File

@ -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' })

View File

@ -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