mirror of
https://github.com/IT4Change/gradido.git
synced 2025-12-13 07:45:54 +00:00
Merge branch 'master' into 2509-feature-federation-separate-dht-node-as-new-modul
This commit is contained in:
commit
0bf7ebe458
5
.github/workflows/test.yml
vendored
5
.github/workflows/test.yml
vendored
@ -35,7 +35,6 @@ jobs:
|
||||
build_test_admin:
|
||||
name: Docker Build Test - Admin Interface
|
||||
runs-on: ubuntu-latest
|
||||
#needs: [nothing]
|
||||
steps:
|
||||
##########################################################################
|
||||
# CHECKOUT CODE ##########################################################
|
||||
@ -437,7 +436,7 @@ jobs:
|
||||
report_name: Coverage Frontend
|
||||
type: lcov
|
||||
result_path: ./coverage/lcov.info
|
||||
min_coverage: 89
|
||||
min_coverage: 95
|
||||
token: ${{ github.token }}
|
||||
|
||||
##############################################################################
|
||||
@ -479,7 +478,7 @@ jobs:
|
||||
report_name: Coverage Admin Interface
|
||||
type: lcov
|
||||
result_path: ./coverage/lcov.info
|
||||
min_coverage: 95
|
||||
min_coverage: 96
|
||||
token: ${{ github.token }}
|
||||
|
||||
##############################################################################
|
||||
|
||||
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@ -3,6 +3,10 @@
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"hediet.vscode-drawio"
|
||||
"hediet.vscode-drawio",
|
||||
"streetsidesoftware.code-spell-checker-german",
|
||||
"mtxr.sqltools",
|
||||
"mtxr.sqltools-driver-mysql",
|
||||
"jcbuisson.vue"
|
||||
]
|
||||
}
|
||||
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@ -1,3 +1,18 @@
|
||||
{
|
||||
"git.ignoreLimitWarning": true
|
||||
"git.ignoreLimitWarning": true,
|
||||
"sqltools.connections": [
|
||||
{
|
||||
"mysqlOptions": {
|
||||
"authProtocol": "default"
|
||||
},
|
||||
"previewLimit": 50,
|
||||
"server": "localhost",
|
||||
"port": 3306,
|
||||
"driver": "MariaDB",
|
||||
"name": "localhost",
|
||||
"database": "gradido_community",
|
||||
"username": "root",
|
||||
"password": ""
|
||||
}
|
||||
],
|
||||
}
|
||||
68
CHANGELOG.md
68
CHANGELOG.md
@ -4,8 +4,76 @@ 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.17.1](https://github.com/gradido/gradido/compare/1.17.0...1.17.1)
|
||||
|
||||
- refactor(frontend): change contribution memo add word-break [`#2583`](https://github.com/gradido/gradido/pull/2583)
|
||||
- refactor(admin): add text-break on all table memo fields [`#2584`](https://github.com/gradido/gradido/pull/2584)
|
||||
- fix(frontend): throw proper frontend warning errors [`#2586`](https://github.com/gradido/gradido/pull/2586)
|
||||
- refactor(frontend): equalize en with fr languages. [`#2585`](https://github.com/gradido/gradido/pull/2585)
|
||||
- refactor(frontend): forgot password unit tests [`#2582`](https://github.com/gradido/gradido/pull/2582)
|
||||
- fix(frontend): fix min value for hours input [`#2581`](https://github.com/gradido/gradido/pull/2581)
|
||||
- fix(frontend): change dropdown placement calender no-flip true [`#2580`](https://github.com/gradido/gradido/pull/2580)
|
||||
- refactor(frontend): link send result style [`#2577`](https://github.com/gradido/gradido/pull/2577)
|
||||
- refactor(frontend): remove vertical scrolling & small fixes [`#2578`](https://github.com/gradido/gradido/pull/2578)
|
||||
- refactor(frontend): tyle mobile device auth template [`#2576`](https://github.com/gradido/gradido/pull/2576)
|
||||
|
||||
#### [1.17.0](https://github.com/gradido/gradido/compare/1.16.0...1.17.0)
|
||||
|
||||
> 18 January 2023
|
||||
|
||||
- chore(release): v1.17.0 [`#2575`](https://github.com/gradido/gradido/pull/2575)
|
||||
- fix(frontend): submit contribution text [`#2573`](https://github.com/gradido/gradido/pull/2573)
|
||||
- fix(backend): admin cannot delete confirmed contribution [`#2571`](https://github.com/gradido/gradido/pull/2571)
|
||||
- fix(frontend): english locales - horas -> hours [`#2572`](https://github.com/gradido/gradido/pull/2572)
|
||||
- fix(frontend): mobil divices datepicker add props dropleft [`#2570`](https://github.com/gradido/gradido/pull/2570)
|
||||
- fix(frontend): pagination [`#2569`](https://github.com/gradido/gradido/pull/2569)
|
||||
- fix(frontend): add a watch on gdt prop to assure propper loading when mounted [`#2568`](https://github.com/gradido/gradido/pull/2568)
|
||||
- refactor(frontend): creation step in quarter hour set [`#2566`](https://github.com/gradido/gradido/pull/2566)
|
||||
- fix(frontend): tunneled email on right side last transactions [`#2561`](https://github.com/gradido/gradido/pull/2561)
|
||||
- feat(frontend): test transaction page [`#2555`](https://github.com/gradido/gradido/pull/2555)
|
||||
- refactor(backend): statistics with field resolvers [`#2553`](https://github.com/gradido/gradido/pull/2553)
|
||||
- fix(frontend): normalized amount transaction if processed again [`#2550`](https://github.com/gradido/gradido/pull/2550)
|
||||
- fix(backend): semaphore deadlock [`#2551`](https://github.com/gradido/gradido/pull/2551)
|
||||
- fix(frontend): mobile design [`#2552`](https://github.com/gradido/gradido/pull/2552)
|
||||
- refactor(frontend): slots for right sidebar and header [`#2548`](https://github.com/gradido/gradido/pull/2548)
|
||||
- fix(frontend): creation menu highlighted on all submenus [`#2527`](https://github.com/gradido/gradido/pull/2527)
|
||||
- refactor(frontend): computed hours for open creations [`#2545`](https://github.com/gradido/gradido/pull/2545)
|
||||
- feat(other): add description for daily backup cronjob [`#2532`](https://github.com/gradido/gradido/pull/2532)
|
||||
- fix(frontend): editing transaction does not work [`#2543`](https://github.com/gradido/gradido/pull/2543)
|
||||
- refactor(frontend): remove open creations from store [`#2541`](https://github.com/gradido/gradido/pull/2541)
|
||||
- feat(other): vscode extensions [`#2524`](https://github.com/gradido/gradido/pull/2524)
|
||||
- fix(backend): remove jest from dependecies [`#2533`](https://github.com/gradido/gradido/pull/2533)
|
||||
- fix(frontend): initials without space [`#2546`](https://github.com/gradido/gradido/pull/2546)
|
||||
- fix(backend): fix backend not confirmable [`#2539`](https://github.com/gradido/gradido/pull/2539)
|
||||
- fix(frontend): send gdd and send link gdd is running [`#2534`](https://github.com/gradido/gradido/pull/2534)
|
||||
- fix(other): update browser list [`#2540`](https://github.com/gradido/gradido/pull/2540)
|
||||
- test(backend): increase backend coverage to 78% [`#2542`](https://github.com/gradido/gradido/pull/2542)
|
||||
- fix(frontend): pagination gdt [`#2525`](https://github.com/gradido/gradido/pull/2525)
|
||||
- fix(frontend): leaves are over the user symbol [`#2526`](https://github.com/gradido/gradido/pull/2526)
|
||||
- fix(frontend): input-email label and placeholder are displayed correctly per language [`#2528`](https://github.com/gradido/gradido/pull/2528)
|
||||
- feat(backend): add hideAmountGDD & hideAmountGDT to users table. [`#2506`](https://github.com/gradido/gradido/pull/2506)
|
||||
- fix(frontend): avatar initials always has 2 letters [`#2530`](https://github.com/gradido/gradido/pull/2530)
|
||||
- refactor(backend): seed contributions as user [`#2460`](https://github.com/gradido/gradido/pull/2460)
|
||||
- fix(backend): fix logger middleware [`#2503`](https://github.com/gradido/gradido/pull/2503)
|
||||
- fix(backend): fix email text [`#2523`](https://github.com/gradido/gradido/pull/2523)
|
||||
- feat(other): new scopes for lint pr [`#2489`](https://github.com/gradido/gradido/pull/2489)
|
||||
- fix(backend): fix config - some typos [`#2477`](https://github.com/gradido/gradido/pull/2477)
|
||||
- style(frontend): new Design [`#2297`](https://github.com/gradido/gradido/pull/2297)
|
||||
- refactor(other): adjust some texts and translations [`#2504`](https://github.com/gradido/gradido/pull/2504)
|
||||
- test(other): fix tests breaking with the new year [`#2505`](https://github.com/gradido/gradido/pull/2505)
|
||||
- feat(backend): federation implement exchange of api versions persist in table [`#2427`](https://github.com/gradido/gradido/pull/2427)
|
||||
- feat(backend): semaphore to lock transaction table [`#2458`](https://github.com/gradido/gradido/pull/2458)
|
||||
- feat(backend): design html emails and adjust texts [`#2472`](https://github.com/gradido/gradido/pull/2472)
|
||||
- feat(backend): test semaphore [`#2468`](https://github.com/gradido/gradido/pull/2468)
|
||||
- fix(admin): reduce triggers of success toast on deleted user form to exactly one [`#2471`](https://github.com/gradido/gradido/pull/2471)
|
||||
- refactor(other): build nginx docker image in workflow independent of other builds [`#2470`](https://github.com/gradido/gradido/pull/2470)
|
||||
- feat(backend): setup unit tests for federation [`#2465`](https://github.com/gradido/gradido/pull/2465)
|
||||
|
||||
#### [1.16.0](https://github.com/gradido/gradido/compare/1.15.0...1.16.0)
|
||||
|
||||
> 15 December 2022
|
||||
|
||||
- feat(release): version 1.16.0 [`#2467`](https://github.com/gradido/gradido/pull/2467)
|
||||
- refactor(backend): cleaning user related old password junk [`#2426`](https://github.com/gradido/gradido/pull/2426)
|
||||
- fix(database): consistent transaction table [`#2453`](https://github.com/gradido/gradido/pull/2453)
|
||||
- refactor(backend): dissolve admin resolver [`#2416`](https://github.com/gradido/gradido/pull/2416)
|
||||
|
||||
@ -39,7 +39,7 @@ module.exports = {
|
||||
{
|
||||
src: './src',
|
||||
extensions: ['.js', '.vue'],
|
||||
ignores: [],
|
||||
ignores: ['/overlay/'],
|
||||
enableFix: false,
|
||||
},
|
||||
],
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"description": "Administraion Interface for Gradido",
|
||||
"main": "index.js",
|
||||
"author": "Moriz Wahl",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.1",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
|
||||
@ -88,7 +88,7 @@ export default {
|
||||
return `${value} GDD`
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: this.$t('transactionlist.memo') },
|
||||
{ key: 'memo', label: this.$t('transactionlist.memo'), class: 'text-break' },
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="component-overlay">
|
||||
<b-jumbotron class="bg-light p-4">
|
||||
<template #header>{{ $t('overlay.confirm.title') }}</template>
|
||||
<template #header><slot name="title" /></template>
|
||||
|
||||
<template #lead>
|
||||
<b-row class="mt-4">
|
||||
@ -31,26 +31,18 @@
|
||||
</template>
|
||||
|
||||
<hr class="my-4" />
|
||||
<p>{{ $t('overlay.confirm.text') }}</p>
|
||||
<p>
|
||||
{{ $t('overlay.confirm.question') }}
|
||||
</p>
|
||||
<slot name="text" />
|
||||
<slot name="question" />
|
||||
|
||||
<b-container>
|
||||
<b-row>
|
||||
<b-col>
|
||||
<b-button size="md" variant="danger" class="m-3" @click="$emit('overlay-cancel')">
|
||||
{{ $t('overlay.confirm.cancel') }}
|
||||
<b-button size="md" variant="info" class="m-3" @click="$emit('overlay-cancel')">
|
||||
{{ $t('overlay.cancel') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-right">
|
||||
<b-button
|
||||
size="md"
|
||||
variant="success"
|
||||
class="m-3 text-right"
|
||||
@click="$emit('confirm-creation', item)"
|
||||
>
|
||||
{{ $t('overlay.confirm.yes') }}
|
||||
</b-button>
|
||||
<slot name="submit-btn" />
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-container>
|
||||
|
||||
@ -5,6 +5,7 @@ const localVue = global.localVue
|
||||
|
||||
const apolloMutateMock = jest.fn().mockResolvedValue({})
|
||||
const apolloQueryMock = jest.fn().mockResolvedValue({})
|
||||
const toggleDetailsMock = jest.fn()
|
||||
|
||||
const propsData = {
|
||||
items: [
|
||||
@ -57,7 +58,7 @@ const propsData = {
|
||||
return value + ' GDD'
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: 'text' },
|
||||
{ key: 'memo', label: 'text', class: 'text-break' },
|
||||
{
|
||||
key: 'date',
|
||||
label: 'date',
|
||||
@ -138,5 +139,50 @@ describe('OpenCreationsTable', () => {
|
||||
expect(wrapper.vm.items[0].creation).toEqual([444, 555, 666])
|
||||
})
|
||||
})
|
||||
|
||||
describe('call updateState', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.vm.updateState(4)
|
||||
})
|
||||
|
||||
it('emits update-state', () => {
|
||||
expect(wrapper.vm.$root.$emit('update-state', 4)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('call updateCreationData', () => {
|
||||
const date = new Date()
|
||||
beforeEach(() => {
|
||||
wrapper.vm.updateCreationData({
|
||||
amount: Number(80.0),
|
||||
date: date,
|
||||
memo: 'Test memo',
|
||||
row: {
|
||||
item: {},
|
||||
detailsShowing: false,
|
||||
toggleDetails: toggleDetailsMock,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('emits update-state', () => {
|
||||
expect(
|
||||
wrapper.vm.$emit('update-contributions', {
|
||||
amount: Number(80.0),
|
||||
date: date,
|
||||
memo: 'Test memo',
|
||||
row: {
|
||||
item: {},
|
||||
detailsShowing: false,
|
||||
toggleDetails: toggleDetailsMock,
|
||||
},
|
||||
}),
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('calls toggleDetails', () => {
|
||||
expect(toggleDetailsMock).toBeCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -5,10 +5,10 @@
|
||||
<b-button
|
||||
variant="danger"
|
||||
size="md"
|
||||
@click="$emit('remove-creation', row.item)"
|
||||
@click="$emit('show-overlay', row.item, 'delete')"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon icon="x" variant="light"></b-icon>
|
||||
<b-icon icon="trash" variant="light"></b-icon>
|
||||
</b-button>
|
||||
</template>
|
||||
<template #cell(editCreation)="row">
|
||||
@ -37,12 +37,24 @@
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(deny)="row">
|
||||
<div v-if="$store.state.moderator.id !== row.item.userId">
|
||||
<b-button
|
||||
variant="warning"
|
||||
size="md"
|
||||
@click="$emit('show-overlay', row.item, 'deny')"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon icon="x" variant="light"></b-icon>
|
||||
</b-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell(confirm)="row">
|
||||
<div v-if="$store.state.moderator.id !== row.item.userId">
|
||||
<b-button
|
||||
variant="success"
|
||||
size="md"
|
||||
@click="$emit('show-overlay', row.item)"
|
||||
@click="$emit('show-overlay', row.item, 'confirm')"
|
||||
class="mr-2"
|
||||
>
|
||||
<b-icon icon="check" scale="2" variant=""></b-icon>
|
||||
|
||||
@ -67,7 +67,7 @@ export default {
|
||||
return `${value} GDD`
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: this.$t('transactionlist.memo') },
|
||||
{ key: 'memo', label: this.$t('transactionlist.memo'), class: 'text-break' },
|
||||
{
|
||||
key: 'validUntil',
|
||||
label: this.$t('transactionlink.valid_until'),
|
||||
|
||||
@ -4,12 +4,14 @@ export const communityStatistics = gql`
|
||||
query {
|
||||
communityStatistics {
|
||||
totalUsers
|
||||
activeUsers
|
||||
deletedUsers
|
||||
totalGradidoCreated
|
||||
totalGradidoDecayed
|
||||
totalGradidoAvailable
|
||||
totalGradidoUnbookedDecayed
|
||||
dynamicStatisticsFields {
|
||||
activeUsers
|
||||
totalGradidoAvailable
|
||||
totalGradidoUnbookedDecayed
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
7
admin/src/graphql/denyContribution.js
Normal file
7
admin/src/graphql/denyContribution.js
Normal file
@ -0,0 +1,7 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const denyContribution = gql`
|
||||
mutation ($id: Int!) {
|
||||
denyContribution(id: $id)
|
||||
}
|
||||
`
|
||||
@ -34,7 +34,6 @@
|
||||
"creation_form": {
|
||||
"creation_failed": "Ausstehende Schöpfung für {email} konnte nicht erzeugt werden.",
|
||||
"creation_for": "Aktives Grundeinkommen für",
|
||||
"deleteNow": "Möchtest du diesen Beitrag zur Gemeinschaft wirklich löschen?",
|
||||
"enter_text": "Text eintragen",
|
||||
"form": "Schöpfungsformular",
|
||||
"min_characters": "Mindestens 10 Zeichen eingeben",
|
||||
@ -45,6 +44,7 @@
|
||||
"toasted": "Offene Schöpfung ({value} GDD) für {email} wurde gespeichert und liegt zur Bestätigung bereit",
|
||||
"toasted_created": "Schöpfung wurde erfolgreich gespeichert",
|
||||
"toasted_delete": "Offene Schöpfung wurde gelöscht",
|
||||
"toasted_denied": "Offene Schöpfung wurde abgelehnt",
|
||||
"toasted_update": "`Offene Schöpfung {value} GDD) für {email} wurde geändert und liegt zur Bestätigung bereit",
|
||||
"update_creation": "Schöpfung aktualisieren"
|
||||
},
|
||||
@ -54,6 +54,7 @@
|
||||
"deleted": "gelöscht",
|
||||
"deleted_user": "Alle gelöschten Nutzer",
|
||||
"delete_user": "Nutzer löschen",
|
||||
"deny": "Ablehnen",
|
||||
"edit": "Bearbeiten",
|
||||
"enabled": "aktiviert",
|
||||
"error": "Fehler",
|
||||
@ -110,12 +111,24 @@
|
||||
"open": "offen",
|
||||
"open_creations": "Offene Schöpfungen",
|
||||
"overlay": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": {
|
||||
"cancel": "Abbrechen",
|
||||
"question": "Willst du diese vorgespeicherte Schöpfung wirklich vollziehen und endgültig speichern?",
|
||||
"question": "Willst du diesen Gemeinwohl-Beitrag wirklich bestätigen und gutschreiben?",
|
||||
"text": "Nach dem Speichern ist der Datensatz nicht mehr änderbar. Bitte überprüfe genau, dass alles stimmt.",
|
||||
"title": "Gemeinwohl-Beitrag bestätigen!",
|
||||
"yes": "Ja, Beitrag bestätigen und speichern!"
|
||||
},
|
||||
"delete": {
|
||||
"question": "Willst du diesen Gemeinwohl-Beitrag wirklich endgültig löschen?",
|
||||
"text": "Nach dem Löschen ist der Datensatz nicht mehr vorhanden.",
|
||||
"title": "Gemeinwohl-Beitrag löschen!",
|
||||
"yes": "Ja, Beitrag löschen!"
|
||||
},
|
||||
"deny": {
|
||||
"question": "Willst du diesen Gemeinwohl-Beitrag wirklich ablehnen?",
|
||||
"text": "Nach dem Speichern ist der Datensatz nicht mehr änderbar und kann auch nicht mehr gelöscht werden. Bitte überprüfe genau, dass alles stimmt.",
|
||||
"title": "Schöpfung bestätigen!",
|
||||
"yes": "Ja, Schöpfung bestätigen und speichern!"
|
||||
"title": "Gemeinwohl-Beitrag ablehnen!",
|
||||
"yes": "Ja, Beitrag ablehnen und speichern!"
|
||||
}
|
||||
},
|
||||
"redeemed": "eingelöst",
|
||||
|
||||
@ -34,7 +34,6 @@
|
||||
"creation_form": {
|
||||
"creation_failed": "Could not create pending creation for {email}",
|
||||
"creation_for": "Active Basic Income for",
|
||||
"deleteNow": "Do you really want to delete this contribution to the community?",
|
||||
"enter_text": "Enter text",
|
||||
"form": "Creation form",
|
||||
"min_characters": "Enter at least 10 characters",
|
||||
@ -45,6 +44,7 @@
|
||||
"toasted": "Open creation ({value} GDD) for {email} has been saved and is ready for confirmation.",
|
||||
"toasted_created": "Creation has been successfully saved",
|
||||
"toasted_delete": "Open creation has been deleted",
|
||||
"toasted_denied": "Open creation has been denied",
|
||||
"toasted_update": "Open creation {value} GDD) for {email} has been changed and is ready for confirmation.",
|
||||
"update_creation": "Creation update"
|
||||
},
|
||||
@ -54,6 +54,7 @@
|
||||
"deleted": "deleted",
|
||||
"deleted_user": "All deleted user",
|
||||
"delete_user": "Delete user",
|
||||
"deny": "Reject",
|
||||
"edit": "Edit",
|
||||
"enabled": "enabled",
|
||||
"error": "Error",
|
||||
@ -110,12 +111,24 @@
|
||||
"open": "open",
|
||||
"open_creations": "Open creations",
|
||||
"overlay": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": {
|
||||
"cancel": "Cancel",
|
||||
"question": "Do you really want to carry out and finally save this pre-stored creation?",
|
||||
"text": "After saving, the record can no longer be changed or deleted. Please check carefully that everything is correct.",
|
||||
"text": "After saving, the record can no longer be changed. Please check carefully that everything is correct.",
|
||||
"title": "Confirm creation!",
|
||||
"yes": "Yes, confirm and save creation!"
|
||||
},
|
||||
"delete": {
|
||||
"question": "Do you really want to carry out and finally save this pre-stored creation?",
|
||||
"text": "After saving, the record can no longer be changed or deleted. Please check carefully that everything is correct.",
|
||||
"title": "Delete creation!",
|
||||
"yes": "Yes, delete and save creation!"
|
||||
},
|
||||
"deny": {
|
||||
"question": "Do you really want to carry out and finally save this pre-stored creation?",
|
||||
"text": "After saving, the record can no longer be changed or deleted. Please check carefully that everything is correct.",
|
||||
"title": "Reject creation!",
|
||||
"yes": "Yes, reject and save creation!"
|
||||
}
|
||||
},
|
||||
"redeemed": "redeemed",
|
||||
|
||||
@ -17,12 +17,14 @@ const defaultData = () => {
|
||||
return {
|
||||
communityStatistics: {
|
||||
totalUsers: 3113,
|
||||
activeUsers: 1057,
|
||||
deletedUsers: 35,
|
||||
totalGradidoCreated: '4083774.05000000000000000000',
|
||||
totalGradidoDecayed: '-1062639.13634129622923372197',
|
||||
totalGradidoAvailable: '2513565.869444365732411569',
|
||||
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
|
||||
dynamicStatisticsFields: {
|
||||
activeUsers: 1057,
|
||||
totalGradidoAvailable: '2513565.869444365732411569',
|
||||
totalGradidoUnbookedDecayed: '-500474.6738366222166261272',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,7 +31,9 @@ export default {
|
||||
return communityStatistics
|
||||
},
|
||||
update({ communityStatistics }) {
|
||||
this.statistics = communityStatistics
|
||||
const totals = { ...communityStatistics.dynamicStatisticsFields }
|
||||
this.statistics = { ...communityStatistics, ...totals }
|
||||
delete this.statistics.dynamicStatisticsFields
|
||||
},
|
||||
error({ message }) {
|
||||
this.toastError(message)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import CreationConfirm from './CreationConfirm.vue'
|
||||
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
|
||||
import { denyContribution } from '../graphql/denyContribution'
|
||||
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
|
||||
import { confirmContribution } from '../graphql/confirmContribution'
|
||||
import { toastErrorSpy, toastSuccessSpy } from '../../test/testSetup'
|
||||
@ -75,6 +76,7 @@ describe('CreationConfirm', () => {
|
||||
|
||||
const listUnconfirmedContributionsMock = jest.fn()
|
||||
const adminDeleteContributionMock = jest.fn()
|
||||
const adminDenyContributionMock = jest.fn()
|
||||
const confirmContributionMock = jest.fn()
|
||||
|
||||
mockClient.setRequestHandler(
|
||||
@ -89,6 +91,13 @@ describe('CreationConfirm', () => {
|
||||
adminDeleteContributionMock.mockResolvedValue({ data: { adminDeleteContribution: true } }),
|
||||
)
|
||||
|
||||
mockClient.setRequestHandler(
|
||||
denyContribution,
|
||||
adminDenyContributionMock
|
||||
.mockRejectedValueOnce({ message: 'Ouch!' })
|
||||
.mockResolvedValue({ data: { denyContribution: true } }),
|
||||
)
|
||||
|
||||
mockClient.setRequestHandler(
|
||||
confirmContribution,
|
||||
confirmContributionMock.mockResolvedValue({ data: { confirmContribution: true } }),
|
||||
@ -130,110 +139,174 @@ describe('CreationConfirm', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove creation with success', () => {
|
||||
let spy
|
||||
|
||||
describe('admin confirms deletion', () => {
|
||||
describe('actions in overlay', () => {
|
||||
describe('delete creation', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve('some value'))
|
||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('opens a modal', () => {
|
||||
expect(spy).toBeCalled()
|
||||
})
|
||||
|
||||
it('calls the adminDeleteContribution mutation', () => {
|
||||
expect(adminDeleteContributionMock).toBeCalledWith({ id: 1 })
|
||||
})
|
||||
|
||||
it('commits openCreationsMinus to store', () => {
|
||||
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin cancels deletion', () => {
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve(false))
|
||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('does not call the adminDeleteContribution mutation', () => {
|
||||
expect(adminDeleteContributionMock).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('remove creation with error', () => {
|
||||
let spy
|
||||
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
|
||||
spy.mockImplementation(() => Promise.resolve('some value'))
|
||||
adminDeleteContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
|
||||
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm creation with success', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('tr').at(2).findAll('button').at(2).trigger('click')
|
||||
})
|
||||
|
||||
describe('overlay', () => {
|
||||
it('opens the overlay', () => {
|
||||
expect(wrapper.find('#overlay').isVisible()).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('cancel confirmation', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
|
||||
describe('with success', () => {
|
||||
describe('cancel deletion', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('closes the overlay', async () => {
|
||||
expect(wrapper.find('#overlay').exists()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('still has 2 items in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the overlay', async () => {
|
||||
expect(wrapper.find('#overlay').exists()).toBeFalsy()
|
||||
describe('confirm deletion', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
|
||||
})
|
||||
|
||||
it('calls the adminDeleteContribution mutation', () => {
|
||||
expect(adminDeleteContributionMock).toBeCalledWith({ id: 1 })
|
||||
})
|
||||
it('commits openCreationsMinus to store', () => {
|
||||
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
|
||||
})
|
||||
|
||||
it('has 1 item left in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('still has 2 items in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(2)
|
||||
describe('with error', () => {
|
||||
beforeEach(async () => {
|
||||
adminDeleteContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
|
||||
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
|
||||
})
|
||||
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm creation', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('tr').at(2).findAll('button').at(3).trigger('click')
|
||||
})
|
||||
|
||||
it('opens the overlay', () => {
|
||||
expect(wrapper.find('#overlay').isVisible()).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('with succes', () => {
|
||||
describe('cancel confirmation', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('closes the overlay', async () => {
|
||||
expect(wrapper.find('#overlay').exists()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('still has 2 items in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm confirmation', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
|
||||
})
|
||||
|
||||
it('calls the confirmContribution mutation', () => {
|
||||
expect(confirmContributionMock).toBeCalledWith({ id: 2 })
|
||||
})
|
||||
|
||||
it('commits openCreationsMinus to store', () => {
|
||||
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_created')
|
||||
})
|
||||
|
||||
it('has 1 item left in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm creation', () => {
|
||||
describe('with error', () => {
|
||||
beforeEach(async () => {
|
||||
confirmContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
|
||||
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
|
||||
})
|
||||
|
||||
it('calls the confirmContribution mutation', () => {
|
||||
expect(confirmContributionMock).toBeCalledWith({ id: 2 })
|
||||
it('toasts an error message', () => {
|
||||
expect(toastErrorSpy).toBeCalledWith('Ouchhh!')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deny creation', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.findAll('tr').at(1).findAll('button').at(2).trigger('click')
|
||||
})
|
||||
|
||||
it('opens the overlay', () => {
|
||||
expect(wrapper.find('#overlay').isVisible()).toBeTruthy()
|
||||
})
|
||||
|
||||
describe('with succes', () => {
|
||||
describe('cancel deny', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('#overlay').findAll('button').at(0).trigger('click')
|
||||
})
|
||||
|
||||
it('closes the overlay', async () => {
|
||||
expect(wrapper.find('#overlay').exists()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('still has 2 items in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
it('commits openCreationsMinus to store', () => {
|
||||
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
|
||||
})
|
||||
describe('confirm deny', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_created')
|
||||
})
|
||||
it('calls the denyContribution mutation', () => {
|
||||
expect(adminDenyContributionMock).toBeCalledWith({ id: 1 })
|
||||
})
|
||||
|
||||
it('has 1 item left in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(1)
|
||||
it('commits openCreationsMinus to store', () => {
|
||||
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
|
||||
})
|
||||
|
||||
it('toasts a success message', () => {
|
||||
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_denied')
|
||||
})
|
||||
|
||||
it('has 1 item left in the table', () => {
|
||||
expect(wrapper.findAll('tbody > tr')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirm creation with error', () => {
|
||||
describe('with error', () => {
|
||||
beforeEach(async () => {
|
||||
confirmContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
|
||||
adminDenyContributionMock.mockRejectedValue({ message: 'Ouchhh!' })
|
||||
await wrapper.find('#overlay').findAll('button').at(1).trigger('click')
|
||||
})
|
||||
|
||||
|
||||
@ -1,13 +1,33 @@
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-dynamic-keys -->
|
||||
<template>
|
||||
<div class="creation-confirm">
|
||||
<div v-if="overlay" id="overlay" @dblclick="overlay = false">
|
||||
<overlay :item="item" @overlay-cancel="overlay = false" @confirm-creation="confirmCreation" />
|
||||
<overlay :item="item" @overlay-cancel="overlay = false">
|
||||
<template #title>
|
||||
{{ $t(overlayTitle) }}
|
||||
</template>
|
||||
<template #text>
|
||||
<p>{{ $t(overlayText) }}</p>
|
||||
</template>
|
||||
<template #question>
|
||||
<p>{{ $t(overlayQuestion) }}</p>
|
||||
</template>
|
||||
<template #submit-btn>
|
||||
<b-button
|
||||
size="md"
|
||||
v-bind:variant="overlayIcon"
|
||||
class="m-3 text-right"
|
||||
@click="overlayEvent"
|
||||
>
|
||||
{{ $t(overlayBtnText) }}
|
||||
</b-button>
|
||||
</template>
|
||||
</overlay>
|
||||
</div>
|
||||
<open-creations-table
|
||||
class="mt-4"
|
||||
:items="pendingCreations"
|
||||
:fields="fields"
|
||||
@remove-creation="removeCreation"
|
||||
@show-overlay="showOverlay"
|
||||
@update-state="updateState"
|
||||
@update-contributions="$apollo.queries.PendingContributions.refetch()"
|
||||
@ -20,6 +40,7 @@ import OpenCreationsTable from '../components/Tables/OpenCreationsTable.vue'
|
||||
import { listUnconfirmedContributions } from '../graphql/listUnconfirmedContributions'
|
||||
import { adminDeleteContribution } from '../graphql/adminDeleteContribution'
|
||||
import { confirmContribution } from '../graphql/confirmContribution'
|
||||
import { denyContribution } from '../graphql/denyContribution'
|
||||
|
||||
export default {
|
||||
name: 'CreationConfirm',
|
||||
@ -32,27 +53,45 @@ export default {
|
||||
pendingCreations: [],
|
||||
overlay: false,
|
||||
item: {},
|
||||
variant: 'confirm',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
removeCreation(item) {
|
||||
this.$bvModal.msgBoxConfirm(this.$t('creation_form.deleteNow')).then(async (value) => {
|
||||
if (value)
|
||||
await this.$apollo
|
||||
.mutate({
|
||||
mutation: adminDeleteContribution,
|
||||
variables: {
|
||||
id: item.id,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.updatePendingCreations(item.id)
|
||||
this.toastSuccess(this.$t('creation_form.toasted_delete'))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.toastError(error.message)
|
||||
})
|
||||
})
|
||||
deleteCreation() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: adminDeleteContribution,
|
||||
variables: {
|
||||
id: this.item.id,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.overlay = false
|
||||
this.updatePendingCreations(this.item.id)
|
||||
this.toastSuccess(this.$t('creation_form.toasted_delete'))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.overlay = false
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
denyCreation() {
|
||||
this.$apollo
|
||||
.mutate({
|
||||
mutation: denyContribution,
|
||||
variables: {
|
||||
id: this.item.id,
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
this.overlay = false
|
||||
this.updatePendingCreations(this.item.id)
|
||||
this.toastSuccess(this.$t('creation_form.toasted_denied'))
|
||||
})
|
||||
.catch((error) => {
|
||||
this.overlay = false
|
||||
this.toastError(error.message)
|
||||
})
|
||||
},
|
||||
confirmCreation() {
|
||||
this.$apollo
|
||||
@ -76,9 +115,10 @@ export default {
|
||||
this.pendingCreations = this.pendingCreations.filter((obj) => obj.id !== id)
|
||||
this.$store.commit('openCreationsMinus', 1)
|
||||
},
|
||||
showOverlay(item) {
|
||||
showOverlay(item, variant) {
|
||||
this.overlay = true
|
||||
this.item = item
|
||||
this.variant = variant
|
||||
},
|
||||
updateState(id) {
|
||||
this.pendingCreations.find((obj) => obj.id === id).messagesCount++
|
||||
@ -99,7 +139,7 @@ export default {
|
||||
return value + ' GDD'
|
||||
},
|
||||
},
|
||||
{ key: 'memo', label: this.$t('text') },
|
||||
{ key: 'memo', label: this.$t('text'), class: 'text-break' },
|
||||
{
|
||||
key: 'date',
|
||||
label: this.$t('date'),
|
||||
@ -109,9 +149,37 @@ export default {
|
||||
},
|
||||
{ key: 'moderator', label: this.$t('moderator') },
|
||||
{ key: 'editCreation', label: this.$t('edit') },
|
||||
{ key: 'deny', label: this.$t('deny') },
|
||||
{ key: 'confirm', label: this.$t('save') },
|
||||
]
|
||||
},
|
||||
overlayTitle() {
|
||||
return `overlay.${this.variant}.title`
|
||||
},
|
||||
overlayText() {
|
||||
return `overlay.${this.variant}.text`
|
||||
},
|
||||
overlayQuestion() {
|
||||
return `overlay.${this.variant}.question`
|
||||
},
|
||||
overlayBtnText() {
|
||||
return `overlay.${this.variant}.yes`
|
||||
},
|
||||
overlayEvent() {
|
||||
return this[`${this.variant}Creation`]
|
||||
},
|
||||
overlayIcon() {
|
||||
switch (this.variant) {
|
||||
case 'confirm':
|
||||
return 'success'
|
||||
case 'deny':
|
||||
return 'warning'
|
||||
case 'delete':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
},
|
||||
},
|
||||
apollo: {
|
||||
PendingContributions: {
|
||||
|
||||
@ -4141,9 +4141,9 @@ caniuse-api@^3.0.0:
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000844, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001271:
|
||||
version "1.0.30001442"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001442.tgz"
|
||||
integrity sha512-239m03Pqy0hwxYPYR5JwOIxRJfLTWtle9FV8zosfV5pHg+/51uD4nxcUlM8+mWWGfwKtt8lJNHnD3cWw9VZ6ow==
|
||||
version "1.0.30001445"
|
||||
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001445.tgz"
|
||||
integrity sha512-8sdQIdMztYmzfTMO6KfLny878Ln9c2M0fc7EH60IjlP4Dc4PiCy7K2Vl3ITmWgOyPgVQKa5x+UP/KqFsxj4mBg==
|
||||
|
||||
capture-exit@^2.0.0:
|
||||
version "2.0.0"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido-backend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.1",
|
||||
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/gradido/gradido/backend",
|
||||
@ -31,7 +31,6 @@
|
||||
"express": "^4.17.1",
|
||||
"graphql": "^15.5.1",
|
||||
"i18n": "^0.15.1",
|
||||
"jest": "^27.2.4",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"log4js": "^6.4.6",
|
||||
|
||||
@ -35,6 +35,7 @@ export enum RIGHTS {
|
||||
SEARCH_ADMIN_USERS = 'SEARCH_ADMIN_USERS',
|
||||
CREATE_CONTRIBUTION_MESSAGE = 'CREATE_CONTRIBUTION_MESSAGE',
|
||||
LIST_ALL_CONTRIBUTION_MESSAGES = 'LIST_ALL_CONTRIBUTION_MESSAGES',
|
||||
OPEN_CREATIONS = 'OPEN_CREATIONS',
|
||||
// Admin
|
||||
SEARCH_USERS = 'SEARCH_USERS',
|
||||
SET_USER_ROLE = 'SET_USER_ROLE',
|
||||
@ -53,4 +54,5 @@ export enum RIGHTS {
|
||||
DELETE_CONTRIBUTION_LINK = 'DELETE_CONTRIBUTION_LINK',
|
||||
UPDATE_CONTRIBUTION_LINK = 'UPDATE_CONTRIBUTION_LINK',
|
||||
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',
|
||||
DENY_CONTRIBUTION = 'DENY_CONTRIBUTION',
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ export const ROLE_USER = new Role('user', [
|
||||
RIGHTS.COMMUNITY_STATISTICS,
|
||||
RIGHTS.CREATE_CONTRIBUTION_MESSAGE,
|
||||
RIGHTS.LIST_ALL_CONTRIBUTION_MESSAGES,
|
||||
RIGHTS.OPEN_CREATIONS,
|
||||
])
|
||||
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
sendAccountActivationEmail,
|
||||
sendAccountMultiRegistrationEmail,
|
||||
sendContributionConfirmedEmail,
|
||||
sendContributionRejectedEmail,
|
||||
sendContributionDeniedEmail,
|
||||
sendResetPasswordEmail,
|
||||
sendTransactionLinkRedeemedEmail,
|
||||
sendTransactionReceivedEmail,
|
||||
@ -360,9 +360,9 @@ describe('sendEmailVariants', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendContributionRejectedEmail', () => {
|
||||
describe('sendContributionDeniedEmail', () => {
|
||||
beforeAll(async () => {
|
||||
result = await sendContributionRejectedEmail({
|
||||
result = await sendContributionDeniedEmail({
|
||||
firstName: 'Peter',
|
||||
lastName: 'Lustig',
|
||||
email: 'peter@lustig.de',
|
||||
@ -379,7 +379,7 @@ describe('sendEmailVariants', () => {
|
||||
receiver: {
|
||||
to: 'Peter Lustig <peter@lustig.de>',
|
||||
},
|
||||
template: 'contributionRejected',
|
||||
template: 'contributionDenied',
|
||||
locals: {
|
||||
firstName: 'Peter',
|
||||
lastName: 'Lustig',
|
||||
|
||||
@ -103,7 +103,7 @@ export const sendContributionConfirmedEmail = (data: {
|
||||
})
|
||||
}
|
||||
|
||||
export const sendContributionRejectedEmail = (data: {
|
||||
export const sendContributionDeniedEmail = (data: {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
@ -114,7 +114,7 @@ export const sendContributionRejectedEmail = (data: {
|
||||
}): Promise<Record<string, unknown> | null> => {
|
||||
return sendEmailTranslated({
|
||||
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
|
||||
template: 'contributionRejected',
|
||||
template: 'contributionDenied',
|
||||
locals: {
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
|
||||
16
backend/src/emails/templates/contributionDenied/html.pug
Normal file
16
backend/src/emails/templates/contributionDenied/html.pug
Normal file
@ -0,0 +1,16 @@
|
||||
doctype html
|
||||
html(lang=locale)
|
||||
head
|
||||
title= t('emails.contributionDenied.subject')
|
||||
body
|
||||
h1(style='margin-bottom: 24px;')= t('emails.contributionDenied.subject')
|
||||
#container.col
|
||||
include ../hello.pug
|
||||
p= t('emails.contributionDenied.commonGoodContributionDenied', { senderFirstName, senderLastName, contributionMemo })
|
||||
p= t('emails.contributionDenied.toSeeContributionsAndMessages')
|
||||
p
|
||||
= t('emails.general.linkToYourAccount')
|
||||
= " "
|
||||
a(href=overviewURL) #{overviewURL}
|
||||
p= t('emails.general.pleaseDoNotReply')
|
||||
include ../greatingFormularImprint.pug
|
||||
@ -0,0 +1 @@
|
||||
= t('emails.contributionDenied.subject')
|
||||
@ -1,16 +0,0 @@
|
||||
doctype html
|
||||
html(lang=locale)
|
||||
head
|
||||
title= t('emails.contributionRejected.subject')
|
||||
body
|
||||
h1(style='margin-bottom: 24px;')= t('emails.contributionRejected.subject')
|
||||
#container.col
|
||||
include ../hello.pug
|
||||
p= t('emails.contributionRejected.commonGoodContributionRejected', { senderFirstName, senderLastName, contributionMemo })
|
||||
p= t('emails.contributionRejected.toSeeContributionsAndMessages')
|
||||
p
|
||||
= t('emails.general.linkToYourAccount')
|
||||
= " "
|
||||
a(href=overviewURL) #{overviewURL}
|
||||
p= t('emails.general.pleaseDoNotReply')
|
||||
include ../greatingFormularImprint.pug
|
||||
@ -1 +0,0 @@
|
||||
= t('emails.contributionRejected.subject')
|
||||
@ -7,6 +7,7 @@ class EventProtocolEmitter {
|
||||
/* }extends EventEmitter { */
|
||||
private events: Event[]
|
||||
|
||||
/*
|
||||
public addEvent(event: Event) {
|
||||
this.events.push(event)
|
||||
}
|
||||
@ -14,6 +15,7 @@ class EventProtocolEmitter {
|
||||
public getEvents(): Event[] {
|
||||
return this.events
|
||||
}
|
||||
*/
|
||||
|
||||
public isDisabled() {
|
||||
logger.info(`EventProtocol - isDisabled=${CONFIG.EVENT_PROTOCOL_DISABLED}`)
|
||||
|
||||
@ -1 +0,0 @@
|
||||
declare module '@hyperswarm/dht'
|
||||
@ -1,798 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import { startDHT } from './index'
|
||||
import DHT from '@hyperswarm/dht'
|
||||
import CONFIG from '@/config'
|
||||
import { logger } from '@test/testSetup'
|
||||
import { Community as DbCommunity } from '@entity/Community'
|
||||
import { testEnvironment, cleanDB } from '@test/helpers'
|
||||
|
||||
CONFIG.FEDERATION_DHT_SEED = '64ebcb0e3ad547848fef4197c6e2332f'
|
||||
|
||||
jest.mock('@hyperswarm/dht')
|
||||
|
||||
const TEST_TOPIC = 'gradido_test_topic'
|
||||
|
||||
const keyPairMock = {
|
||||
publicKey: Buffer.from('publicKey'),
|
||||
secretKey: Buffer.from('secretKey'),
|
||||
}
|
||||
|
||||
const serverListenSpy = jest.fn()
|
||||
|
||||
const serverEventMocks: { [key: string]: any } = {}
|
||||
|
||||
const serverOnMock = jest.fn().mockImplementation((key: string, callback) => {
|
||||
serverEventMocks[key] = callback
|
||||
})
|
||||
|
||||
const nodeCreateServerMock = jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
on: serverOnMock,
|
||||
listen: serverListenSpy,
|
||||
}
|
||||
})
|
||||
|
||||
const nodeAnnounceMock = jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
finished: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
const lookupResultMock = {
|
||||
token: Buffer.from(TEST_TOPIC),
|
||||
from: {
|
||||
id: Buffer.from('somone'),
|
||||
host: '188.95.53.5',
|
||||
port: 63561,
|
||||
},
|
||||
to: { id: null, host: '83.53.31.27', port: 55723 },
|
||||
peers: [
|
||||
{
|
||||
publicKey: Buffer.from('some-public-key'),
|
||||
relayAddresses: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const nodeLookupMock = jest.fn().mockResolvedValue([lookupResultMock])
|
||||
|
||||
const socketEventMocks: { [key: string]: any } = {}
|
||||
|
||||
const socketOnMock = jest.fn().mockImplementation((key: string, callback) => {
|
||||
socketEventMocks[key] = callback
|
||||
})
|
||||
|
||||
const socketWriteMock = jest.fn()
|
||||
|
||||
const nodeConnectMock = jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
on: socketOnMock,
|
||||
once: socketOnMock,
|
||||
write: socketWriteMock,
|
||||
}
|
||||
})
|
||||
|
||||
DHT.hash.mockImplementation(() => {
|
||||
return Buffer.from(TEST_TOPIC)
|
||||
})
|
||||
|
||||
DHT.keyPair.mockImplementation(() => {
|
||||
return keyPairMock
|
||||
})
|
||||
|
||||
DHT.mockImplementation(() => {
|
||||
return {
|
||||
createServer: nodeCreateServerMock,
|
||||
announce: nodeAnnounceMock,
|
||||
lookup: nodeLookupMock,
|
||||
connect: nodeConnectMock,
|
||||
}
|
||||
})
|
||||
|
||||
let con: any
|
||||
let testEnv: any
|
||||
|
||||
beforeAll(async () => {
|
||||
testEnv = await testEnvironment(logger)
|
||||
con = testEnv.con
|
||||
await cleanDB()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
await con.close()
|
||||
})
|
||||
|
||||
describe('federation', () => {
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
describe('call startDHT', () => {
|
||||
const hashSpy = jest.spyOn(DHT, 'hash')
|
||||
const keyPairSpy = jest.spyOn(DHT, 'keyPair')
|
||||
beforeEach(async () => {
|
||||
DHT.mockClear()
|
||||
jest.clearAllMocks()
|
||||
await startDHT(TEST_TOPIC)
|
||||
})
|
||||
|
||||
it('calls DHT.hash', () => {
|
||||
expect(hashSpy).toBeCalledWith(Buffer.from(TEST_TOPIC))
|
||||
})
|
||||
|
||||
it('creates a key pair', () => {
|
||||
expect(keyPairSpy).toBeCalledWith(expect.any(Buffer))
|
||||
})
|
||||
|
||||
it('initializes a new DHT object', () => {
|
||||
expect(DHT).toBeCalledWith({ keyPair: keyPairMock })
|
||||
})
|
||||
|
||||
describe('DHT node', () => {
|
||||
it('creates a server', () => {
|
||||
expect(nodeCreateServerMock).toBeCalled()
|
||||
})
|
||||
|
||||
it('listens on the server', () => {
|
||||
expect(serverListenSpy).toBeCalled()
|
||||
})
|
||||
|
||||
describe('timers', () => {
|
||||
beforeEach(() => {
|
||||
jest.runOnlyPendingTimers()
|
||||
})
|
||||
|
||||
it('announces on topic', () => {
|
||||
expect(nodeAnnounceMock).toBeCalledWith(Buffer.from(TEST_TOPIC), keyPairMock)
|
||||
})
|
||||
|
||||
it('looks up on topic', () => {
|
||||
expect(nodeLookupMock).toBeCalledWith(Buffer.from(TEST_TOPIC))
|
||||
})
|
||||
})
|
||||
|
||||
describe('server connection event', () => {
|
||||
beforeEach(() => {
|
||||
serverEventMocks.connection({
|
||||
remotePublicKey: Buffer.from('another-public-key'),
|
||||
on: socketOnMock,
|
||||
})
|
||||
})
|
||||
|
||||
it('can be triggered', () => {
|
||||
expect(socketOnMock).toBeCalled()
|
||||
})
|
||||
|
||||
describe('socket events', () => {
|
||||
describe('on data', () => {
|
||||
describe('with receiving simply a string', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
socketEventMocks.data(Buffer.from('no-json string'))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith('data: no-json string')
|
||||
})
|
||||
|
||||
it('logs an error of unexpected data format and structure', () => {
|
||||
expect(logger.error).toBeCalledWith(
|
||||
'Error on receiving data from socket:',
|
||||
new SyntaxError('Unexpected token o in JSON at position 1'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving array of strings', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
const strArray: string[] = ['invalid type test', 'api', 'url']
|
||||
socketEventMocks.data(Buffer.from(strArray.toString()))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith('data: invalid type test,api,url')
|
||||
})
|
||||
|
||||
it('logs an error of unexpected data format and structure', () => {
|
||||
expect(logger.error).toBeCalledWith(
|
||||
'Error on receiving data from socket:',
|
||||
new SyntaxError('Unexpected token i in JSON at position 0'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving array of string-arrays', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
const strArray: string[][] = [
|
||||
[`api`, `url`, `invalid type in array test`],
|
||||
[`wrong`, `api`, `url`],
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(strArray.toString()))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(
|
||||
'data: api,url,invalid type in array test,wrong,api,url',
|
||||
)
|
||||
})
|
||||
|
||||
it('logs an error of unexpected data format and structure', () => {
|
||||
expect(logger.error).toBeCalledWith(
|
||||
'Error on receiving data from socket:',
|
||||
new SyntaxError('Unexpected token a in JSON at position 0'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving JSON-Array with too much entries', () => {
|
||||
let jsonArray: { api: string; url: string }[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{ api: 'v1_0', url: 'too much versions at the same time test' },
|
||||
{ api: 'v1_0', url: 'url2' },
|
||||
{ api: 'v1_0', url: 'url3' },
|
||||
{ api: 'v1_0', url: 'url4' },
|
||||
{ api: 'v1_0', url: 'url5' },
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(
|
||||
'data: [{"api":"v1_0","url":"too much versions at the same time test"},{"api":"v1_0","url":"url2"},{"api":"v1_0","url":"url3"},{"api":"v1_0","url":"url4"},{"api":"v1_0","url":"url5"}]',
|
||||
)
|
||||
})
|
||||
|
||||
it('logs a warning of too much apiVersion-Definitions', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received totaly wrong or too much apiVersions-Definition JSON-String: ${JSON.stringify(
|
||||
jsonArray,
|
||||
)}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving wrong but tolerated property data', () => {
|
||||
let jsonArray: any[]
|
||||
let result: DbCommunity[] = []
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{
|
||||
wrong: 'wrong but tolerated property test',
|
||||
api: 'v1_0',
|
||||
url: 'url1',
|
||||
},
|
||||
{
|
||||
api: 'v2_0',
|
||||
url: 'url2',
|
||||
wrong: 'wrong but tolerated property test',
|
||||
},
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
result = await DbCommunity.find()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
})
|
||||
|
||||
it('has two Communty entries in database', () => {
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('has an entry for api version v1_0', () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'v1_0',
|
||||
endPoint: 'url1',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('has an entry for api version v2_0', () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'v2_0',
|
||||
endPoint: 'url2',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data but missing api property', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{ test1: 'missing api proterty test', url: 'any url definition as string' },
|
||||
{ api: 'some api', test2: 'missing url property test' },
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
|
||||
})
|
||||
|
||||
it('logs a warning of invalid apiVersion-Definition', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data but missing url property', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{ api: 'some api', test2: 'missing url property test' },
|
||||
{ test1: 'missing api proterty test', url: 'any url definition as string' },
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
|
||||
})
|
||||
|
||||
it('logs a warning of invalid apiVersion-Definition', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data but wrong type of api property', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{ api: 1, url: 'wrong property type tests' },
|
||||
{ api: 'urltyptest', url: 2 },
|
||||
{ api: 1, url: 2 },
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
|
||||
})
|
||||
|
||||
it('logs a warning of invalid apiVersion-Definition', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data but wrong type of url property', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{ api: 'urltyptest', url: 2 },
|
||||
{ api: 1, url: 'wrong property type tests' },
|
||||
{ api: 1, url: 2 },
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
|
||||
})
|
||||
|
||||
it('logs a warning of invalid apiVersion-Definition', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data but wrong type of both properties', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{ api: 1, url: 2 },
|
||||
{ api: 'urltyptest', url: 2 },
|
||||
{ api: 1, url: 'wrong property type tests' },
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
|
||||
})
|
||||
|
||||
it('logs a warning of invalid apiVersion-Definition', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data but too long api string', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{ api: 'toolong api', url: 'some valid url' },
|
||||
{
|
||||
api: 'valid api',
|
||||
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
|
||||
},
|
||||
{
|
||||
api: 'toolong api',
|
||||
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
|
||||
},
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
|
||||
})
|
||||
|
||||
it('logs a warning of invalid apiVersion-Definition', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received apiVersion with content longer than max length: ${JSON.stringify(
|
||||
jsonArray[0],
|
||||
)}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data but too long url string', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{
|
||||
api: 'api',
|
||||
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
|
||||
},
|
||||
{ api: 'toolong api', url: 'some valid url' },
|
||||
{
|
||||
api: 'toolong api',
|
||||
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
|
||||
},
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
|
||||
})
|
||||
|
||||
it('logs a warning of invalid apiVersion-Definition', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received apiVersion with content longer than max length: ${JSON.stringify(
|
||||
jsonArray[0],
|
||||
)}`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data but both properties with too long strings', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{
|
||||
api: 'toolong api',
|
||||
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
|
||||
},
|
||||
{
|
||||
api: 'api',
|
||||
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
|
||||
},
|
||||
{ api: 'toolong api', url: 'some valid url' },
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data of exact max allowed properties length', () => {
|
||||
let jsonArray: any[]
|
||||
let result: DbCommunity[] = []
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{
|
||||
api: 'valid api',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
{
|
||||
api: 'api',
|
||||
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
|
||||
},
|
||||
{ api: 'toolong api', url: 'some valid url' },
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
result = await DbCommunity.find()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
})
|
||||
|
||||
it('has one Communty entry in database', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it(`has an entry with max content length for api and url`, () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'valid api',
|
||||
endPoint:
|
||||
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data of exact max allowed buffer length', () => {
|
||||
let jsonArray: any[]
|
||||
let result: DbCommunity[] = []
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{
|
||||
api: 'valid api1',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
{
|
||||
api: 'valid api2',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
{
|
||||
api: 'valid api3',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
{
|
||||
api: 'valid api4',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
result = await DbCommunity.find()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
})
|
||||
|
||||
it('has five Communty entries in database', () => {
|
||||
expect(result).toHaveLength(4)
|
||||
})
|
||||
|
||||
it(`has an entry 'valid api1' with max content length for api and url`, () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'valid api1',
|
||||
endPoint:
|
||||
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it(`has an entry 'valid api2' with max content length for api and url`, () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'valid api2',
|
||||
endPoint:
|
||||
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it(`has an entry 'valid api3' with max content length for api and url`, () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'valid api3',
|
||||
endPoint:
|
||||
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it(`has an entry 'valid api4' with max content length for api and url`, () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'valid api4',
|
||||
endPoint:
|
||||
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with receiving data longer than max allowed buffer length', () => {
|
||||
let jsonArray: any[]
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
jsonArray = [
|
||||
{
|
||||
api: 'Xvalid api1',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
{
|
||||
api: 'valid api2',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
{
|
||||
api: 'valid api3',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
{
|
||||
api: 'valid api4',
|
||||
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
|
||||
},
|
||||
]
|
||||
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
|
||||
})
|
||||
|
||||
it('logs the received data', () => {
|
||||
expect(logger.warn).toBeCalledWith(
|
||||
`received more than max allowed length of data buffer: ${
|
||||
JSON.stringify(jsonArray).length
|
||||
} against 1141 max allowed`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with proper data', () => {
|
||||
let result: DbCommunity[] = []
|
||||
beforeAll(async () => {
|
||||
jest.clearAllMocks()
|
||||
await socketEventMocks.data(
|
||||
Buffer.from(
|
||||
JSON.stringify([
|
||||
{
|
||||
api: 'v1_0',
|
||||
url: 'http://localhost:4000/api/v1_0',
|
||||
},
|
||||
{
|
||||
api: 'v2_0',
|
||||
url: 'http://localhost:4000/api/v2_0',
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
result = await DbCommunity.find()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
})
|
||||
|
||||
it('has two Communty entries in database', () => {
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('has an entry for api version v1_0', () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'v1_0',
|
||||
endPoint: 'http://localhost:4000/api/v1_0',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('has an entry for api version v2_0', () => {
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
publicKey: expect.any(Buffer),
|
||||
apiVersion: 'v2_0',
|
||||
endPoint: 'http://localhost:4000/api/v2_0',
|
||||
lastAnnouncedAt: expect.any(Date),
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: null,
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('on open', () => {
|
||||
beforeEach(() => {
|
||||
socketEventMocks.open()
|
||||
})
|
||||
|
||||
it.skip('calls socket write with own api versions', () => {
|
||||
expect(socketWriteMock).toBeCalledWith(
|
||||
Buffer.from(
|
||||
JSON.stringify([
|
||||
{
|
||||
api: 'v1_0',
|
||||
url: 'http://localhost:4000/api/v1_0',
|
||||
},
|
||||
{
|
||||
api: 'v1_1',
|
||||
url: 'http://localhost:4000/api/v1_1',
|
||||
},
|
||||
{
|
||||
api: 'v2_0',
|
||||
url: 'http://localhost:4000/api/v2_0',
|
||||
},
|
||||
]),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,185 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
import DHT from '@hyperswarm/dht'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import CONFIG from '@/config'
|
||||
import { Community as DbCommunity } from '@entity/Community'
|
||||
|
||||
const KEY_SECRET_SEEDBYTES = 32
|
||||
const getSeed = (): Buffer | null =>
|
||||
CONFIG.FEDERATION_DHT_SEED ? Buffer.alloc(KEY_SECRET_SEEDBYTES, CONFIG.FEDERATION_DHT_SEED) : null
|
||||
|
||||
const POLLTIME = 20000
|
||||
const SUCCESSTIME = 120000
|
||||
const ERRORTIME = 240000
|
||||
const ANNOUNCETIME = 30000
|
||||
|
||||
enum ApiVersionType {
|
||||
V1_0 = 'v1_0',
|
||||
V1_1 = 'v1_1',
|
||||
V2_0 = 'v2_0',
|
||||
}
|
||||
type CommunityApi = {
|
||||
api: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export const startDHT = async (topic: string): Promise<void> => {
|
||||
try {
|
||||
const TOPIC = DHT.hash(Buffer.from(topic))
|
||||
const keyPair = DHT.keyPair(getSeed())
|
||||
logger.info(`keyPairDHT: publicKey=${keyPair.publicKey.toString('hex')}`)
|
||||
logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex')}`)
|
||||
|
||||
const ownApiVersions = Object.values(ApiVersionType).map(function (apiEnum) {
|
||||
const comApi: CommunityApi = {
|
||||
api: apiEnum,
|
||||
url: CONFIG.FEDERATION_COMMUNITY_URL + apiEnum,
|
||||
}
|
||||
return comApi
|
||||
})
|
||||
logger.debug(`ApiList: ${JSON.stringify(ownApiVersions)}`)
|
||||
|
||||
const node = new DHT({ keyPair })
|
||||
|
||||
const server = node.createServer()
|
||||
|
||||
server.on('connection', function (socket: any) {
|
||||
logger.info(`server on... with Remote public key: ${socket.remotePublicKey.toString('hex')}`)
|
||||
|
||||
socket.on('data', async (data: Buffer) => {
|
||||
try {
|
||||
if (data.length > 1141) {
|
||||
logger.warn(
|
||||
`received more than max allowed length of data buffer: ${data.length} against 1141 max allowed`,
|
||||
)
|
||||
return
|
||||
}
|
||||
logger.info(`data: ${data.toString('ascii')}`)
|
||||
const recApiVersions: CommunityApi[] = JSON.parse(data.toString('ascii'))
|
||||
|
||||
// TODO better to introduce the validation by https://github.com/typestack/class-validato
|
||||
if (recApiVersions && Array.isArray(recApiVersions) && recApiVersions.length < 5) {
|
||||
for (const recApiVersion of recApiVersions) {
|
||||
if (
|
||||
!recApiVersion.api ||
|
||||
typeof recApiVersion.api !== 'string' ||
|
||||
!recApiVersion.url ||
|
||||
typeof recApiVersion.url !== 'string'
|
||||
) {
|
||||
logger.warn(
|
||||
`received invalid apiVersion-Definition: ${JSON.stringify(recApiVersion)}`,
|
||||
)
|
||||
// in a forEach-loop use return instead of continue
|
||||
return
|
||||
}
|
||||
// TODO better to introduce the validation on entity-Level by https://github.com/typestack/class-validator
|
||||
if (recApiVersion.api.length > 10 || recApiVersion.url.length > 255) {
|
||||
logger.warn(
|
||||
`received apiVersion with content longer than max length: ${JSON.stringify(
|
||||
recApiVersion,
|
||||
)}`,
|
||||
)
|
||||
// in a forEach-loop use return instead of continue
|
||||
return
|
||||
}
|
||||
|
||||
const variables = {
|
||||
apiVersion: recApiVersion.api,
|
||||
endPoint: recApiVersion.url,
|
||||
publicKey: socket.remotePublicKey.toString('hex'),
|
||||
lastAnnouncedAt: new Date(),
|
||||
}
|
||||
logger.debug(`upsert with variables=${JSON.stringify(variables)}`)
|
||||
// this will NOT update the updatedAt column, to distingue between a normal update and the last announcement
|
||||
await DbCommunity.createQueryBuilder()
|
||||
.insert()
|
||||
.into(DbCommunity)
|
||||
.values(variables)
|
||||
.orUpdate({
|
||||
conflict_target: ['id', 'publicKey', 'apiVersion'],
|
||||
overwrite: ['end_point', 'last_announced_at'],
|
||||
})
|
||||
.execute()
|
||||
logger.info(`federation community upserted successfully...`)
|
||||
}
|
||||
} else {
|
||||
logger.warn(
|
||||
`received totaly wrong or too much apiVersions-Definition JSON-String: ${JSON.stringify(
|
||||
recApiVersions,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error on receiving data from socket:', e)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await server.listen()
|
||||
|
||||
setInterval(async () => {
|
||||
logger.info(`Announcing on topic: ${TOPIC.toString('hex')}`)
|
||||
await node.announce(TOPIC, keyPair).finished()
|
||||
}, ANNOUNCETIME)
|
||||
|
||||
let successfulRequests: string[] = []
|
||||
let errorfulRequests: string[] = []
|
||||
|
||||
setInterval(async () => {
|
||||
logger.info('Refreshing successful nodes')
|
||||
successfulRequests = []
|
||||
}, SUCCESSTIME)
|
||||
|
||||
setInterval(async () => {
|
||||
logger.info('Refreshing errorful nodes')
|
||||
errorfulRequests = []
|
||||
}, ERRORTIME)
|
||||
|
||||
setInterval(async () => {
|
||||
const result = await node.lookup(TOPIC)
|
||||
|
||||
const collectedPubKeys: string[] = []
|
||||
|
||||
for await (const data of result) {
|
||||
data.peers.forEach((peer: any) => {
|
||||
const pubKey = peer.publicKey.toString('hex')
|
||||
if (
|
||||
pubKey !== keyPair.publicKey.toString('hex') &&
|
||||
!successfulRequests.includes(pubKey) &&
|
||||
!errorfulRequests.includes(pubKey) &&
|
||||
!collectedPubKeys.includes(pubKey)
|
||||
) {
|
||||
collectedPubKeys.push(peer.publicKey.toString('hex'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(`Found new peers: ${collectedPubKeys}`)
|
||||
|
||||
collectedPubKeys.forEach((remotePubKey) => {
|
||||
const socket = node.connect(Buffer.from(remotePubKey, 'hex'))
|
||||
|
||||
// socket.once("connect", function () {
|
||||
// console.log("client side emitted connect");
|
||||
// });
|
||||
|
||||
// socket.once("end", function () {
|
||||
// console.log("client side ended");
|
||||
// });
|
||||
|
||||
socket.once('error', (err: any) => {
|
||||
errorfulRequests.push(remotePubKey)
|
||||
logger.error(`error on peer ${remotePubKey}: ${err.message}`)
|
||||
})
|
||||
|
||||
socket.on('open', function () {
|
||||
socket.write(Buffer.from(JSON.stringify(ownApiVersions)))
|
||||
successfulRequests.push(remotePubKey)
|
||||
})
|
||||
})
|
||||
}, POLLTIME)
|
||||
} catch (err) {
|
||||
logger.error('DHT unexpected error:', err)
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { registerEnumType } from 'type-graphql'
|
||||
|
||||
export enum TransactionType {
|
||||
CREATION = 'creation',
|
||||
SEND = 'send',
|
||||
RECIEVE = 'receive',
|
||||
}
|
||||
|
||||
registerEnumType(TransactionType, {
|
||||
name: 'TransactionType', // this one is mandatory
|
||||
description: 'Name of the Type of the transaction', // this one is optional
|
||||
})
|
||||
@ -2,13 +2,25 @@ import { ObjectType, Field } from 'type-graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
@ObjectType()
|
||||
export class CommunityStatistics {
|
||||
@Field(() => Number)
|
||||
totalUsers: number
|
||||
|
||||
export class DynamicStatisticsFields {
|
||||
@Field(() => Number)
|
||||
activeUsers: number
|
||||
|
||||
@Field(() => Decimal)
|
||||
totalGradidoAvailable: Decimal
|
||||
|
||||
@Field(() => Decimal)
|
||||
totalGradidoUnbookedDecayed: Decimal
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class CommunityStatistics {
|
||||
@Field(() => Number)
|
||||
allUsers: number
|
||||
|
||||
@Field(() => Number)
|
||||
totalUsers: number
|
||||
|
||||
@Field(() => Number)
|
||||
deletedUsers: number
|
||||
|
||||
@ -18,9 +30,7 @@ export class CommunityStatistics {
|
||||
@Field(() => Decimal)
|
||||
totalGradidoDecayed: Decimal
|
||||
|
||||
@Field(() => Decimal)
|
||||
totalGradidoAvailable: Decimal
|
||||
|
||||
@Field(() => Decimal)
|
||||
totalGradidoUnbookedDecayed: Decimal
|
||||
// be carefull querying this, takes longer than 2 secs.
|
||||
@Field(() => DynamicStatisticsFields)
|
||||
dynamicStatisticsFields: DynamicStatisticsFields
|
||||
}
|
||||
|
||||
@ -18,6 +18,8 @@ export class Contribution {
|
||||
this.contributionDate = contribution.contributionDate
|
||||
this.state = contribution.contributionStatus
|
||||
this.messagesCount = contribution.messages ? contribution.messages.length : 0
|
||||
this.deniedAt = contribution.deniedAt
|
||||
this.deniedBy = contribution.deniedBy
|
||||
}
|
||||
|
||||
@Field(() => Number)
|
||||
@ -47,6 +49,12 @@ export class Contribution {
|
||||
@Field(() => Number, { nullable: true })
|
||||
confirmedBy: number | null
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
deniedAt: Date | null
|
||||
|
||||
@Field(() => Number, { nullable: true })
|
||||
deniedBy: number | null
|
||||
|
||||
@Field(() => Date)
|
||||
contributionDate: Date
|
||||
|
||||
|
||||
14
backend/src/graphql/model/OpenCreation.ts
Normal file
14
backend/src/graphql/model/OpenCreation.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { ObjectType, Field, Int } from 'type-graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
@ObjectType()
|
||||
export class OpenCreation {
|
||||
@Field(() => Int)
|
||||
month: number
|
||||
|
||||
@Field(() => Int)
|
||||
year: number
|
||||
|
||||
@Field(() => Decimal)
|
||||
amount: Decimal
|
||||
}
|
||||
@ -1,13 +1,11 @@
|
||||
import { ObjectType, Field } from 'type-graphql'
|
||||
import { KlickTipp } from './KlickTipp'
|
||||
import { User as dbUser } from '@entity/User'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { FULL_CREATION_AVAILABLE } from '../resolver/const/const'
|
||||
import { UserContact } from './UserContact'
|
||||
|
||||
@ObjectType()
|
||||
export class User {
|
||||
constructor(user: dbUser, creation: Decimal[] = FULL_CREATION_AVAILABLE) {
|
||||
constructor(user: dbUser) {
|
||||
this.id = user.id
|
||||
this.gradidoID = user.gradidoID
|
||||
this.alias = user.alias
|
||||
@ -26,7 +24,6 @@ export class User {
|
||||
this.isAdmin = user.isAdmin
|
||||
this.klickTipp = null
|
||||
this.hasElopage = null
|
||||
this.creation = creation
|
||||
this.hideAmountGDD = user.hideAmountGDD
|
||||
this.hideAmountGDT = user.hideAmountGDT
|
||||
}
|
||||
@ -34,9 +31,6 @@ export class User {
|
||||
@Field(() => Number)
|
||||
id: number
|
||||
|
||||
// `public_key` binary(32) DEFAULT NULL,
|
||||
// `privkey` binary(80) DEFAULT NULL,
|
||||
|
||||
@Field(() => String)
|
||||
gradidoID: string
|
||||
|
||||
@ -62,9 +56,6 @@ export class User {
|
||||
@Field(() => Date, { nullable: true })
|
||||
deletedAt: Date | null
|
||||
|
||||
// `password` bigint(20) unsigned DEFAULT 0,
|
||||
// `email_hash` binary(32) DEFAULT NULL,
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt: Date
|
||||
|
||||
@ -84,8 +75,6 @@ export class User {
|
||||
@Field(() => Number, { nullable: true })
|
||||
publisherId: number | null
|
||||
|
||||
// `passphrase` text COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
isAdmin: Date | null
|
||||
|
||||
@ -94,7 +83,4 @@ export class User {
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
hasElopage: boolean | null
|
||||
|
||||
@Field(() => [Decimal])
|
||||
creation: Decimal[]
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
||||
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
|
||||
import { stephenHawking } from '@/seeds/users/stephen-hawking'
|
||||
import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
|
||||
import {
|
||||
@ -26,7 +27,13 @@ import {
|
||||
sendContributionConfirmedEmail,
|
||||
// sendContributionRejectedEmail,
|
||||
} from '@/emails/sendEmailVariants'
|
||||
import { cleanDB, resetToken, testEnvironment, contributionDateFormatter } from '@test/helpers'
|
||||
import {
|
||||
cleanDB,
|
||||
resetToken,
|
||||
testEnvironment,
|
||||
contributionDateFormatter,
|
||||
resetEntity,
|
||||
} from '@test/helpers'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { userFactory } from '@/seeds/factory/user'
|
||||
import { creationFactory } from '@/seeds/factory/creation'
|
||||
@ -1818,6 +1825,49 @@ describe('ContributionResolver', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('creation already confirmed', () => {
|
||||
it('throws an error', async () => {
|
||||
await userFactory(testEnv, bobBaumeister)
|
||||
await query({
|
||||
query: login,
|
||||
variables: { email: 'bob@baumeister.de', password: 'Aa12345_' },
|
||||
})
|
||||
const {
|
||||
data: { createContribution: confirmedContribution },
|
||||
} = await mutate({
|
||||
mutation: createContribution,
|
||||
variables: {
|
||||
amount: 100.0,
|
||||
memo: 'Confirmed Contribution',
|
||||
creationDate: contributionDateFormatter(new Date()),
|
||||
},
|
||||
})
|
||||
await query({
|
||||
query: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
await mutate({
|
||||
mutation: confirmContribution,
|
||||
variables: {
|
||||
id: confirmedContribution.id ? confirmedContribution.id : -1,
|
||||
},
|
||||
})
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: adminDeleteContribution,
|
||||
variables: {
|
||||
id: confirmedContribution.id ? confirmedContribution.id : -1,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('A confirmed contribution can not be deleted')],
|
||||
}),
|
||||
)
|
||||
await resetEntity(DbTransaction)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmContribution', () => {
|
||||
|
||||
@ -11,8 +11,9 @@ import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||
import { AdminCreateContributions } from '@model/AdminCreateContributions'
|
||||
import { AdminUpdateContribution } from '@model/AdminUpdateContribution'
|
||||
import { Contribution, ContributionListResult } from '@model/Contribution'
|
||||
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
||||
import { Decay } from '@model/Decay'
|
||||
import { OpenCreation } from '@model/OpenCreation'
|
||||
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
|
||||
import { TransactionTypeId } from '@enum/TransactionTypeId'
|
||||
import { Order } from '@enum/Order'
|
||||
import { ContributionType } from '@enum/ContributionType'
|
||||
@ -27,6 +28,7 @@ import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { Context, getUser, getClientTimezoneOffset } from '@/server/context'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import {
|
||||
getCreationDates,
|
||||
getUserCreation,
|
||||
getUserCreations,
|
||||
validateContribution,
|
||||
@ -48,7 +50,7 @@ import { eventProtocol } from '@/event/EventProtocolEmitter'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
import {
|
||||
sendContributionConfirmedEmail,
|
||||
sendContributionRejectedEmail,
|
||||
sendContributionDeniedEmail,
|
||||
} from '@/emails/sendEmailVariants'
|
||||
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
|
||||
|
||||
@ -215,7 +217,7 @@ export class ContributionResolver {
|
||||
const user = getUser(context)
|
||||
|
||||
const contributionToUpdate = await DbContribution.findOne({
|
||||
where: { id: contributionId, confirmedAt: IsNull() },
|
||||
where: { id: contributionId, confirmedAt: IsNull(), deniedAt: IsNull() },
|
||||
})
|
||||
if (!contributionToUpdate) {
|
||||
logger.error('No contribution found to given id')
|
||||
@ -407,7 +409,7 @@ export class ContributionResolver {
|
||||
const moderator = getUser(context)
|
||||
|
||||
const contributionToUpdate = await DbContribution.findOne({
|
||||
where: { id, confirmedAt: IsNull() },
|
||||
where: { id, confirmedAt: IsNull(), deniedAt: IsNull() },
|
||||
})
|
||||
if (!contributionToUpdate) {
|
||||
logger.error('No contribution found to given id.')
|
||||
@ -473,6 +475,7 @@ export class ContributionResolver {
|
||||
.from(DbContribution, 'c')
|
||||
.leftJoinAndSelect('c.messages', 'm')
|
||||
.where({ confirmedAt: IsNull() })
|
||||
.andWhere({ deniedAt: IsNull() })
|
||||
.getMany()
|
||||
|
||||
if (contributions.length === 0) {
|
||||
@ -510,6 +513,10 @@ export class ContributionResolver {
|
||||
logger.error(`Contribution not found for given id: ${id}`)
|
||||
throw new Error('Contribution not found for given id.')
|
||||
}
|
||||
if (contribution.confirmedAt) {
|
||||
logger.error('A confirmed contribution can not be deleted')
|
||||
throw new Error('A confirmed contribution can not be deleted')
|
||||
}
|
||||
const moderator = getUser(context)
|
||||
if (
|
||||
contribution.contributionType === ContributionType.USER &&
|
||||
@ -534,7 +541,7 @@ export class ContributionResolver {
|
||||
await eventProtocol.writeEvent(
|
||||
event.setEventAdminContributionDelete(eventAdminContributionDelete),
|
||||
)
|
||||
sendContributionRejectedEmail({
|
||||
sendContributionDeniedEmail({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.emailContact.email,
|
||||
@ -555,7 +562,6 @@ export class ContributionResolver {
|
||||
): Promise<boolean> {
|
||||
// acquire lock
|
||||
const releaseLock = await TRANSACTIONS_LOCK.acquire()
|
||||
|
||||
try {
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
const contribution = await DbContribution.findOne(id)
|
||||
@ -567,6 +573,10 @@ export class ContributionResolver {
|
||||
logger.error(`Contribution already confirmd: ${id}`)
|
||||
throw new Error('Contribution already confirmd.')
|
||||
}
|
||||
if (contribution.contributionStatus === 'DENIED') {
|
||||
logger.error(`Contribution already denied: ${id}`)
|
||||
throw new Error('Contribution already denied.')
|
||||
}
|
||||
const moderatorUser = getUser(context)
|
||||
if (moderatorUser.id === contribution.userId) {
|
||||
logger.error('Moderator can not confirm own contribution')
|
||||
@ -662,7 +672,6 @@ export class ContributionResolver {
|
||||
} finally {
|
||||
releaseLock()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@ -680,6 +689,7 @@ export class ContributionResolver {
|
||||
.from(DbContribution, 'c')
|
||||
.leftJoinAndSelect('c.user', 'u')
|
||||
.where(`user_id = ${userId}`)
|
||||
.withDeleted()
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
.orderBy('c.created_at', order)
|
||||
@ -691,4 +701,77 @@ export class ContributionResolver {
|
||||
)
|
||||
// return userTransactions.map((t) => new Transaction(t, new User(user), communityUser))
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.OPEN_CREATIONS])
|
||||
@Query(() => [OpenCreation])
|
||||
async openCreations(
|
||||
@Arg('userId', () => Int, { nullable: true }) userId: number | null,
|
||||
@Ctx() context: Context,
|
||||
): Promise<OpenCreation[]> {
|
||||
const id = userId || getUser(context).id
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
const creationDates = getCreationDates(clientTimezoneOffset)
|
||||
const creations = await getUserCreation(id, clientTimezoneOffset)
|
||||
return creationDates.map((date, index) => {
|
||||
return {
|
||||
month: date.getMonth(),
|
||||
year: date.getFullYear(),
|
||||
amount: creations[index],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Authorized([RIGHTS.DENY_CONTRIBUTION])
|
||||
@Mutation(() => Boolean)
|
||||
async denyContribution(
|
||||
@Arg('id', () => Int) id: number,
|
||||
@Ctx() context: Context,
|
||||
): Promise<boolean> {
|
||||
const contributionToUpdate = await DbContribution.findOne({
|
||||
id,
|
||||
confirmedAt: IsNull(),
|
||||
deniedBy: IsNull(),
|
||||
})
|
||||
if (!contributionToUpdate) {
|
||||
logger.error(`Contribution not found for given id: ${id}`)
|
||||
throw new Error(`Contribution not found for given id.`)
|
||||
}
|
||||
if (
|
||||
contributionToUpdate.contributionStatus !== ContributionStatus.IN_PROGRESS &&
|
||||
contributionToUpdate.contributionStatus !== ContributionStatus.PENDING
|
||||
) {
|
||||
logger.error(
|
||||
`Contribution state (${contributionToUpdate.contributionStatus}) is not allowed.`,
|
||||
)
|
||||
throw new Error(`State of the contribution is not allowed.`)
|
||||
}
|
||||
const moderator = getUser(context)
|
||||
const user = await DbUser.findOne(
|
||||
{ id: contributionToUpdate.userId },
|
||||
{ relations: ['emailContact'] },
|
||||
)
|
||||
if (!user) {
|
||||
logger.error(
|
||||
`Could not find User for the Contribution (userId: ${contributionToUpdate.userId}).`,
|
||||
)
|
||||
throw new Error('Could not find User for the Contribution.')
|
||||
}
|
||||
|
||||
contributionToUpdate.contributionStatus = ContributionStatus.DENIED
|
||||
contributionToUpdate.deniedBy = moderator.id
|
||||
contributionToUpdate.deniedAt = new Date()
|
||||
const res = await contributionToUpdate.save()
|
||||
|
||||
sendContributionDeniedEmail({
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.emailContact.email,
|
||||
language: user.language,
|
||||
senderFirstName: moderator.firstName,
|
||||
senderLastName: moderator.lastName,
|
||||
contributionMemo: contributionToUpdate.memo,
|
||||
})
|
||||
|
||||
return !!res
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,81 +1,113 @@
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { Resolver, Query, Authorized } from 'type-graphql'
|
||||
import { Resolver, Query, Authorized, FieldResolver } from 'type-graphql'
|
||||
import { getConnection } from '@dbTools/typeorm'
|
||||
|
||||
import { Transaction as DbTransaction } from '@entity/Transaction'
|
||||
import { User as DbUser } from '@entity/User'
|
||||
|
||||
import { CommunityStatistics } from '@model/CommunityStatistics'
|
||||
import { CommunityStatistics, DynamicStatisticsFields } from '@model/CommunityStatistics'
|
||||
|
||||
import { RIGHTS } from '@/auth/RIGHTS'
|
||||
import { calculateDecay } from '@/util/decay'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
@Resolver()
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
@Resolver((of) => CommunityStatistics)
|
||||
export class StatisticsResolver {
|
||||
@Authorized([RIGHTS.COMMUNITY_STATISTICS])
|
||||
@Query(() => CommunityStatistics)
|
||||
async communityStatistics(): Promise<CommunityStatistics> {
|
||||
const allUsers = await DbUser.count({ withDeleted: true })
|
||||
const totalUsers = await DbUser.count()
|
||||
const deletedUsers = allUsers - totalUsers
|
||||
return new CommunityStatistics()
|
||||
}
|
||||
|
||||
@FieldResolver(() => Decimal)
|
||||
async allUsers(): Promise<number> {
|
||||
return await DbUser.count({ withDeleted: true })
|
||||
}
|
||||
|
||||
@FieldResolver()
|
||||
async totalUsers(): Promise<number> {
|
||||
return await DbUser.count()
|
||||
}
|
||||
|
||||
@FieldResolver()
|
||||
async deletedUsers(): Promise<number> {
|
||||
return (await this.allUsers()) - (await this.totalUsers())
|
||||
}
|
||||
|
||||
@FieldResolver()
|
||||
async totalGradidoCreated(): Promise<Decimal> {
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
try {
|
||||
await queryRunner.connect()
|
||||
const { totalGradidoCreated } = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('SUM(transaction.amount) AS totalGradidoCreated')
|
||||
.from(DbTransaction, 'transaction')
|
||||
.where('transaction.typeId = 1')
|
||||
.getRawOne()
|
||||
return totalGradidoCreated
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
}
|
||||
}
|
||||
|
||||
@FieldResolver()
|
||||
async totalGradidoDecayed(): Promise<Decimal> {
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
try {
|
||||
await queryRunner.connect()
|
||||
const { totalGradidoDecayed } = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('SUM(transaction.decay) AS totalGradidoDecayed')
|
||||
.from(DbTransaction, 'transaction')
|
||||
.where('transaction.decay IS NOT NULL')
|
||||
.getRawOne()
|
||||
return totalGradidoDecayed
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
}
|
||||
}
|
||||
|
||||
@FieldResolver()
|
||||
async dynamicStatisticsFields(): Promise<DynamicStatisticsFields> {
|
||||
let totalGradidoAvailable: Decimal = new Decimal(0)
|
||||
let totalGradidoUnbookedDecayed: Decimal = new Decimal(0)
|
||||
|
||||
const receivedCallDate = new Date()
|
||||
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
try {
|
||||
await queryRunner.connect()
|
||||
|
||||
const lastUserTransactions = await queryRunner.manager
|
||||
.createQueryBuilder(DbUser, 'user')
|
||||
.select('transaction.balance', 'balance')
|
||||
.addSelect('transaction.balance_date', 'balanceDate')
|
||||
.innerJoin(DbTransaction, 'transaction', 'user.id = transaction.user_id')
|
||||
.where(
|
||||
`transaction.balance_date = (SELECT MAX(t.balance_date) FROM transactions AS t WHERE t.user_id = user.id)`,
|
||||
)
|
||||
.orderBy('transaction.balance_date', 'DESC')
|
||||
.addOrderBy('transaction.id', 'DESC')
|
||||
.getRawMany()
|
||||
const lastUserTransactions = await queryRunner.manager
|
||||
.createQueryBuilder(DbUser, 'user')
|
||||
.select('transaction.balance', 'balance')
|
||||
.addSelect('transaction.balance_date', 'balanceDate')
|
||||
.innerJoin(DbTransaction, 'transaction', 'user.id = transaction.user_id')
|
||||
.where(
|
||||
`transaction.balance_date = (SELECT MAX(t.balance_date) FROM transactions AS t WHERE t.user_id = user.id)`,
|
||||
)
|
||||
.orderBy('transaction.balance_date', 'DESC')
|
||||
.addOrderBy('transaction.id', 'DESC')
|
||||
.getRawMany()
|
||||
|
||||
const activeUsers = lastUserTransactions.length
|
||||
const activeUsers = lastUserTransactions.length
|
||||
|
||||
lastUserTransactions.forEach(({ balance, balanceDate }) => {
|
||||
const decay = calculateDecay(new Decimal(balance), new Date(balanceDate), receivedCallDate)
|
||||
if (decay) {
|
||||
totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString())
|
||||
totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString())
|
||||
lastUserTransactions.forEach(({ balance, balanceDate }) => {
|
||||
const decay = calculateDecay(new Decimal(balance), new Date(balanceDate), receivedCallDate)
|
||||
if (decay) {
|
||||
totalGradidoAvailable = totalGradidoAvailable.plus(decay.balance.toString())
|
||||
totalGradidoUnbookedDecayed = totalGradidoUnbookedDecayed.plus(decay.decay.toString())
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
activeUsers,
|
||||
totalGradidoAvailable,
|
||||
totalGradidoUnbookedDecayed,
|
||||
}
|
||||
})
|
||||
|
||||
const { totalGradidoCreated } = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('SUM(transaction.amount) AS totalGradidoCreated')
|
||||
.from(DbTransaction, 'transaction')
|
||||
.where('transaction.typeId = 1')
|
||||
.getRawOne()
|
||||
|
||||
const { totalGradidoDecayed } = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('SUM(transaction.decay) AS totalGradidoDecayed')
|
||||
.from(DbTransaction, 'transaction')
|
||||
.where('transaction.decay IS NOT NULL')
|
||||
.getRawOne()
|
||||
|
||||
await queryRunner.release()
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
deletedUsers,
|
||||
totalGradidoCreated,
|
||||
totalGradidoDecayed,
|
||||
totalGradidoAvailable,
|
||||
totalGradidoUnbookedDecayed,
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
import { transactionLinkCode } from './TransactionLinkResolver'
|
||||
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
|
||||
import { peterLustig } from '@/seeds/users/peter-lustig'
|
||||
import { cleanDB, testEnvironment, resetToken } from '@test/helpers'
|
||||
import { cleanDB, testEnvironment, resetToken, resetEntity } from '@test/helpers'
|
||||
import { creationFactory } from '@/seeds/factory/creation'
|
||||
import { creations } from '@/seeds/creation/index'
|
||||
import { userFactory } from '@/seeds/factory/user'
|
||||
@ -50,238 +50,340 @@ afterAll(async () => {
|
||||
})
|
||||
|
||||
describe('TransactionLinkResolver', () => {
|
||||
// TODO: have this test separated into a transactionLink and a contributionLink part (if possible)
|
||||
describe('redeem daily Contribution Link', () => {
|
||||
const now = new Date()
|
||||
let contributionLink: DbContributionLink | undefined
|
||||
let contribution: UnconfirmedContribution | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
await mutate({
|
||||
mutation: createContributionLink,
|
||||
variables: {
|
||||
amount: new Decimal(5),
|
||||
name: 'Daily Contribution Link',
|
||||
memo: 'Thank you for contribute daily to the community',
|
||||
cycle: 'DAILY',
|
||||
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
|
||||
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
|
||||
maxAmountPerMonth: new Decimal(200),
|
||||
maxPerCycle: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('has a daily contribution link in the database', async () => {
|
||||
const cls = await DbContributionLink.find()
|
||||
expect(cls).toHaveLength(1)
|
||||
contributionLink = cls[0]
|
||||
expect(contributionLink).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
name: 'Daily Contribution Link',
|
||||
memo: 'Thank you for contribute daily to the community',
|
||||
validFrom: new Date(now.getFullYear(), 0, 1),
|
||||
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0),
|
||||
cycle: 'DAILY',
|
||||
maxPerCycle: 1,
|
||||
totalMaxCountOfContribution: null,
|
||||
maxAccountBalance: null,
|
||||
minGapHours: null,
|
||||
createdAt: expect.any(Date),
|
||||
deletedAt: null,
|
||||
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
|
||||
linkEnabled: true,
|
||||
amount: expect.decimalEqual(5),
|
||||
maxAmountPerMonth: expect.decimalEqual(200),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('user has pending contribution of 1000 GDD', () => {
|
||||
beforeAll(async () => {
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
const result = await mutate({
|
||||
mutation: createContribution,
|
||||
variables: {
|
||||
amount: new Decimal(1000),
|
||||
memo: 'I was brewing potions for the community the whole month',
|
||||
creationDate: now.toISOString(),
|
||||
},
|
||||
})
|
||||
contribution = result.data.createContribution
|
||||
})
|
||||
|
||||
it('does not allow the user to redeem the contribution link', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.',
|
||||
),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has no pending contributions that would not allow to redeem the link', () => {
|
||||
beforeAll(async () => {
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
await mutate({
|
||||
mutation: updateContribution,
|
||||
variables: {
|
||||
contributionId: contribution ? contribution.id : -1,
|
||||
amount: new Decimal(800),
|
||||
memo: 'I was brewing potions for the community the whole month',
|
||||
creationDate: now.toISOString(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('allows the user to redeem the contribution link', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
redeemTransactionLink: true,
|
||||
},
|
||||
errors: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
|
||||
),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
describe('after one day', () => {
|
||||
describe('redeemTransactionLink', () => {
|
||||
describe('contributionLink', () => {
|
||||
describe('input not valid', () => {
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers()
|
||||
setTimeout(jest.fn(), 1000 * 60 * 60 * 24)
|
||||
jest.runAllTimers()
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('allows the user to redeem the contribution link again', async () => {
|
||||
it('throws error when link does not exists', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
redeemTransactionLink: true,
|
||||
},
|
||||
errors: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
code: 'CL-123456',
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
|
||||
'Creation from contribution link was not successful. Error: No contribution link found to given code: CL-123456',
|
||||
),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('throws error when link is not valid yet', async () => {
|
||||
const now = new Date()
|
||||
const {
|
||||
data: { createContributionLink: contributionLink },
|
||||
} = await mutate({
|
||||
mutation: createContributionLink,
|
||||
variables: {
|
||||
amount: new Decimal(5),
|
||||
name: 'Daily Contribution Link',
|
||||
memo: 'Thank you for contribute daily to the community',
|
||||
cycle: 'DAILY',
|
||||
validFrom: new Date(now.getFullYear() + 1, 0, 1).toISOString(),
|
||||
validTo: new Date(now.getFullYear() + 1, 11, 31, 23, 59, 59, 999).toISOString(),
|
||||
maxAmountPerMonth: new Decimal(200),
|
||||
maxPerCycle: 1,
|
||||
},
|
||||
})
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + contributionLink.code,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: Contribution link not valid yet',
|
||||
),
|
||||
],
|
||||
})
|
||||
await resetEntity(DbContributionLink)
|
||||
})
|
||||
|
||||
it('throws error when contributionLink cycle is invalid', async () => {
|
||||
const now = new Date()
|
||||
const {
|
||||
data: { createContributionLink: contributionLink },
|
||||
} = await mutate({
|
||||
mutation: createContributionLink,
|
||||
variables: {
|
||||
amount: new Decimal(5),
|
||||
name: 'Daily Contribution Link',
|
||||
memo: 'Thank you for contribute daily to the community',
|
||||
cycle: 'INVALID',
|
||||
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
|
||||
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
|
||||
maxAmountPerMonth: new Decimal(200),
|
||||
maxPerCycle: 1,
|
||||
},
|
||||
})
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + contributionLink.code,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: Contribution link has unknown cycle',
|
||||
),
|
||||
],
|
||||
})
|
||||
await resetEntity(DbContributionLink)
|
||||
})
|
||||
|
||||
it('throws error when link is no longer valid', async () => {
|
||||
const now = new Date()
|
||||
const {
|
||||
data: { createContributionLink: contributionLink },
|
||||
} = await mutate({
|
||||
mutation: createContributionLink,
|
||||
variables: {
|
||||
amount: new Decimal(5),
|
||||
name: 'Daily Contribution Link',
|
||||
memo: 'Thank you for contribute daily to the community',
|
||||
cycle: 'DAILY',
|
||||
validFrom: new Date(now.getFullYear() - 1, 0, 1).toISOString(),
|
||||
validTo: new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59, 999).toISOString(),
|
||||
maxAmountPerMonth: new Decimal(200),
|
||||
maxPerCycle: 1,
|
||||
},
|
||||
})
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + contributionLink.code,
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: Contribution link is no longer valid',
|
||||
),
|
||||
],
|
||||
})
|
||||
await resetEntity(DbContributionLink)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transaction links list', () => {
|
||||
const variables = {
|
||||
userId: 1, // dummy, may be replaced
|
||||
filters: null,
|
||||
currentPage: 1,
|
||||
pageSize: 5,
|
||||
}
|
||||
// TODO: have this test separated into a transactionLink and a contributionLink part
|
||||
describe('redeem daily Contribution Link', () => {
|
||||
const now = new Date()
|
||||
let contributionLink: DbContributionLink | undefined
|
||||
let contribution: UnconfirmedContribution | undefined
|
||||
|
||||
// TODO: there is a test not cleaning up after itself! Fix it!
|
||||
beforeAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated', () => {
|
||||
describe('without admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
await mutate({
|
||||
mutation: createContributionLink,
|
||||
variables: {
|
||||
amount: new Decimal(5),
|
||||
name: 'Daily Contribution Link',
|
||||
memo: 'Thank you for contribute daily to the community',
|
||||
cycle: 'DAILY',
|
||||
validFrom: new Date(now.getFullYear(), 0, 1).toISOString(),
|
||||
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).toISOString(),
|
||||
maxAmountPerMonth: new Decimal(200),
|
||||
maxPerCycle: 1,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
it('has a daily contribution link in the database', async () => {
|
||||
const cls = await DbContributionLink.find()
|
||||
expect(cls).toHaveLength(1)
|
||||
contributionLink = cls[0]
|
||||
expect(contributionLink).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.any(Number),
|
||||
name: 'Daily Contribution Link',
|
||||
memo: 'Thank you for contribute daily to the community',
|
||||
validFrom: new Date(now.getFullYear(), 0, 1),
|
||||
validTo: new Date(now.getFullYear(), 11, 31, 23, 59, 59, 0),
|
||||
cycle: 'DAILY',
|
||||
maxPerCycle: 1,
|
||||
totalMaxCountOfContribution: null,
|
||||
maxAccountBalance: null,
|
||||
minGapHours: null,
|
||||
createdAt: expect.any(Date),
|
||||
deletedAt: null,
|
||||
code: expect.stringMatching(/^[0-9a-f]{24,24}$/),
|
||||
linkEnabled: true,
|
||||
amount: expect.decimalEqual(5),
|
||||
maxAmountPerMonth: expect.decimalEqual(200),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('user has pending contribution of 1000 GDD', () => {
|
||||
beforeAll(async () => {
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
const result = await mutate({
|
||||
mutation: createContribution,
|
||||
variables: {
|
||||
amount: new Decimal(1000),
|
||||
memo: 'I was brewing potions for the community the whole month',
|
||||
creationDate: now.toISOString(),
|
||||
},
|
||||
})
|
||||
contribution = result.data.createContribution
|
||||
})
|
||||
|
||||
it('does not allow the user to redeem the contribution link', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: The amount (5 GDD) to be created exceeds the amount (0 GDD) still available for this month.',
|
||||
),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has no pending contributions that would not allow to redeem the link', () => {
|
||||
beforeAll(async () => {
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
await mutate({
|
||||
mutation: updateContribution,
|
||||
variables: {
|
||||
contributionId: contribution ? contribution.id : -1,
|
||||
amount: new Decimal(800),
|
||||
memo: 'I was brewing potions for the community the whole month',
|
||||
creationDate: now.toISOString(),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('allows the user to redeem the contribution link', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
redeemTransactionLink: true,
|
||||
},
|
||||
errors: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
|
||||
),
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
describe('after one day', () => {
|
||||
beforeAll(async () => {
|
||||
jest.useFakeTimers()
|
||||
setTimeout(jest.fn(), 1000 * 60 * 60 * 24)
|
||||
jest.runAllTimers()
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('allows the user to redeem the contribution link again', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
data: {
|
||||
redeemTransactionLink: true,
|
||||
},
|
||||
errors: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not allow the user to redeem the contribution link a second time on the same day', async () => {
|
||||
await expect(
|
||||
mutate({
|
||||
mutation: redeemTransactionLink,
|
||||
variables: {
|
||||
code: 'CL-' + (contributionLink ? contributionLink.code : ''),
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
errors: [
|
||||
new GraphQLError(
|
||||
'Creation from contribution link was not successful. Error: Contribution link already redeemed today',
|
||||
),
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transaction links list', () => {
|
||||
const variables = {
|
||||
userId: 1, // dummy, may be replaced
|
||||
filters: null,
|
||||
currentPage: 1,
|
||||
pageSize: 5,
|
||||
}
|
||||
|
||||
// TODO: there is a test not cleaning up after itself! Fix it!
|
||||
beforeAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('unauthenticated', () => {
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
query({
|
||||
@ -296,40 +398,22 @@ describe('TransactionLinkResolver', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('with admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
// admin 'peter@lustig.de' has to exists for 'creationFactory'
|
||||
await userFactory(testEnv, peterLustig)
|
||||
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
variables.userId = user.id
|
||||
variables.pageSize = 25
|
||||
// bibi needs GDDs
|
||||
const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de')
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await creationFactory(testEnv, bibisCreation!)
|
||||
// bibis transaktion links
|
||||
const bibisTransaktionLinks = transactionLinks.filter(
|
||||
(transactionLink) => transactionLink.email === 'bibi@bloxberg.de',
|
||||
)
|
||||
for (let i = 0; i < bibisTransaktionLinks.length; i++) {
|
||||
await transactionLinkFactory(testEnv, bibisTransaktionLinks[i])
|
||||
}
|
||||
|
||||
// admin: only now log in
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
describe('authenticated', () => {
|
||||
describe('without admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('without any filters', () => {
|
||||
it('finds 6 open transaction links and no deleted or redeemed', async () => {
|
||||
it('returns an error', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
@ -337,185 +421,235 @@ describe('TransactionLinkResolver', () => {
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
errors: [new GraphQLError('401 Unauthorized')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('all filters are null', () => {
|
||||
it('finds 6 open transaction links and no deleted or redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: null,
|
||||
withExpired: null,
|
||||
withRedeemed: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
describe('with admin rights', () => {
|
||||
beforeAll(async () => {
|
||||
// admin 'peter@lustig.de' has to exists for 'creationFactory'
|
||||
await userFactory(testEnv, peterLustig)
|
||||
|
||||
describe('filter with deleted', () => {
|
||||
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 7,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
user = await userFactory(testEnv, bibiBloxberg)
|
||||
variables.userId = user.id
|
||||
variables.pageSize = 25
|
||||
// bibi needs GDDs
|
||||
const bibisCreation = creations.find(
|
||||
(creation) => creation.email === 'bibi@bloxberg.de',
|
||||
)
|
||||
})
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await creationFactory(testEnv, bibisCreation!)
|
||||
// bibis transaktion links
|
||||
const bibisTransaktionLinks = transactionLinks.filter(
|
||||
(transactionLink) => transactionLink.email === 'bibi@bloxberg.de',
|
||||
)
|
||||
for (let i = 0; i < bibisTransaktionLinks.length; i++) {
|
||||
await transactionLinkFactory(testEnv, bibisTransaktionLinks[i])
|
||||
}
|
||||
|
||||
describe('filter by expired', () => {
|
||||
it('finds 5 open transaction links, 1 expired, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withExpired: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 7,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
// admin: only now log in
|
||||
await mutate({
|
||||
mutation: login,
|
||||
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory
|
||||
describe.skip('filter by redeemed', () => {
|
||||
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: null,
|
||||
withExpired: null,
|
||||
withRedeemed: true,
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
resetToken()
|
||||
})
|
||||
|
||||
describe('without any filters', () => {
|
||||
it('finds 6 open transaction links and no deleted or redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Yeah, eingelöst!',
|
||||
redeemedAt: expect.any(String),
|
||||
redeemedBy: expect.any(Number),
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('all filters are null', () => {
|
||||
it('finds 6 open transaction links and no deleted or redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: null,
|
||||
withExpired: null,
|
||||
withRedeemed: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.not.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter with deleted', () => {
|
||||
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 7,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter by expired', () => {
|
||||
it('finds 5 open transaction links, 1 expired, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withExpired: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 7,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: works not as expected, because 'redeemedAt' and 'redeemedBy' have to be added to the transaktion link factory
|
||||
describe.skip('filter by redeemed', () => {
|
||||
it('finds 6 open transaction links, 1 deleted, and no redeemed', async () => {
|
||||
await expect(
|
||||
query({
|
||||
query: listTransactionLinksAdmin,
|
||||
variables: {
|
||||
...variables,
|
||||
filters: {
|
||||
withDeleted: null,
|
||||
withExpired: null,
|
||||
withRedeemed: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
listTransactionLinksAdmin: {
|
||||
linkCount: 6,
|
||||
linkList: expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
memo: 'Leider wollte niemand meine Gradidos zum Neujahr haben :(',
|
||||
createdAt: expect.any(String),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
memo: 'Yeah, eingelöst!',
|
||||
redeemedAt: expect.any(String),
|
||||
redeemedBy: expect.any(Number),
|
||||
}),
|
||||
expect.not.objectContaining({
|
||||
memo: 'Da habe ich mich wohl etwas übernommen.',
|
||||
deletedAt: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('transactionLinkCode', () => {
|
||||
const date = new Date()
|
||||
describe('transactionLinkCode', () => {
|
||||
const date = new Date()
|
||||
|
||||
it('returns a string of length 24', () => {
|
||||
expect(transactionLinkCode(date)).toHaveLength(24)
|
||||
})
|
||||
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))
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -170,148 +170,154 @@ export class TransactionLinkResolver {
|
||||
if (code.match(/^CL-/)) {
|
||||
// acquire lock
|
||||
const releaseLock = await TRANSACTIONS_LOCK.acquire()
|
||||
logger.info('redeem contribution link...')
|
||||
const now = new Date()
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
await queryRunner.startTransaction('REPEATABLE READ')
|
||||
try {
|
||||
const contributionLink = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('contributionLink')
|
||||
.from(DbContributionLink, 'contributionLink')
|
||||
.where('contributionLink.code = :code', { code: code.replace('CL-', '') })
|
||||
.getOne()
|
||||
if (!contributionLink) {
|
||||
logger.error('no contribution link found to given code:', code)
|
||||
throw new Error('No contribution link found')
|
||||
}
|
||||
logger.info('...contribution link found with id', contributionLink.id)
|
||||
if (new Date(contributionLink.validFrom).getTime() > now.getTime()) {
|
||||
logger.error(
|
||||
'contribution link is not valid yet. Valid from: ',
|
||||
contributionLink.validFrom,
|
||||
)
|
||||
throw new Error('Contribution link not valid yet')
|
||||
}
|
||||
if (contributionLink.validTo) {
|
||||
if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) {
|
||||
logger.error('contribution link is depricated. Valid to: ', contributionLink.validTo)
|
||||
throw new Error('Contribution link is depricated')
|
||||
logger.info('redeem contribution link...')
|
||||
const now = new Date()
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
await queryRunner.startTransaction('REPEATABLE READ')
|
||||
try {
|
||||
const contributionLink = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('contributionLink')
|
||||
.from(DbContributionLink, 'contributionLink')
|
||||
.where('contributionLink.code = :code', { code: code.replace('CL-', '') })
|
||||
.getOne()
|
||||
if (!contributionLink) {
|
||||
logger.error('no contribution link found to given code:', code)
|
||||
throw new Error(`No contribution link found to given code: ${code}`)
|
||||
}
|
||||
}
|
||||
let alreadyRedeemed: DbContribution | undefined
|
||||
switch (contributionLink.cycle) {
|
||||
case ContributionCycleType.ONCE: {
|
||||
alreadyRedeemed = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('contribution')
|
||||
.from(DbContribution, 'contribution')
|
||||
.where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', {
|
||||
linkId: contributionLink.id,
|
||||
id: user.id,
|
||||
})
|
||||
.getOne()
|
||||
if (alreadyRedeemed) {
|
||||
logger.info('...contribution link found with id', contributionLink.id)
|
||||
if (new Date(contributionLink.validFrom).getTime() > now.getTime()) {
|
||||
logger.error(
|
||||
'contribution link is not valid yet. Valid from: ',
|
||||
contributionLink.validFrom,
|
||||
)
|
||||
throw new Error('Contribution link not valid yet')
|
||||
}
|
||||
if (contributionLink.validTo) {
|
||||
if (new Date(contributionLink.validTo).setHours(23, 59, 59) < now.getTime()) {
|
||||
logger.error(
|
||||
'contribution link with rule ONCE already redeemed by user with id',
|
||||
user.id,
|
||||
'contribution link is no longer valid. Valid to: ',
|
||||
contributionLink.validTo,
|
||||
)
|
||||
throw new Error('Contribution link already redeemed')
|
||||
throw new Error('Contribution link is no longer valid')
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContributionCycleType.DAILY: {
|
||||
const start = new Date()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date()
|
||||
end.setHours(23, 59, 59, 999)
|
||||
alreadyRedeemed = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('contribution')
|
||||
.from(DbContribution, 'contribution')
|
||||
.where(
|
||||
`contribution.contributionLinkId = :linkId AND contribution.userId = :id
|
||||
AND Date(contribution.confirmedAt) BETWEEN :start AND :end`,
|
||||
{
|
||||
let alreadyRedeemed: DbContribution | undefined
|
||||
switch (contributionLink.cycle) {
|
||||
case ContributionCycleType.ONCE: {
|
||||
alreadyRedeemed = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('contribution')
|
||||
.from(DbContribution, 'contribution')
|
||||
.where('contribution.contributionLinkId = :linkId AND contribution.userId = :id', {
|
||||
linkId: contributionLink.id,
|
||||
id: user.id,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
)
|
||||
.getOne()
|
||||
if (alreadyRedeemed) {
|
||||
logger.error(
|
||||
'contribution link with rule DAILY already redeemed by user with id',
|
||||
user.id,
|
||||
)
|
||||
throw new Error('Contribution link already redeemed today')
|
||||
})
|
||||
.getOne()
|
||||
if (alreadyRedeemed) {
|
||||
logger.error(
|
||||
'contribution link with rule ONCE already redeemed by user with id',
|
||||
user.id,
|
||||
)
|
||||
throw new Error('Contribution link already redeemed')
|
||||
}
|
||||
break
|
||||
}
|
||||
case ContributionCycleType.DAILY: {
|
||||
const start = new Date()
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date()
|
||||
end.setHours(23, 59, 59, 999)
|
||||
alreadyRedeemed = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('contribution')
|
||||
.from(DbContribution, 'contribution')
|
||||
.where(
|
||||
`contribution.contributionLinkId = :linkId AND contribution.userId = :id
|
||||
AND Date(contribution.confirmedAt) BETWEEN :start AND :end`,
|
||||
{
|
||||
linkId: contributionLink.id,
|
||||
id: user.id,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
)
|
||||
.getOne()
|
||||
if (alreadyRedeemed) {
|
||||
logger.error(
|
||||
'contribution link with rule DAILY already redeemed by user with id',
|
||||
user.id,
|
||||
)
|
||||
throw new Error('Contribution link already redeemed today')
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
logger.error('contribution link has unknown cycle', contributionLink.cycle)
|
||||
throw new Error('Contribution link has unknown cycle')
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
logger.error('contribution link has unknown cycle', contributionLink.cycle)
|
||||
throw new Error('Contribution link has unknown cycle')
|
||||
|
||||
const creations = await getUserCreation(user.id, clientTimezoneOffset)
|
||||
logger.info('open creations', creations)
|
||||
validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset)
|
||||
const contribution = new DbContribution()
|
||||
contribution.userId = user.id
|
||||
contribution.createdAt = now
|
||||
contribution.contributionDate = now
|
||||
contribution.memo = contributionLink.memo
|
||||
contribution.amount = contributionLink.amount
|
||||
contribution.contributionLinkId = contributionLink.id
|
||||
contribution.contributionType = ContributionType.LINK
|
||||
contribution.contributionStatus = ContributionStatus.CONFIRMED
|
||||
|
||||
await queryRunner.manager.insert(DbContribution, contribution)
|
||||
|
||||
const lastTransaction = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('transaction')
|
||||
.from(DbTransaction, 'transaction')
|
||||
.where('transaction.userId = :id', { id: user.id })
|
||||
.orderBy('transaction.id', 'DESC')
|
||||
.getOne()
|
||||
let newBalance = new Decimal(0)
|
||||
|
||||
let decay: Decay | null = null
|
||||
if (lastTransaction) {
|
||||
decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now)
|
||||
newBalance = decay.balance
|
||||
}
|
||||
newBalance = newBalance.add(contributionLink.amount.toString())
|
||||
|
||||
const transaction = new DbTransaction()
|
||||
transaction.typeId = TransactionTypeId.CREATION
|
||||
transaction.memo = contribution.memo
|
||||
transaction.userId = contribution.userId
|
||||
transaction.previous = lastTransaction ? lastTransaction.id : null
|
||||
transaction.amount = contribution.amount
|
||||
transaction.creationDate = contribution.contributionDate
|
||||
transaction.balance = newBalance
|
||||
transaction.balanceDate = now
|
||||
transaction.decay = decay ? decay.decay : new Decimal(0)
|
||||
transaction.decayStart = decay ? decay.start : null
|
||||
await queryRunner.manager.insert(DbTransaction, transaction)
|
||||
|
||||
contribution.confirmedAt = now
|
||||
contribution.transactionId = transaction.id
|
||||
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
|
||||
|
||||
await queryRunner.commitTransaction()
|
||||
logger.info('creation from contribution link commited successfuly.')
|
||||
} catch (e) {
|
||||
await queryRunner.rollbackTransaction()
|
||||
logger.error(`Creation from contribution link was not successful: ${e}`)
|
||||
throw new Error(`Creation from contribution link was not successful. ${e}`)
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
}
|
||||
|
||||
const creations = await getUserCreation(user.id, clientTimezoneOffset)
|
||||
logger.info('open creations', creations)
|
||||
validateContribution(creations, contributionLink.amount, now, clientTimezoneOffset)
|
||||
const contribution = new DbContribution()
|
||||
contribution.userId = user.id
|
||||
contribution.createdAt = now
|
||||
contribution.contributionDate = now
|
||||
contribution.memo = contributionLink.memo
|
||||
contribution.amount = contributionLink.amount
|
||||
contribution.contributionLinkId = contributionLink.id
|
||||
contribution.contributionType = ContributionType.LINK
|
||||
contribution.contributionStatus = ContributionStatus.CONFIRMED
|
||||
|
||||
await queryRunner.manager.insert(DbContribution, contribution)
|
||||
|
||||
const lastTransaction = await queryRunner.manager
|
||||
.createQueryBuilder()
|
||||
.select('transaction')
|
||||
.from(DbTransaction, 'transaction')
|
||||
.where('transaction.userId = :id', { id: user.id })
|
||||
.orderBy('transaction.id', 'DESC')
|
||||
.getOne()
|
||||
let newBalance = new Decimal(0)
|
||||
|
||||
let decay: Decay | null = null
|
||||
if (lastTransaction) {
|
||||
decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now)
|
||||
newBalance = decay.balance
|
||||
}
|
||||
newBalance = newBalance.add(contributionLink.amount.toString())
|
||||
|
||||
const transaction = new DbTransaction()
|
||||
transaction.typeId = TransactionTypeId.CREATION
|
||||
transaction.memo = contribution.memo
|
||||
transaction.userId = contribution.userId
|
||||
transaction.previous = lastTransaction ? lastTransaction.id : null
|
||||
transaction.amount = contribution.amount
|
||||
transaction.creationDate = contribution.contributionDate
|
||||
transaction.balance = newBalance
|
||||
transaction.balanceDate = now
|
||||
transaction.decay = decay ? decay.decay : new Decimal(0)
|
||||
transaction.decayStart = decay ? decay.start : null
|
||||
await queryRunner.manager.insert(DbTransaction, transaction)
|
||||
|
||||
contribution.confirmedAt = now
|
||||
contribution.transactionId = transaction.id
|
||||
await queryRunner.manager.update(DbContribution, { id: contribution.id }, contribution)
|
||||
|
||||
await queryRunner.commitTransaction()
|
||||
logger.info('creation from contribution link commited successfuly.')
|
||||
} catch (e) {
|
||||
await queryRunner.rollbackTransaction()
|
||||
logger.error(`Creation from contribution link was not successful: ${e}`)
|
||||
throw new Error(`Creation from contribution link was not successful. ${e}`)
|
||||
} finally {
|
||||
await queryRunner.release()
|
||||
releaseLock()
|
||||
}
|
||||
return true
|
||||
|
||||
@ -45,29 +45,28 @@ export const executeTransaction = async (
|
||||
recipient: dbUser,
|
||||
transactionLink?: dbTransactionLink | null,
|
||||
): Promise<boolean> => {
|
||||
logger.info(
|
||||
`executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`,
|
||||
)
|
||||
|
||||
if (sender.id === recipient.id) {
|
||||
logger.error(`Sender and Recipient are the same.`)
|
||||
throw new Error('Sender and Recipient are the same.')
|
||||
}
|
||||
|
||||
if (memo.length > MEMO_MAX_CHARS) {
|
||||
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
|
||||
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
|
||||
}
|
||||
|
||||
if (memo.length < MEMO_MIN_CHARS) {
|
||||
logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`)
|
||||
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
|
||||
}
|
||||
|
||||
// acquire lock
|
||||
const releaseLock = await TRANSACTIONS_LOCK.acquire()
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`executeTransaction(amount=${amount}, memo=${memo}, sender=${sender}, recipient=${recipient})...`,
|
||||
)
|
||||
|
||||
if (sender.id === recipient.id) {
|
||||
logger.error(`Sender and Recipient are the same.`)
|
||||
throw new Error('Sender and Recipient are the same.')
|
||||
}
|
||||
|
||||
if (memo.length > MEMO_MAX_CHARS) {
|
||||
logger.error(`memo text is too long: memo.length=${memo.length} > ${MEMO_MAX_CHARS}`)
|
||||
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
|
||||
}
|
||||
|
||||
if (memo.length < MEMO_MIN_CHARS) {
|
||||
logger.error(`memo text is too short: memo.length=${memo.length} < ${MEMO_MIN_CHARS}`)
|
||||
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
|
||||
}
|
||||
|
||||
// validate amount
|
||||
const receivedCallDate = new Date()
|
||||
const sendBalance = await calculateBalance(
|
||||
@ -187,10 +186,10 @@ export const executeTransaction = async (
|
||||
})
|
||||
}
|
||||
logger.info(`finished executeTransaction successfully`)
|
||||
return true
|
||||
} finally {
|
||||
releaseLock()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@Resolver()
|
||||
|
||||
@ -58,7 +58,7 @@ import {
|
||||
EventSendConfirmationEmail,
|
||||
EventActivateAccount,
|
||||
} from '@/event/Event'
|
||||
import { getUserCreation, getUserCreations } from './util/creations'
|
||||
import { getUserCreations } from './util/creations'
|
||||
import { isValidPassword } from '@/password/EncryptorUtils'
|
||||
import { FULL_CREATION_AVAILABLE } from './const/const'
|
||||
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
|
||||
@ -114,9 +114,8 @@ export class UserResolver {
|
||||
async verifyLogin(@Ctx() context: Context): Promise<User> {
|
||||
logger.info('verifyLogin...')
|
||||
// TODO refactor and do not have duplicate code with login(see below)
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
const userEntity = getUser(context)
|
||||
const user = new User(userEntity, await getUserCreation(userEntity.id, clientTimezoneOffset))
|
||||
const user = new User(userEntity)
|
||||
// Elopage Status & Stored PublisherId
|
||||
user.hasElopage = await this.hasElopage(context)
|
||||
|
||||
@ -132,7 +131,6 @@ export class UserResolver {
|
||||
@Ctx() context: Context,
|
||||
): Promise<User> {
|
||||
logger.info(`login with ${email}, ***, ${publisherId} ...`)
|
||||
const clientTimezoneOffset = getClientTimezoneOffset(context)
|
||||
email = email.trim().toLowerCase()
|
||||
const dbUser = await findUserByEmail(email)
|
||||
if (dbUser.deletedAt) {
|
||||
@ -163,7 +161,7 @@ export class UserResolver {
|
||||
logger.addContext('user', dbUser.id)
|
||||
logger.debug('validation of login credentials successful...')
|
||||
|
||||
const user = new User(dbUser, await getUserCreation(dbUser.id, clientTimezoneOffset))
|
||||
const user = new User(dbUser)
|
||||
logger.debug(`user= ${JSON.stringify(user, null, 2)}`)
|
||||
|
||||
i18n.setLocale(user.language)
|
||||
|
||||
@ -101,15 +101,19 @@ export const getUserCreation = async (
|
||||
}
|
||||
|
||||
const getCreationMonths = (timezoneOffset: number): number[] => {
|
||||
return getCreationDates(timezoneOffset).map((date) => date.getMonth() + 1)
|
||||
}
|
||||
|
||||
export const getCreationDates = (timezoneOffset: number): Date[] => {
|
||||
const clientNow = new Date()
|
||||
clientNow.setTime(clientNow.getTime() - timezoneOffset * 60 * 1000)
|
||||
logger.info(
|
||||
`getCreationMonths -- offset: ${timezoneOffset} -- clientNow: ${clientNow.toISOString()}`,
|
||||
)
|
||||
return [
|
||||
new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1).getMonth() + 1,
|
||||
new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1).getMonth() + 1,
|
||||
clientNow.getMonth() + 1,
|
||||
new Date(clientNow.getFullYear(), clientNow.getMonth() - 2, 1),
|
||||
new Date(clientNow.getFullYear(), clientNow.getMonth() - 1, 1),
|
||||
clientNow,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import createServer from './server/createServer'
|
||||
import { startDHT } from '@/federation/index'
|
||||
|
||||
// config
|
||||
import CONFIG from './config'
|
||||
@ -17,20 +16,6 @@ async function main() {
|
||||
console.log(`GraphIQL available at http://localhost:${CONFIG.PORT}`)
|
||||
}
|
||||
})
|
||||
|
||||
// start DHT hyperswarm when DHT_TOPIC is set in .env
|
||||
if (CONFIG.FEDERATION_DHT_TOPIC) {
|
||||
if (CONFIG.FEDERATION_COMMUNITY_URL === null) {
|
||||
throw Error(`Config-Error: missing configuration of property FEDERATION_COMMUNITY_URL`)
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`starting Federation on ${CONFIG.FEDERATION_DHT_TOPIC} ${
|
||||
CONFIG.FEDERATION_DHT_SEED ? 'with seed...' : 'without seed...'
|
||||
}`,
|
||||
)
|
||||
await startDHT(CONFIG.FEDERATION_DHT_TOPIC) // con,
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
@ -23,8 +23,8 @@
|
||||
"commonGoodContributionConfirmed": "Your public good contribution “{contributionMemo}” has just been confirmed by {senderFirstName} {senderLastName} and credited to your Gradido account.",
|
||||
"subject": "Gradido: Your contribution to the common good was confirmed"
|
||||
},
|
||||
"contributionRejected": {
|
||||
"commonGoodContributionRejected": "Your public good contribution “{contributionMemo}” was rejected by {senderFirstName} {senderLastName}.",
|
||||
"contributionDenied": {
|
||||
"commonGoodContributionDenied": "Your public good contribution “{contributionMemo}” was rejected by {senderFirstName} {senderLastName}.",
|
||||
"subject": "Gradido: Your common good contribution was rejected",
|
||||
"toSeeContributionsAndMessages": "To see your common good contributions and related messages, go to the “Community” menu in your Gradido account and click on the “My contributions to the common good” tab!"
|
||||
},
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido-database",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.1",
|
||||
"description": "Gradido Database Tool to execute database migrations",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/gradido/gradido/database",
|
||||
|
||||
@ -231,3 +231,32 @@ This opens the `crontab` in edit-mode and insert the following entry:
|
||||
```bash
|
||||
0 4 * * * find /tmp -name "yarn--*" -ctime +1 -exec rm -r {} \; > /dev/null
|
||||
```
|
||||
|
||||
## Define Cronjob To start backup script automatically
|
||||
|
||||
At least at production stage we need a daily backup of our database. This can be done by adding a cronjob
|
||||
to start the existing backup.sh script.
|
||||
|
||||
### On production / stage3 / stage2
|
||||
|
||||
To check for existing cronjobs for the `gradido` user, please
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
crontab -l
|
||||
```
|
||||
|
||||
This show all existing entries of the crontab for user `gradido`
|
||||
|
||||
To install/add the cronjob for a daily backup at 3:00am please
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
```
|
||||
and insert the following line
|
||||
```bash
|
||||
0 3 * * * ~/gradido/deployment/bare_metal/backup.sh
|
||||
```
|
||||
|
||||
3
federation/.eslintignore
Normal file
3
federation/.eslintignore
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
**/*.min.js
|
||||
build
|
||||
28
federation/.eslintrc.js
Normal file
28
federation/.eslintrc.js
Normal file
@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
// jest: true,
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['prettier', '@typescript-eslint' /*, 'jest' */],
|
||||
extends: [
|
||||
'standard',
|
||||
'eslint:recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
// add your custom rules here
|
||||
rules: {
|
||||
'no-console': ['error'],
|
||||
'no-debugger': 'error',
|
||||
'prettier/prettier': [
|
||||
'error',
|
||||
{
|
||||
htmlWhitespaceSensitivity: 'ignore',
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
8
federation/.gitignore
vendored
Normal file
8
federation/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
/node_modules/
|
||||
/.env
|
||||
/.env.bak
|
||||
/build/
|
||||
package-json.lock
|
||||
coverage
|
||||
# emacs
|
||||
*~
|
||||
146
federation/log4js-config.json
Normal file
146
federation/log4js-config.json
Normal file
@ -0,0 +1,146 @@
|
||||
{
|
||||
"appenders":
|
||||
{
|
||||
"access":
|
||||
{
|
||||
"type": "dateFile",
|
||||
"filename": "../logs/federation/access-%p.log",
|
||||
"pattern": "yyyy-MM-dd",
|
||||
"layout":
|
||||
{
|
||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||
},
|
||||
"keepFileExt" : true,
|
||||
"fileNameSep" : "_",
|
||||
"numBackups" : 30
|
||||
},
|
||||
"apollo":
|
||||
{
|
||||
"type": "dateFile",
|
||||
"filename": "../logs/federation/apollo-%p.log",
|
||||
"pattern": "yyyy-MM-dd",
|
||||
"layout":
|
||||
{
|
||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||
},
|
||||
"keepFileExt" : true,
|
||||
"fileNameSep" : "_",
|
||||
"numBackups" : 30
|
||||
},
|
||||
"backend":
|
||||
{
|
||||
"type": "dateFile",
|
||||
"filename": "../logs/federation/backend-%p.log",
|
||||
"pattern": "yyyy-MM-dd",
|
||||
"layout":
|
||||
{
|
||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||
},
|
||||
"keepFileExt" : true,
|
||||
"fileNameSep" : "_",
|
||||
"numBackups" : 30
|
||||
},
|
||||
"federation":
|
||||
{
|
||||
"type": "dateFile",
|
||||
"filename": "../logs/federation/apiversion-%v-%p.log",
|
||||
"pattern": "yyyy-MM-dd",
|
||||
"layout":
|
||||
{
|
||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||
},
|
||||
"keepFileExt" : true,
|
||||
"fileNameSep" : "_",
|
||||
"numBackups" : 30
|
||||
},
|
||||
"errorFile":
|
||||
{
|
||||
"type": "dateFile",
|
||||
"filename": "../logs/federation/errors-%p.log",
|
||||
"pattern": "yyyy-MM-dd",
|
||||
"layout":
|
||||
{
|
||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||
},
|
||||
"keepFileExt" : true,
|
||||
"fileNameSep" : "_",
|
||||
"numBackups" : 30
|
||||
},
|
||||
"errors":
|
||||
{
|
||||
"type": "logLevelFilter",
|
||||
"level": "error",
|
||||
"appender": "errorFile"
|
||||
},
|
||||
"out":
|
||||
{
|
||||
"type": "stdout",
|
||||
"layout":
|
||||
{
|
||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||
}
|
||||
},
|
||||
"apolloOut":
|
||||
{
|
||||
"type": "stdout",
|
||||
"layout":
|
||||
{
|
||||
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
|
||||
}
|
||||
}
|
||||
},
|
||||
"categories":
|
||||
{
|
||||
"default":
|
||||
{
|
||||
"appenders":
|
||||
[
|
||||
"out",
|
||||
"errors"
|
||||
],
|
||||
"level": "debug",
|
||||
"enableCallStack": true
|
||||
},
|
||||
"apollo":
|
||||
{
|
||||
"appenders":
|
||||
[
|
||||
"apollo",
|
||||
"apolloOut",
|
||||
"errors"
|
||||
],
|
||||
"level": "debug",
|
||||
"enableCallStack": true
|
||||
},
|
||||
"backend":
|
||||
{
|
||||
"appenders":
|
||||
[
|
||||
"backend",
|
||||
"out",
|
||||
"errors"
|
||||
],
|
||||
"level": "debug",
|
||||
"enableCallStack": true
|
||||
},
|
||||
"federation":
|
||||
{
|
||||
"appenders":
|
||||
[
|
||||
"federation",
|
||||
"out",
|
||||
"errors"
|
||||
],
|
||||
"level": "debug",
|
||||
"enableCallStack": true
|
||||
},
|
||||
"http":
|
||||
{
|
||||
"appenders":
|
||||
[
|
||||
"access"
|
||||
],
|
||||
"level": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
49
federation/package.json
Normal file
49
federation/package.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "gradido-federation",
|
||||
"version": "1.0.0",
|
||||
"description": "Gradido federation module providing Gradido-Hub-Federation and versioned API for inter community communication",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/gradido/gradido/federation",
|
||||
"author": "Claus-Peter Huebner",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"clean": "tsc --build --clean",
|
||||
"start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js",
|
||||
"dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r dotenv/config -r tsconfig-paths/register src/index.ts",
|
||||
"lint": "eslint --max-warnings=0 --ext .js,.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"apollo-server-express": "^2.25.2",
|
||||
"class-validator": "^0.13.2",
|
||||
"cors": "2.8.5",
|
||||
"cross-env": "^7.0.3",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"dotenv": "10.0.0",
|
||||
"express": "4.17.1",
|
||||
"graphql": "15.5.1",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"log4js": "^6.7.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.1",
|
||||
"type-graphql": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^4.28.0",
|
||||
"@typescript-eslint/parser": "^4.28.0",
|
||||
"@types/lodash.clonedeep": "^4.5.6",
|
||||
"@types/node": "^16.10.3",
|
||||
"eslint": "^7.29.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-config-standard": "^16.0.3",
|
||||
"eslint-plugin-import": "^2.23.4",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"prettier": "^2.3.1",
|
||||
"typescript": "^4.3.4",
|
||||
"nodemon": "^2.0.7"
|
||||
}
|
||||
}
|
||||
94
federation/src/config/index.ts
Normal file
94
federation/src/config/index.ts
Normal file
@ -0,0 +1,94 @@
|
||||
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
|
||||
import dotenv from 'dotenv'
|
||||
dotenv.config()
|
||||
/*
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
Decimal.set({
|
||||
precision: 25,
|
||||
rounding: Decimal.ROUND_HALF_UP,
|
||||
})
|
||||
*/
|
||||
|
||||
const constants = {
|
||||
DB_VERSION: '0059-add_hide_amount_to_users',
|
||||
// DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
|
||||
LOG4JS_CONFIG: 'log4js-config.json',
|
||||
// default log level on production should be info
|
||||
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
|
||||
CONFIG_VERSION: {
|
||||
DEFAULT: 'DEFAULT',
|
||||
EXPECTED: 'v1.2023-01-09',
|
||||
CURRENT: '',
|
||||
},
|
||||
}
|
||||
|
||||
const server = {
|
||||
PORT: process.env.PORT || 5000,
|
||||
// JWT_SECRET: process.env.JWT_SECRET || 'secret123',
|
||||
// JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '10m',
|
||||
GRAPHIQL: process.env.GRAPHIQL === 'true' || false,
|
||||
// GDT_API_URL: process.env.GDT_API_URL || 'https://gdt.gradido.net',
|
||||
PRODUCTION: process.env.NODE_ENV === 'production' || false,
|
||||
}
|
||||
const database = {
|
||||
DB_HOST: process.env.DB_HOST || 'localhost',
|
||||
DB_PORT: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306,
|
||||
DB_USER: process.env.DB_USER || 'root',
|
||||
DB_PASSWORD: process.env.DB_PASSWORD || '',
|
||||
DB_DATABASE: process.env.DB_DATABASE || 'gradido_community',
|
||||
TYPEORM_LOGGING_RELATIVE_PATH:
|
||||
process.env.TYPEORM_LOGGING_RELATIVE_PATH || 'typeorm.backend.log',
|
||||
}
|
||||
/*
|
||||
const community = {
|
||||
COMMUNITY_NAME: process.env.COMMUNITY_NAME || 'Gradido Entwicklung',
|
||||
COMMUNITY_URL: process.env.COMMUNITY_URL || 'http://localhost/',
|
||||
COMMUNITY_REGISTER_URL: process.env.COMMUNITY_REGISTER_URL || 'http://localhost/register',
|
||||
COMMUNITY_REDEEM_URL: process.env.COMMUNITY_REDEEM_URL || 'http://localhost/redeem/{code}',
|
||||
COMMUNITY_REDEEM_CONTRIBUTION_URL:
|
||||
process.env.COMMUNITY_REDEEM_CONTRIBUTION_URL || 'http://localhost/redeem/CL-{code}',
|
||||
COMMUNITY_DESCRIPTION:
|
||||
process.env.COMMUNITY_DESCRIPTION || 'Die lokale Entwicklungsumgebung von Gradido.',
|
||||
}
|
||||
*/
|
||||
// const eventProtocol = {
|
||||
// global switch to enable writing of EventProtocol-Entries
|
||||
// EVENT_PROTOCOL_DISABLED: process.env.EVENT_PROTOCOL_DISABLED === 'true' || false,
|
||||
// }
|
||||
|
||||
// This is needed by graphql-directive-auth
|
||||
// process.env.APP_SECRET = server.JWT_SECRET
|
||||
|
||||
// Check config version
|
||||
constants.CONFIG_VERSION.CURRENT =
|
||||
process.env.CONFIG_VERSION || constants.CONFIG_VERSION.DEFAULT
|
||||
if (
|
||||
![
|
||||
constants.CONFIG_VERSION.EXPECTED,
|
||||
constants.CONFIG_VERSION.DEFAULT,
|
||||
].includes(constants.CONFIG_VERSION.CURRENT)
|
||||
) {
|
||||
throw new Error(
|
||||
`Fatal: Config Version incorrect - expected "${constants.CONFIG_VERSION.EXPECTED}" or "${constants.CONFIG_VERSION.DEFAULT}", but found "${constants.CONFIG_VERSION.CURRENT}"`
|
||||
)
|
||||
}
|
||||
|
||||
const federation = {
|
||||
// FEDERATION_DHT_TOPIC: process.env.FEDERATION_DHT_TOPIC || null,
|
||||
// FEDERATION_DHT_SEED: process.env.FEDERATION_DHT_SEED || null,
|
||||
FEDERATION_PORT: process.env.FEDERATION_PORT || 5000,
|
||||
FEDERATION_API: process.env.FEDERATION_API || '1_0',
|
||||
FEDERATION_COMMUNITY_URL: process.env.FEDERATION_COMMUNITY_URL || null,
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
...constants,
|
||||
...server,
|
||||
...database,
|
||||
// ...community,
|
||||
// ...eventProtocol,
|
||||
...federation,
|
||||
}
|
||||
|
||||
export default CONFIG
|
||||
12
federation/src/graphql/api/1_0/resolver/Test2Resolver.ts
Normal file
12
federation/src/graphql/api/1_0/resolver/Test2Resolver.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Query, Resolver } from 'type-graphql'
|
||||
import { federationLogger as logger } from '@/server/logger'
|
||||
import { GetTestApiResult } from '../../GetTestApiResult'
|
||||
|
||||
@Resolver()
|
||||
export class Test2Resolver {
|
||||
@Query(() => GetTestApiResult)
|
||||
async test2(): Promise<GetTestApiResult> {
|
||||
logger.info(`test api 2 1_0`)
|
||||
return new GetTestApiResult('1_0')
|
||||
}
|
||||
}
|
||||
12
federation/src/graphql/api/1_0/resolver/TestResolver.ts
Normal file
12
federation/src/graphql/api/1_0/resolver/TestResolver.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Field, ObjectType, Query, Resolver } from 'type-graphql'
|
||||
import { federationLogger as logger } from '@/server/logger'
|
||||
import { GetTestApiResult } from '../../GetTestApiResult'
|
||||
|
||||
@Resolver()
|
||||
export class TestResolver {
|
||||
@Query(() => GetTestApiResult)
|
||||
async test(): Promise<GetTestApiResult> {
|
||||
logger.info(`test api 1_0`)
|
||||
return new GetTestApiResult('1_0')
|
||||
}
|
||||
}
|
||||
12
federation/src/graphql/api/1_1/resolver/TestResolver.ts
Normal file
12
federation/src/graphql/api/1_1/resolver/TestResolver.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Field, ObjectType, Query, Resolver } from 'type-graphql'
|
||||
import { federationLogger as logger } from '@/server/logger'
|
||||
import { GetTestApiResult } from '../../GetTestApiResult'
|
||||
|
||||
@Resolver()
|
||||
export class TestResolver {
|
||||
@Query(() => GetTestApiResult)
|
||||
async test(): Promise<GetTestApiResult> {
|
||||
logger.info(`test api 1_1`)
|
||||
return new GetTestApiResult('1_1')
|
||||
}
|
||||
}
|
||||
12
federation/src/graphql/api/2_0/resolver/TestResolver.ts
Normal file
12
federation/src/graphql/api/2_0/resolver/TestResolver.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Field, ObjectType, Query, Resolver } from 'type-graphql'
|
||||
import { federationLogger as logger } from '@/server/logger'
|
||||
import { GetTestApiResult } from '../../GetTestApiResult'
|
||||
|
||||
@Resolver()
|
||||
export class TestResolver {
|
||||
@Query(() => GetTestApiResult)
|
||||
async test(): Promise<GetTestApiResult> {
|
||||
logger.info(`test api 2_0`)
|
||||
return new GetTestApiResult('2_0')
|
||||
}
|
||||
}
|
||||
11
federation/src/graphql/api/GetTestApiResult.ts
Normal file
11
federation/src/graphql/api/GetTestApiResult.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { Field, ObjectType } from 'type-graphql'
|
||||
|
||||
@ObjectType()
|
||||
export class GetTestApiResult {
|
||||
constructor(apiVersion: string) {
|
||||
this.api = apiVersion
|
||||
}
|
||||
|
||||
@Field(() => String)
|
||||
api: string
|
||||
}
|
||||
12
federation/src/graphql/api/schema.ts
Normal file
12
federation/src/graphql/api/schema.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import path from 'path'
|
||||
// config
|
||||
import CONFIG from '../../config'
|
||||
import { federationLogger as logger } from '@/server/logger'
|
||||
|
||||
export const getApiResolvers = (): string => {
|
||||
logger.info(`getApiResolvers...${CONFIG.FEDERATION_API}`)
|
||||
return path.join(
|
||||
__dirname,
|
||||
`./${CONFIG.FEDERATION_API}/resolver/*Resolver.{ts,js}`
|
||||
)
|
||||
}
|
||||
23
federation/src/graphql/scalar/Decimal.ts
Normal file
23
federation/src/graphql/scalar/Decimal.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { GraphQLScalarType, Kind } from 'graphql'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
export default new GraphQLScalarType({
|
||||
name: 'Decimal',
|
||||
description: 'The `Decimal` scalar type to represent currency values',
|
||||
|
||||
serialize(value: Decimal) {
|
||||
return value.toString()
|
||||
},
|
||||
|
||||
parseValue(value) {
|
||||
return new Decimal(value)
|
||||
},
|
||||
|
||||
parseLiteral(ast) {
|
||||
if (ast.kind !== Kind.STRING) {
|
||||
throw new TypeError(`${String(ast)} is not a valid decimal value.`)
|
||||
}
|
||||
|
||||
return new Decimal(ast.value)
|
||||
},
|
||||
})
|
||||
17
federation/src/graphql/schema.ts
Normal file
17
federation/src/graphql/schema.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { GraphQLSchema } from 'graphql'
|
||||
import { buildSchema } from 'type-graphql'
|
||||
|
||||
// import isAuthorized from './directive/isAuthorized'
|
||||
import DecimalScalar from './scalar/Decimal'
|
||||
import Decimal from 'decimal.js-light'
|
||||
import { getApiResolvers } from './api/schema'
|
||||
|
||||
const schema = async (): Promise<GraphQLSchema> => {
|
||||
return await buildSchema({
|
||||
resolvers: [getApiResolvers()],
|
||||
// authChecker: isAuthorized,
|
||||
scalarsMap: [{ type: Decimal, scalar: DecimalScalar }],
|
||||
})
|
||||
}
|
||||
|
||||
export default schema
|
||||
33
federation/src/index.ts
Normal file
33
federation/src/index.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import createServer from './server/createServer'
|
||||
|
||||
// config
|
||||
import CONFIG from './config'
|
||||
|
||||
async function main() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`FEDERATION_PORT=${CONFIG.FEDERATION_PORT}`)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`FEDERATION_API=${CONFIG.FEDERATION_API}`)
|
||||
const { app } = await createServer()
|
||||
|
||||
app.listen(CONFIG.FEDERATION_PORT, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Server is running at http://localhost:${CONFIG.FEDERATION_PORT}`
|
||||
)
|
||||
if (CONFIG.GRAPHIQL) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`GraphIQL available at http://localhost:${CONFIG.FEDERATION_PORT}`
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
8
federation/src/server/cors.ts
Normal file
8
federation/src/server/cors.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import cors from 'cors'
|
||||
|
||||
const corsOptions = {
|
||||
origin: '*',
|
||||
exposedHeaders: ['token'],
|
||||
}
|
||||
|
||||
export default cors(corsOptions)
|
||||
91
federation/src/server/createServer.ts
Normal file
91
federation/src/server/createServer.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import 'reflect-metadata'
|
||||
|
||||
import { ApolloServer } from 'apollo-server-express'
|
||||
import express, { Express } from 'express'
|
||||
|
||||
// database
|
||||
import connection from '@/typeorm/connection'
|
||||
import { checkDBVersion } from '@/typeorm/DBVersion'
|
||||
|
||||
// server
|
||||
import cors from './cors'
|
||||
// import serverContext from './context'
|
||||
import plugins from './plugins'
|
||||
|
||||
// config
|
||||
import CONFIG from '@/config'
|
||||
|
||||
// graphql
|
||||
import schema from '@/graphql/schema'
|
||||
|
||||
// webhooks
|
||||
// import { elopageWebhook } from '@/webhook/elopage'
|
||||
import { Connection } from '@dbTools/typeorm'
|
||||
|
||||
import { apolloLogger } from './logger'
|
||||
import { Logger } from 'log4js'
|
||||
|
||||
// i18n
|
||||
// import { i18n } from './localization'
|
||||
|
||||
// TODO implement
|
||||
// import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity";
|
||||
|
||||
type ServerDef = { apollo: ApolloServer; app: Express; con: Connection }
|
||||
|
||||
const createServer = async (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// context: any = serverContext,
|
||||
logger: Logger = apolloLogger
|
||||
// localization: i18n.I18n = i18n,
|
||||
): Promise<ServerDef> => {
|
||||
logger.addContext('user', 'unknown')
|
||||
logger.debug('createServer...')
|
||||
|
||||
// open mysql connection
|
||||
const con = await connection()
|
||||
if (!con || !con.isConnected) {
|
||||
logger.fatal(`Couldn't open connection to database!`)
|
||||
throw new Error(`Fatal: Couldn't open connection to database`)
|
||||
}
|
||||
|
||||
// check for correct database version
|
||||
const dbVersion = await checkDBVersion(CONFIG.DB_VERSION)
|
||||
if (!dbVersion) {
|
||||
logger.fatal('Fatal: Database Version incorrect')
|
||||
throw new Error('Fatal: Database Version incorrect')
|
||||
}
|
||||
|
||||
// Express Server
|
||||
const app = express()
|
||||
|
||||
// cors
|
||||
app.use(cors)
|
||||
|
||||
// bodyparser json
|
||||
app.use(express.json())
|
||||
// bodyparser urlencoded for elopage
|
||||
app.use(express.urlencoded({ extended: true }))
|
||||
|
||||
// i18n
|
||||
// app.use(localization.init)
|
||||
|
||||
// Elopage Webhook
|
||||
// app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook)
|
||||
|
||||
// Apollo Server
|
||||
const apollo = new ApolloServer({
|
||||
schema: await schema(),
|
||||
// playground: CONFIG.GRAPHIQL,
|
||||
// introspection: CONFIG.GRAPHIQL,
|
||||
// context,
|
||||
plugins,
|
||||
logger,
|
||||
})
|
||||
apollo.applyMiddleware({ app, path: '/' })
|
||||
logger.debug('createServer...successful')
|
||||
|
||||
return { apollo, app, con }
|
||||
}
|
||||
|
||||
export default createServer
|
||||
43
federation/src/server/logger.ts
Normal file
43
federation/src/server/logger.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import log4js from 'log4js'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
const options = JSON.parse(readFileSync(CONFIG.LOG4JS_CONFIG, 'utf-8'))
|
||||
|
||||
options.categories.backend.level = CONFIG.LOG_LEVEL
|
||||
options.categories.apollo.level = CONFIG.LOG_LEVEL
|
||||
let filename: string = options.appenders.federation.filename
|
||||
options.appenders.federation.filename = filename
|
||||
.replace('%v', CONFIG.FEDERATION_API)
|
||||
.replace('%p', CONFIG.FEDERATION_PORT.toString())
|
||||
filename = options.appenders.access.filename
|
||||
options.appenders.access.filename = filename.replace(
|
||||
'%p',
|
||||
CONFIG.FEDERATION_PORT.toString()
|
||||
)
|
||||
filename = options.appenders.apollo.filename
|
||||
options.appenders.apollo.filename = filename.replace(
|
||||
'%p',
|
||||
CONFIG.FEDERATION_PORT.toString()
|
||||
)
|
||||
filename = options.appenders.backend.filename
|
||||
options.appenders.backend.filename = filename.replace(
|
||||
'%p',
|
||||
CONFIG.FEDERATION_PORT.toString()
|
||||
)
|
||||
filename = options.appenders.errorFile.filename
|
||||
options.appenders.errorFile.filename = filename.replace(
|
||||
'%p',
|
||||
CONFIG.FEDERATION_PORT.toString()
|
||||
)
|
||||
|
||||
log4js.configure(options)
|
||||
|
||||
const apolloLogger = log4js.getLogger('apollo')
|
||||
// const backendLogger = log4js.getLogger('backend')
|
||||
const federationLogger = log4js.getLogger('federation')
|
||||
|
||||
// backendLogger.addContext('user', 'unknown')
|
||||
|
||||
export { apolloLogger, federationLogger }
|
||||
68
federation/src/server/plugins.ts
Normal file
68
federation/src/server/plugins.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
|
||||
import clonedeep from 'lodash.clonedeep'
|
||||
|
||||
const setHeadersPlugin = {
|
||||
requestDidStart() {
|
||||
return {
|
||||
willSendResponse(requestContext: any) {
|
||||
const { setHeaders = [] } = requestContext.context
|
||||
setHeaders.forEach(({ key, value }: { [key: string]: string }) => {
|
||||
if (requestContext.response.http.headers.get(key)) {
|
||||
requestContext.response.http.headers.set(key, value)
|
||||
} else {
|
||||
requestContext.response.http.headers.append(key, value)
|
||||
}
|
||||
})
|
||||
return requestContext
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const filterVariables = (variables: any) => {
|
||||
const vars = clonedeep(variables)
|
||||
if (vars.password) vars.password = '***'
|
||||
if (vars.passwordNew) vars.passwordNew = '***'
|
||||
return vars
|
||||
}
|
||||
|
||||
const logPlugin = {
|
||||
requestDidStart(requestContext: any) {
|
||||
const { logger } = requestContext
|
||||
const { query, mutation, variables, operationName } = requestContext.request
|
||||
if (operationName !== 'IntrospectionQuery') {
|
||||
logger.info(`Request:
|
||||
${mutation || query}variables: ${JSON.stringify(
|
||||
filterVariables(variables),
|
||||
null,
|
||||
2
|
||||
)}`)
|
||||
}
|
||||
return {
|
||||
willSendResponse(requestContext: any) {
|
||||
if (operationName !== 'IntrospectionQuery') {
|
||||
if (requestContext.context.user)
|
||||
logger.info(`User ID: ${requestContext.context.user.id}`)
|
||||
if (requestContext.response.data) {
|
||||
logger.info('Response Success!')
|
||||
logger.trace(`Response-Data:
|
||||
${JSON.stringify(requestContext.response.data, null, 2)}`)
|
||||
}
|
||||
if (requestContext.response.errors)
|
||||
logger.error(`Response-Errors:
|
||||
${JSON.stringify(requestContext.response.errors, null, 2)}`)
|
||||
}
|
||||
return requestContext
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const plugins =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? [setHeadersPlugin]
|
||||
: [setHeadersPlugin, logPlugin]
|
||||
|
||||
export default plugins
|
||||
27
federation/src/typeorm/DBVersion.ts
Normal file
27
federation/src/typeorm/DBVersion.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Migration } from '@entity/Migration'
|
||||
import { federationLogger as logger } from '@/server/logger'
|
||||
|
||||
const getDBVersion = async (): Promise<string | null> => {
|
||||
try {
|
||||
const dbVersion = await Migration.findOne({ order: { version: 'DESC' } })
|
||||
return dbVersion ? dbVersion.fileName : null
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const checkDBVersion = async (DB_VERSION: string): Promise<boolean> => {
|
||||
const dbVersion = await getDBVersion()
|
||||
if (!dbVersion || dbVersion.indexOf(DB_VERSION) === -1) {
|
||||
logger.error(
|
||||
`Wrong database version detected - the backend requires '${DB_VERSION}' but found '${
|
||||
dbVersion || 'None'
|
||||
}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export { checkDBVersion, getDBVersion }
|
||||
34
federation/src/typeorm/connection.ts
Normal file
34
federation/src/typeorm/connection.ts
Normal file
@ -0,0 +1,34 @@
|
||||
// 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 { entities } from '@entity/index'
|
||||
|
||||
const connection = async (): Promise<Connection | null> => {
|
||||
try {
|
||||
return createConnection({
|
||||
name: 'default',
|
||||
type: 'mysql',
|
||||
host: CONFIG.DB_HOST,
|
||||
port: CONFIG.DB_PORT,
|
||||
username: CONFIG.DB_USER,
|
||||
password: CONFIG.DB_PASSWORD,
|
||||
database: CONFIG.DB_DATABASE,
|
||||
entities,
|
||||
synchronize: false,
|
||||
logging: true,
|
||||
logger: new FileLogger('all', {
|
||||
logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
|
||||
}),
|
||||
extra: {
|
||||
charset: 'utf8mb4_unicode_ci',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default connection
|
||||
90
federation/tsconfig.json
Normal file
90
federation/tsconfig.json
Normal file
@ -0,0 +1,90 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./build", /* Redirect output structure to the directory. */
|
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "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/*"],
|
||||
// "@enum/*": ["src/graphql/enum/*"],
|
||||
// "@model/*": ["src/graphql/model/*"],
|
||||
"@repository/*": ["src/typeorm/repository/*"],
|
||||
// "@test/*": ["test/*"],
|
||||
/* external */
|
||||
"@typeorm/*": ["../backend/src/typeorm/*", "../../backend/src/typeorm/*"],
|
||||
"@dbTools/*": ["../database/src/*", "../../database/build/src/*"],
|
||||
"@entity/*": ["../database/entity/*", "../../database/build/entity/*"]
|
||||
},
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
"typeRoots": ["src/dht_node/@types", "node_modules/@types"], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
|
||||
/* Advanced Options */
|
||||
"skipLibCheck": true, /* Skip type checking of declaration files. */
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../database/tsconfig.json",
|
||||
// add 'prepend' if you want to include the referenced project in your output file
|
||||
// "prepend": true
|
||||
}
|
||||
]
|
||||
}
|
||||
3247
federation/yarn.lock
Normal file
3247
federation/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bootstrap-vue-gradido-wallet",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node run/server.js",
|
||||
@ -66,6 +66,7 @@
|
||||
"vuex-persistedstate": "^4.0.0-beta.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apollo/client": "^3.7.4",
|
||||
"@intlify/eslint-plugin-vue-i18n": "^1.4.0",
|
||||
"@vue/cli-plugin-babel": "^3.7.0",
|
||||
"@vue/cli-plugin-eslint": "^3.7.0",
|
||||
@ -76,6 +77,7 @@
|
||||
"babel-plugin-transform-require-context": "^0.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv-webpack": "^7.0.3",
|
||||
"mock-apollo-client": "^1.2.1",
|
||||
"postcss": "^8.4.8",
|
||||
"postcss-html": "^1.3.0",
|
||||
"postcss-scss": "^4.0.3",
|
||||
|
||||
@ -30,7 +30,7 @@ export default {
|
||||
font-family: 'WorkSans', sans-serif !important;
|
||||
}
|
||||
.appContent {
|
||||
min-width: 360px;
|
||||
min-width: 330px;
|
||||
max-width: 1320px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
"date": "01 janvier 2023",
|
||||
"text": "Compte Gradido 2023 : nouveau design et communautés décentralisées",
|
||||
"url": "https://gradido.net/fr/gradido-konto-2023-neues-design-und-dezentrale-communities/",
|
||||
"extra": "Ce sont souvent les personnes les plus discrètes qui créent silencieusement, avec application et passion, les bases de grands développements. Notre site Développeur ont effectué ces derniers mois un travail préparatoire formidable qui sera mis à profit en 2023."
|
||||
"extra": "Ce sont souvent les personnes les plus discrètes qui créent silencieusement, avec application et passion, les bases de grands développements. Nos développeurs ont effectués ces derniers mois un travail préparatoire formidable qui sera mis à profit en 2023."
|
||||
},
|
||||
{
|
||||
"locale": "es",
|
||||
|
||||
@ -11,7 +11,7 @@ body {
|
||||
|
||||
.bg-gradient {
|
||||
background: rgb(4 112 6);
|
||||
background: linear-gradient(90deg, rgb(4 112 6 / 100%) 73%, rgb(197 141 56 / 100%) 100%);
|
||||
background: linear-gradient(90deg, rgb(4 112 6 / 100%) 22%, rgb(197 141 56 / 100%) 98%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
@ -53,6 +53,7 @@ export default {
|
||||
|
||||
.auth-header {
|
||||
font-family: 'Open Sans', sans-serif !important;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.sheet-img {
|
||||
@ -61,6 +62,17 @@ export default {
|
||||
max-width: 64%;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px) {
|
||||
.auth-header {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.auth-header {
|
||||
height: 70px;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 450px) {
|
||||
.sheet-img {
|
||||
top: -15px;
|
||||
|
||||
@ -1,17 +1,31 @@
|
||||
<template>
|
||||
<div class="clipboard-copy">
|
||||
<div v-if="canCopyLink" size="lg" class="mb-5">
|
||||
<div class="d-flex">
|
||||
<div>
|
||||
<label>{{ $t('gdd_per_link.copy-link') }}</label>
|
||||
<div class="pointer text-center bg-secondary gradido-border-radius p-4" @click="copyLink">
|
||||
{{ link }}
|
||||
<div v-if="canCopyLink" class="mb-5">
|
||||
<div>
|
||||
<label>{{ $t('gdd_per_link.copy-link') }}</label>
|
||||
<div
|
||||
class="pointer text-center bg-secondary gradido-border-radius p-3"
|
||||
@click="copyLink"
|
||||
data-test="copyLink"
|
||||
>
|
||||
{{ link }}
|
||||
<div>
|
||||
<b-button class="p-4">
|
||||
<b-icon icon="link45deg"></b-icon>
|
||||
</b-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5">
|
||||
<label>{{ $t('gdd_per_link.copy-link-with-text') }}</label>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<label>{{ $t('gdd_per_link.copy-link-with-text') }}</label>
|
||||
<div
|
||||
class="pointer text-center bg-secondary gradido-border-radius p-3"
|
||||
data-test="copyLinkWithText"
|
||||
@click="copyLinkWithText"
|
||||
>
|
||||
{{ linkText }}
|
||||
<div>
|
||||
<b-button @click="copyLinkWithText" class="p-4">
|
||||
<b-button class="p-4">
|
||||
<b-icon icon="link45deg"></b-icon>
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,7 @@ describe('ContributionForm', () => {
|
||||
date: '',
|
||||
memo: '',
|
||||
amount: '',
|
||||
hours: 0,
|
||||
},
|
||||
isThisMonth: true,
|
||||
minimalDate: new Date(),
|
||||
@ -22,6 +23,7 @@ describe('ContributionForm', () => {
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$n: jest.fn((n) => n),
|
||||
$store: {
|
||||
state: {
|
||||
creation: ['1000', '1000', '1000'],
|
||||
@ -375,6 +377,7 @@ describe('ContributionForm', () => {
|
||||
date: now,
|
||||
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
|
||||
amount: '200',
|
||||
hours: 0,
|
||||
},
|
||||
]),
|
||||
]),
|
||||
|
||||
@ -3,13 +3,12 @@
|
||||
<b-form
|
||||
ref="form"
|
||||
@submit.prevent="submit"
|
||||
class="border p-3 bg-white appBoxShadow gradido-border-radius"
|
||||
class="p-3 bg-white appBoxShadow gradido-border-radius"
|
||||
>
|
||||
<label>{{ $t('contribution.selectDate') }}</label>
|
||||
<b-form-datepicker
|
||||
id="contribution-date"
|
||||
v-model="form.date"
|
||||
size="lg"
|
||||
:locale="$i18n.locale"
|
||||
:max="maximalDate"
|
||||
:min="minimalDate"
|
||||
@ -18,44 +17,44 @@
|
||||
:label-no-date-selected="$t('contribution.noDateSelected')"
|
||||
required
|
||||
:disabled="this.form.id !== null"
|
||||
:no-flip="true"
|
||||
>
|
||||
<template #nav-prev-year><span></span></template>
|
||||
<template #nav-next-year><span></span></template>
|
||||
</b-form-datepicker>
|
||||
<div v-if="validMaxGDD > 0">
|
||||
<input-textarea
|
||||
id="contribution-memo"
|
||||
v-model="form.memo"
|
||||
:name="$t('form.message')"
|
||||
:label="$t('contribution.activity')"
|
||||
:placeholder="$t('contribution.yourActivity')"
|
||||
:rules="{ required: true, min: 5, max: 255 }"
|
||||
/>
|
||||
<input-hour
|
||||
v-model="form.hours"
|
||||
:name="$t('form.hours')"
|
||||
:label="$t('form.hours')"
|
||||
placeholder="0.5"
|
||||
:rules="{
|
||||
required: true,
|
||||
min: 0.5,
|
||||
max: validMaxTime,
|
||||
gddCreationTime: [0.5, validMaxTime],
|
||||
}"
|
||||
:validMaxTime="validMaxTime"
|
||||
@updateAmount="updateAmount"
|
||||
></input-hour>
|
||||
<input-amount
|
||||
id="contribution-amount"
|
||||
v-model="form.amount"
|
||||
:name="$t('form.amount')"
|
||||
:label="$t('form.amount')"
|
||||
placeholder="20"
|
||||
:rules="{ required: true, gddSendAmount: [20, validMaxGDD] }"
|
||||
typ="ContributionForm"
|
||||
></input-amount>
|
||||
</div>
|
||||
<div v-else class="mb-5">{{ $t('contribution.exhausted') }}</div>
|
||||
|
||||
<input-textarea
|
||||
id="contribution-memo"
|
||||
v-model="form.memo"
|
||||
:name="$t('form.message')"
|
||||
:label="$t('contribution.activity')"
|
||||
:placeholder="$t('contribution.yourActivity')"
|
||||
:rules="{ required: true, min: 5, max: 255 }"
|
||||
/>
|
||||
<input-hour
|
||||
v-model="form.hours"
|
||||
:name="$t('form.hours')"
|
||||
:label="$t('form.hours')"
|
||||
placeholder="0.25"
|
||||
:rules="{
|
||||
required: true,
|
||||
min: 0.25,
|
||||
max: validMaxTime,
|
||||
gddCreationTime: [0.25, validMaxTime],
|
||||
}"
|
||||
:validMaxTime="validMaxTime"
|
||||
@updateAmount="updateAmount"
|
||||
></input-hour>
|
||||
<input-amount
|
||||
id="contribution-amount"
|
||||
v-model="form.amount"
|
||||
:name="$t('form.amount')"
|
||||
:label="$t('form.amount')"
|
||||
placeholder="20"
|
||||
:rules="{ required: true, gddSendAmount: [20, validMaxGDD] }"
|
||||
typ="ContributionForm"
|
||||
></input-amount>
|
||||
|
||||
<b-row class="mt-5">
|
||||
<b-col>
|
||||
<b-button type="reset" variant="secondary" @click="reset" data-test="button-cancel">
|
||||
@ -111,7 +110,7 @@ export default {
|
||||
this.form.id = null
|
||||
this.form.date = ''
|
||||
this.form.memo = ''
|
||||
this.form.hours = 0.0
|
||||
this.form.hours = 0
|
||||
this.form.amount = ''
|
||||
},
|
||||
},
|
||||
|
||||
@ -48,7 +48,7 @@ describe('ContributionListItem', () => {
|
||||
|
||||
it('is x-circle when deletedAt is present', async () => {
|
||||
await wrapper.setProps({ deletedAt: new Date().toISOString() })
|
||||
expect(wrapper.vm.icon).toBe('x-circle')
|
||||
expect(wrapper.vm.icon).toBe('trash')
|
||||
})
|
||||
|
||||
it('is check when confirmedAt is present', async () => {
|
||||
|
||||
@ -16,21 +16,31 @@
|
||||
<b-avatar v-else :icon="icon" :variant="variant" size="3em"></b-avatar>
|
||||
</b-col>
|
||||
<b-col>
|
||||
<div v-if="firstName" class="mr-3 font-weight-bold">{{ firstName }} {{ lastName }}</div>
|
||||
<div v-if="firstName" class="mr-3 font-weight-bold">
|
||||
{{ firstName }} {{ lastName }}
|
||||
<b-icon :icon="icon" :variant="variant"></b-icon>
|
||||
</div>
|
||||
<div class="small">
|
||||
{{ $d(new Date(contributionDate), 'monthAndYear') }}
|
||||
</div>
|
||||
<div class="mt-3 font-weight-bold">{{ $t('contributionText') }}</div>
|
||||
<div class="mb-3">{{ memo }}</div>
|
||||
<div class="mb-3 text-break word-break">{{ memo }}</div>
|
||||
<div v-if="state === 'IN_PROGRESS'" class="text-205">
|
||||
{{ $t('contribution.alert.answerQuestion') }}
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="12" lg="3" offset="3" offset-md="0" offset-lg="0">
|
||||
<b-col cols="9" lg="3" offset="3" offset-md="0" offset-lg="0">
|
||||
<div class="small">
|
||||
{{ $t('creation') }} {{ $t('(') }}{{ amount / 20 }} {{ $t('h') }}{{ $t(')') }}
|
||||
</div>
|
||||
<div class="font-weight-bold">{{ amount | GDD }}</div>
|
||||
<div v-if="state === 'DENIED' && allContribution" class="font-weight-bold">
|
||||
<b-icon icon="x-circle" variant="danger"></b-icon>
|
||||
{{ $t('contribution.alert.denied') }}
|
||||
</div>
|
||||
<div v-if="state === 'DELETED'" class="small">
|
||||
{{ $t('contribution.deleted') }}
|
||||
</div>
|
||||
<div v-else class="font-weight-bold">{{ amount | GDD }}</div>
|
||||
</b-col>
|
||||
<b-col cols="12" md="1" lg="1" class="text-right align-items-center">
|
||||
<div v-if="messagesCount > 0" @click="visible = !visible">
|
||||
@ -140,6 +150,14 @@ export default {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
deniedBy: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
deniedAt: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
state: {
|
||||
type: String,
|
||||
required: false,
|
||||
@ -168,13 +186,15 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
if (this.deletedAt) return 'x-circle'
|
||||
if (this.deletedAt) return 'trash'
|
||||
if (this.deniedAt) return 'x-circle'
|
||||
if (this.confirmedAt) return 'check'
|
||||
if (this.state === 'IN_PROGRESS') return 'question-circle'
|
||||
return 'bell-fill'
|
||||
},
|
||||
variant() {
|
||||
if (this.deletedAt) return 'danger'
|
||||
if (this.deniedAt) return 'warning'
|
||||
if (this.confirmedAt) return 'success'
|
||||
if (this.state === 'IN_PROGRESS') return 'f5'
|
||||
return 'primary'
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import OpenCreationsAmount from './OpenCreationsAmount.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('OpenCreationsAmount', () => {
|
||||
let wrapper
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((date, formatter = null) => {
|
||||
return { date, formatter }
|
||||
}),
|
||||
}
|
||||
|
||||
const thisMonth = new Date()
|
||||
const lastMonth = new Date(thisMonth.getFullYear(), thisMonth.getMonth() - 1)
|
||||
|
||||
const propsData = {
|
||||
minimalDate: lastMonth,
|
||||
maxGddLastMonth: 400,
|
||||
maxGddThisMonth: 600,
|
||||
}
|
||||
|
||||
const Wrapper = () => {
|
||||
return mount(OpenCreationsAmount, {
|
||||
localVue,
|
||||
mocks,
|
||||
propsData,
|
||||
})
|
||||
}
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders the component', () => {
|
||||
expect(wrapper.find('div.appBoxShadow').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders two dates', () => {
|
||||
expect(mocks.$d).toBeCalledTimes(2)
|
||||
})
|
||||
|
||||
it('renders the date of last month', () => {
|
||||
expect(mocks.$d).toBeCalledWith(lastMonth, 'monthAndYear')
|
||||
})
|
||||
|
||||
it('renders the date of this month', () => {
|
||||
expect(mocks.$d).toBeCalledWith(expect.any(Date), 'monthAndYear')
|
||||
})
|
||||
|
||||
describe('open creations for both months', () => {
|
||||
it('renders submitted contributions text', () => {
|
||||
expect(mocks.$t).toBeCalledWith('contribution.submit')
|
||||
})
|
||||
|
||||
it('does not render max reached text', () => {
|
||||
expect(mocks.$t).not.toBeCalledWith('maxReached')
|
||||
})
|
||||
|
||||
it('renders submitted hours last month', () => {
|
||||
expect(wrapper.findAll('div.row').at(1).findAll('div.col').at(2).text()).toBe('30 h')
|
||||
})
|
||||
|
||||
it('renders available hours last month', () => {
|
||||
expect(wrapper.findAll('div.row').at(1).findAll('div.col').at(3).text()).toBe('20 h')
|
||||
})
|
||||
|
||||
it('renders submitted hours this month', () => {
|
||||
expect(wrapper.findAll('div.row').at(2).findAll('div.col').at(2).text()).toBe('20 h')
|
||||
})
|
||||
|
||||
it('renders available hours this month', () => {
|
||||
expect(wrapper.findAll('div.row').at(2).findAll('div.col').at(3).text()).toBe('30 h')
|
||||
})
|
||||
})
|
||||
|
||||
describe('no creations available for last month', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setProps({ maxGddLastMonth: 0 })
|
||||
})
|
||||
|
||||
it('renders submitted contributions text', () => {
|
||||
expect(mocks.$t).toBeCalledWith('contribution.submit')
|
||||
})
|
||||
|
||||
it('renders max reached text', () => {
|
||||
expect(mocks.$t).toBeCalledWith('maxReached')
|
||||
})
|
||||
|
||||
it('renders submitted hours last month', () => {
|
||||
expect(wrapper.findAll('div.row').at(1).findAll('div.col').at(2).text()).toBe('50 h')
|
||||
})
|
||||
|
||||
it('renders available hours last month', () => {
|
||||
expect(wrapper.findAll('div.row').at(1).findAll('div.col').at(3).text()).toBe('0 h')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -14,9 +14,9 @@
|
||||
{{ maxGddLastMonth > 0 ? $t('contribution.submit') : $t('maxReached') }}
|
||||
</b-col>
|
||||
<b-col class="d-none d-md-inline text-197 text-center">
|
||||
{{ (1000 - maxGddLastMonth) / 20 }} {{ $t('h') }}
|
||||
{{ hoursSubmittedLastMonth }} {{ $t('h') }}
|
||||
</b-col>
|
||||
<b-col class="text-4 text-center">{{ maxGddLastMonth / 20 }} {{ $t('h') }}</b-col>
|
||||
<b-col class="text-4 text-center">{{ hoursAvailableLastMonth }} {{ $t('h') }}</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row class="font-weight-bold">
|
||||
@ -25,9 +25,9 @@
|
||||
{{ maxGddThisMonth > 0 ? $t('contribution.submit') : $t('maxReached') }}
|
||||
</b-col>
|
||||
<b-col class="d-none d-md-inline text-197 text-center">
|
||||
{{ (1000 - maxGddThisMonth) / 20 }} {{ $t('h') }}
|
||||
{{ hoursSubmittedThisMonth }} {{ $t('h') }}
|
||||
</b-col>
|
||||
<b-col class="text-4 text-center">{{ maxGddThisMonth / 20 }} {{ $t('h') }}</b-col>
|
||||
<b-col class="text-4 text-center">{{ hoursAvailableThisMonth }} {{ $t('h') }}</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
</div>
|
||||
@ -40,5 +40,19 @@ export default {
|
||||
maxGddLastMonth: { type: Number, required: true },
|
||||
maxGddThisMonth: { type: Number, required: true },
|
||||
},
|
||||
computed: {
|
||||
hoursSubmittedThisMonth() {
|
||||
return (1000 - this.maxGddThisMonth) / 20
|
||||
},
|
||||
hoursSubmittedLastMonth() {
|
||||
return (1000 - this.maxGddLastMonth) / 20
|
||||
},
|
||||
hoursAvailableThisMonth() {
|
||||
return this.maxGddThisMonth / 20
|
||||
},
|
||||
hoursAvailableLastMonth() {
|
||||
return this.maxGddLastMonth / 20
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -10,6 +10,12 @@ const mocks = {
|
||||
$tc: jest.fn((tc) => tc),
|
||||
$t: jest.fn((t) => t),
|
||||
$d: jest.fn((d) => d),
|
||||
$store: {
|
||||
state: {
|
||||
firstName: 'Bibi',
|
||||
lastName: 'Bloxberg',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const propsData = {
|
||||
|
||||
@ -35,12 +35,15 @@
|
||||
<b-col offset="2">{{ $t('form.new_balance') }}</b-col>
|
||||
<b-col>{{ (balance - amount) | GDD }}</b-col>
|
||||
</b-row>
|
||||
<b-row class="mt-5 p-5">
|
||||
<b-col>
|
||||
<b-button @click="$emit('on-back')">{{ $t('back') }}</b-button>
|
||||
<b-row class="mt-5">
|
||||
<b-col cols="12" md="6" lg="6">
|
||||
<b-button block @click="$emit('on-back')" class="mb-3 mb-md-0 mb-lg-0">
|
||||
{{ $t('back') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-right">
|
||||
<b-col cols="12" md="6" lg="6" class="text-lg-right">
|
||||
<b-button
|
||||
block
|
||||
class="send-button"
|
||||
variant="gradido"
|
||||
:disabled="disabled"
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row class="mt-5 pr-3 text-color-gdd-yellow h3">
|
||||
<b-row class="mt-5 text-color-gdd-yellow h3">
|
||||
<b-col cols="2" class="text-right">
|
||||
<b-icon class="text-color-gdd-yellow" icon="droplet-half"></b-icon>
|
||||
</b-col>
|
||||
@ -39,12 +39,15 @@
|
||||
<b-col offset="2">{{ $t('form.new_balance') }}</b-col>
|
||||
<b-col>{{ (balance - amount) | GDD }}</b-col>
|
||||
</b-row>
|
||||
<b-row class="mt-5 p-5">
|
||||
<b-col>
|
||||
<b-button @click="$emit('on-back')">{{ $t('back') }}</b-button>
|
||||
<b-row class="mt-5">
|
||||
<b-col cols="12" md="6" lg="6">
|
||||
<b-button block @click="$emit('on-back')" class="mb-3 mb-md-0 mb-lg-0">
|
||||
{{ $t('back') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-right">
|
||||
<b-col cols="12" md="6" lg="6" class="text-lg-right">
|
||||
<b-button
|
||||
block
|
||||
variant="gradido"
|
||||
:disabled="disabled"
|
||||
@click="$emit('send-transaction'), (disabled = true)"
|
||||
|
||||
@ -1,112 +1,121 @@
|
||||
<template>
|
||||
<b-row class="transaction-form">
|
||||
<b-col cols="12">
|
||||
<b-card class="appBoxShadow gradido-border-radius" body-class="p-3">
|
||||
<validation-observer v-slot="{ handleSubmit }" ref="formValidator">
|
||||
<b-form role="form" @submit.prevent="handleSubmit(onSubmit)" @reset="onReset">
|
||||
<b-form-radio-group v-model="radioSelected" class="container">
|
||||
<b-row class="mb-4">
|
||||
<b-col cols="12" lg="6">
|
||||
<b-row class="bg-248 gradido-border-radius pt-lg-2 mr-lg-2">
|
||||
<b-col cols="10" @click="radioSelected = sendTypes.send" class="pointer">
|
||||
{{ $t('send_gdd') }}
|
||||
</b-col>
|
||||
<b-col cols="2">
|
||||
<b-form-radio
|
||||
name="shipping"
|
||||
size="lg"
|
||||
:value="sendTypes.send"
|
||||
stacked
|
||||
class="custom-radio-button pointer"
|
||||
></b-form-radio>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<div class="transaction-form">
|
||||
<b-row>
|
||||
<b-col cols="12">
|
||||
<b-card class="appBoxShadow gradido-border-radius" body-class="p-3">
|
||||
<validation-observer v-slot="{ handleSubmit }" ref="formValidator">
|
||||
<b-form role="form" @submit.prevent="handleSubmit(onSubmit)" @reset="onReset">
|
||||
<b-form-radio-group v-model="radioSelected" class="container">
|
||||
<b-row class="mb-4">
|
||||
<b-col cols="12" lg="6">
|
||||
<b-row class="bg-248 gradido-border-radius pt-lg-2 mr-lg-2">
|
||||
<b-col cols="10" @click="radioSelected = sendTypes.send" class="pointer">
|
||||
{{ $t('send_gdd') }}
|
||||
</b-col>
|
||||
<b-col cols="2">
|
||||
<b-form-radio
|
||||
name="shipping"
|
||||
size="lg"
|
||||
:value="sendTypes.send"
|
||||
stacked
|
||||
class="custom-radio-button pointer"
|
||||
></b-form-radio>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col>
|
||||
<b-row class="bg-248 gradido-border-radius pt-lg-2 ml-lg-2 mt-2 mt-lg-0">
|
||||
<b-col cols="10" @click="radioSelected = sendTypes.link" class="pointer">
|
||||
{{ $t('send_per_link') }}
|
||||
</b-col>
|
||||
<b-col cols="2" class="pointer">
|
||||
<b-form-radio
|
||||
name="shipping"
|
||||
:value="sendTypes.link"
|
||||
size="lg"
|
||||
class="custom-radio-button"
|
||||
></b-form-radio>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<div class="mt-4 mb-4" v-if="radioSelected === sendTypes.link">
|
||||
<h2 class="alert-heading">{{ $t('gdd_per_link.header') }}</h2>
|
||||
<div>
|
||||
{{ $t('gdd_per_link.choose-amount') }}
|
||||
</div>
|
||||
</div>
|
||||
</b-form-radio-group>
|
||||
<b-row>
|
||||
<b-col>
|
||||
<b-row class="bg-248 gradido-border-radius pt-lg-2 ml-lg-2 mt-2 mt-lg-0">
|
||||
<b-col cols="10" @click="radioSelected = sendTypes.link" class="pointer">
|
||||
{{ $t('send_per_link') }}
|
||||
<b-row>
|
||||
<b-col cols="12">
|
||||
<div v-if="radioSelected === sendTypes.send">
|
||||
<input-email
|
||||
:name="$t('form.recipient')"
|
||||
:label="$t('form.recipient')"
|
||||
:placeholder="$t('form.email')"
|
||||
v-model="form.email"
|
||||
:disabled="isBalanceDisabled"
|
||||
@onValidation="onValidation"
|
||||
/>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="2" class="pointer">
|
||||
<b-form-radio
|
||||
name="shipping"
|
||||
:value="sendTypes.link"
|
||||
size="lg"
|
||||
class="custom-radio-button"
|
||||
></b-form-radio>
|
||||
<b-col cols="12" lg="6">
|
||||
<input-amount
|
||||
v-model="form.amount"
|
||||
:name="$t('form.amount')"
|
||||
:label="$t('form.amount')"
|
||||
:placeholder="'0.01'"
|
||||
:rules="{ required: true, gddSendAmount: [0.01, balance] }"
|
||||
typ="TransactionForm"
|
||||
:disabled="isBalanceDisabled"
|
||||
></input-amount>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<div class="mt-4 mb-4" v-if="radioSelected === sendTypes.link">
|
||||
<h2 class="alert-heading">{{ $t('gdd_per_link.header') }}</h2>
|
||||
<div>
|
||||
{{ $t('gdd_per_link.choose-amount') }}
|
||||
</div>
|
||||
<b-row>
|
||||
<b-col>
|
||||
<input-textarea
|
||||
v-model="form.memo"
|
||||
:name="$t('form.message')"
|
||||
:label="$t('form.message')"
|
||||
:placeholder="$t('form.message')"
|
||||
:rules="{ required: true, min: 5, max: 255 }"
|
||||
:disabled="isBalanceDisabled"
|
||||
/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<div v-if="!!isBalanceDisabled" class="text-danger mt-5">
|
||||
{{ $t('form.no_gdd_available') }}
|
||||
</div>
|
||||
</b-form-radio-group>
|
||||
<b-row>
|
||||
<b-col>
|
||||
<b-row>
|
||||
<b-col cols="12">
|
||||
<div v-if="radioSelected === sendTypes.send">
|
||||
<input-email
|
||||
:name="$t('form.recipient')"
|
||||
:label="$t('form.recipient')"
|
||||
:placeholder="$t('form.email')"
|
||||
v-model="form.email"
|
||||
:disabled="isBalanceDisabled"
|
||||
/>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="12" lg="6">
|
||||
<input-amount
|
||||
v-model="form.amount"
|
||||
:name="$t('form.amount')"
|
||||
:label="$t('form.amount')"
|
||||
:placeholder="'0.01'"
|
||||
:rules="{ required: true, gddSendAmount: [0.01, balance] }"
|
||||
typ="TransactionForm"
|
||||
:disabled="isBalanceDisabled"
|
||||
></input-amount>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row>
|
||||
<b-col>
|
||||
<input-textarea
|
||||
v-model="form.memo"
|
||||
:name="$t('form.message')"
|
||||
:label="$t('form.message')"
|
||||
:placeholder="$t('form.message')"
|
||||
:rules="{ required: true, min: 5, max: 255 }"
|
||||
:disabled="isBalanceDisabled"
|
||||
/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<div v-if="!!isBalanceDisabled" class="text-danger mt-5">
|
||||
{{ $t('form.no_gdd_available') }}
|
||||
</div>
|
||||
<b-row v-else class="test-buttons mt-5">
|
||||
<b-col>
|
||||
<b-button type="reset" variant="secondary" @click="onReset">
|
||||
{{ $t('form.reset') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col class="text-right">
|
||||
<b-button type="submit" variant="gradido">
|
||||
{{ $t('form.check_now') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-form>
|
||||
</validation-observer>
|
||||
</b-card>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row v-else class="test-buttons mt-3">
|
||||
<b-col cols="12" md="6" lg="6">
|
||||
<b-button
|
||||
block
|
||||
type="reset"
|
||||
variant="secondary"
|
||||
@click="onReset"
|
||||
class="mb-3 mb-md-0 mb-lg-0"
|
||||
>
|
||||
{{ $t('form.reset') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col cols="12" md="6" lg="6" class="text-lg-right">
|
||||
<b-button block type="submit" variant="gradido">
|
||||
{{ $t('form.check_now') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-form>
|
||||
</validation-observer>
|
||||
</b-card>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { SEND_TYPES } from '@/pages/Send.vue'
|
||||
@ -140,6 +149,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onValidation() {
|
||||
this.$refs.formValidator.validate()
|
||||
},
|
||||
onSubmit() {
|
||||
this.$emit('set-transaction', {
|
||||
selected: this.radioSelected,
|
||||
@ -153,6 +165,7 @@ export default {
|
||||
this.form.email = ''
|
||||
this.form.amount = ''
|
||||
this.form.memo = ''
|
||||
this.$refs.formValidator.validate()
|
||||
},
|
||||
setNewRecipientEmail() {
|
||||
this.form.email = this.recipientEmail ? this.recipientEmail : this.form.email
|
||||
@ -177,6 +190,9 @@ export default {
|
||||
created() {
|
||||
this.setNewRecipientEmail()
|
||||
},
|
||||
mounted() {
|
||||
if (this.form.email !== '') this.$refs.formValidator.validate()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
:memo="memo"
|
||||
:validUntil="validUntil"
|
||||
></clipboard-copy>
|
||||
<label>{{ $t('qrCode') }}</label>
|
||||
<div class="text-center">
|
||||
<div><figure-qr-code :link="link" /></div>
|
||||
<div>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<div v-else>{{ errorResult }}</div>
|
||||
</div>
|
||||
<p class="text-center mt-5">
|
||||
<b-button variant="secondary" @click="$emit('on-reset')">
|
||||
<b-button variant="secondary" @click="$emit('on-back')">
|
||||
{{ $t('form.close') }}
|
||||
</b-button>
|
||||
</p>
|
||||
|
||||
@ -26,45 +26,46 @@
|
||||
</template>
|
||||
</transaction-list-item>
|
||||
</div>
|
||||
<div v-if="transactionCount > 0" class="h4 m-3">{{ $t('lastMonth') }}</div>
|
||||
<div v-for="({ id, typeId }, index) in transactions" :key="`l2-` + id">
|
||||
<transaction-list-item
|
||||
v-if="typeId !== 'DECAY'"
|
||||
:typeId="typeId"
|
||||
class="pointer mb-4 bg-white appBoxShadow gradido-border-radius p-3 test-list-group-item"
|
||||
>
|
||||
<template #SEND>
|
||||
<transaction-send
|
||||
v-bind="transactions[index]"
|
||||
:previousBookedBalance="previousBookedBalance(index)"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
<div class="mt-3">
|
||||
<div v-for="({ id, typeId }, index) in transactions" :key="`l2-` + id">
|
||||
<transaction-list-item
|
||||
v-if="typeId !== 'DECAY'"
|
||||
:typeId="typeId"
|
||||
class="pointer mb-3 bg-white appBoxShadow gradido-border-radius p-3 test-list-group-item"
|
||||
>
|
||||
<template #SEND>
|
||||
<transaction-send
|
||||
v-bind="transactions[index]"
|
||||
:previousBookedBalance="previousBookedBalance(index)"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #RECEIVE>
|
||||
<transaction-receive
|
||||
v-bind="transactions[index]"
|
||||
:previousBookedBalance="previousBookedBalance(index)"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
<template #RECEIVE>
|
||||
<transaction-receive
|
||||
v-bind="transactions[index]"
|
||||
:previousBookedBalance="previousBookedBalance(index)"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #CREATION>
|
||||
<transaction-creation
|
||||
v-bind="transactions[index]"
|
||||
:previousBookedBalance="previousBookedBalance(index)"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
<template #CREATION>
|
||||
<transaction-creation
|
||||
v-bind="transactions[index]"
|
||||
:previousBookedBalance="previousBookedBalance(index)"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #LINK_SUMMARY>
|
||||
<transaction-link-summary
|
||||
v-bind="transactions[index]"
|
||||
:transactionLinkCount="transactionLinkCount"
|
||||
@update-transactions="updateTransactions"
|
||||
/>
|
||||
</template>
|
||||
</transaction-list-item>
|
||||
<template #LINK_SUMMARY>
|
||||
<transaction-link-summary
|
||||
v-bind="transactions[index]"
|
||||
:transactionLinkCount="transactionLinkCount"
|
||||
@update-transactions="updateTransactions"
|
||||
/>
|
||||
</template>
|
||||
</transaction-list-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<b-pagination
|
||||
|
||||
@ -9,10 +9,10 @@ describe('InputAmount', () => {
|
||||
|
||||
const mocks = {
|
||||
$t: jest.fn((t) => t),
|
||||
$n: jest.fn((n) => n),
|
||||
$i18n: {
|
||||
locale: jest.fn(() => 'en'),
|
||||
},
|
||||
$n: jest.fn((n) => String(n)),
|
||||
$route: {
|
||||
params: {},
|
||||
},
|
||||
@ -46,13 +46,14 @@ describe('InputAmount', () => {
|
||||
|
||||
describe('amount normalization', () => {
|
||||
describe('if invalid', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.setProps({ value: '12m34' })
|
||||
valid = false
|
||||
})
|
||||
|
||||
it('is not normalized', () => {
|
||||
wrapper.vm.normalizeAmount(valid)
|
||||
expect(wrapper.vm.amountValue).toBe(0.0)
|
||||
wrapper.vm.normalizeAmount(false)
|
||||
expect(wrapper.vm.currentValue).toBe('12m34')
|
||||
})
|
||||
})
|
||||
|
||||
@ -97,13 +98,14 @@ describe('InputAmount', () => {
|
||||
|
||||
describe('amount normalization', () => {
|
||||
describe('if invalid', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.setProps({ value: '12m34' })
|
||||
valid = false
|
||||
})
|
||||
|
||||
it('is not normalized', () => {
|
||||
wrapper.vm.normalizeAmount(valid)
|
||||
expect(wrapper.vm.amountValue).toBe(0.0)
|
||||
expect(wrapper.vm.currentValue).toBe('12m34')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -20,8 +20,9 @@
|
||||
trim
|
||||
v-focus="amountFocused"
|
||||
@focus="amountFocused = true"
|
||||
@blur="normalizeAmount(true)"
|
||||
@blur="normalizeAmount(valid)"
|
||||
:disabled="disabled"
|
||||
autocomplete="off"
|
||||
></b-form-input>
|
||||
|
||||
<b-form-invalid-feedback v-bind="ariaMsg">
|
||||
@ -63,7 +64,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentValue: '',
|
||||
currentValue: this.value,
|
||||
amountValue: 0.0,
|
||||
amountFocused: false,
|
||||
}
|
||||
@ -89,5 +90,8 @@ export default {
|
||||
this.currentValue = this.$n(this.amountValue, 'ungroupedDecimal')
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.value !== '') this.normalizeAmount(true)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
@focus="emailFocused = true"
|
||||
@blur="normalizeEmail()"
|
||||
:disabled="disabled"
|
||||
autocomplete="off"
|
||||
></b-form-input>
|
||||
<b-form-invalid-feedback v-bind="ariaMsg">
|
||||
{{ errors[0] }}
|
||||
@ -62,7 +63,10 @@ export default {
|
||||
this.$emit('input', this.currentValue)
|
||||
},
|
||||
value() {
|
||||
if (this.value !== this.currentValue) this.currentValue = this.value
|
||||
if (this.value !== this.currentValue) {
|
||||
this.currentValue = this.value
|
||||
}
|
||||
this.$emit('onValidation')
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@ -74,7 +74,7 @@ describe('InputHour', () => {
|
||||
it('emits input with new value', async () => {
|
||||
await wrapper.find('input').setValue('12')
|
||||
expect(wrapper.emitted('input')).toBeTruthy()
|
||||
expect(wrapper.emitted('input')).toEqual([['12']])
|
||||
expect(wrapper.emitted('input')).toEqual([[12]])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
:placeholder="placeholder"
|
||||
type="number"
|
||||
:state="validated ? valid : false"
|
||||
step="0.5"
|
||||
step="0.25"
|
||||
min="0"
|
||||
:max="validMaxTime"
|
||||
class="bg-248"
|
||||
@ -32,11 +32,11 @@ export default {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
name: { type: String, required: true, default: 'Time' },
|
||||
label: { type: String, required: true, default: 'Time' },
|
||||
placeholder: { type: String, required: true, default: 'Time' },
|
||||
name: { type: String, required: true },
|
||||
label: { type: String, required: true },
|
||||
placeholder: { type: String, required: true },
|
||||
value: { type: Number, required: true, default: 0 },
|
||||
validMaxTime: { type: Number, required: true, default: 0 },
|
||||
validMaxTime: { type: Number, required: true },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@ -50,7 +50,7 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
currentValue() {
|
||||
this.$emit('input', this.currentValue)
|
||||
this.$emit('input', Number(this.currentValue))
|
||||
},
|
||||
value() {
|
||||
if (this.value !== this.currentValue) this.currentValue = this.value
|
||||
|
||||
@ -58,7 +58,7 @@ describe('InputTextarea', () => {
|
||||
})
|
||||
|
||||
it('has the value ""', () => {
|
||||
expect(wrapper.vm.currentValue).toEqual('')
|
||||
expect(wrapper.vm.currentValue).toEqual('Long enough')
|
||||
})
|
||||
|
||||
it('has the label "input-field-label"', () => {
|
||||
@ -72,9 +72,8 @@ describe('InputTextarea', () => {
|
||||
|
||||
describe('input value changes', () => {
|
||||
it('emits input with new value', async () => {
|
||||
await wrapper.find('textarea').setValue('Long enough')
|
||||
expect(wrapper.emitted('input')).toBeTruthy()
|
||||
expect(wrapper.emitted('input')).toEqual([['Long enough']])
|
||||
await wrapper.find('textarea').setValue('New Text')
|
||||
expect(wrapper.emitted('input')).toEqual([['New Text']])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
rows="4"
|
||||
max-rows="4"
|
||||
:disabled="disabled"
|
||||
no-resize
|
||||
></b-form-textarea>
|
||||
<b-form-invalid-feedback v-bind="ariaMsg">
|
||||
{{ errors[0] }}
|
||||
@ -41,7 +42,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentValue: '',
|
||||
currentValue: this.value,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@ -1,64 +1,49 @@
|
||||
<template>
|
||||
<div class="navbar-component position-sticky">
|
||||
<b-navbar toggleable="lg" class="pr-4">
|
||||
<b-navbar-brand>
|
||||
<b-img
|
||||
class="imgLogo mt-lg--2 mt-3 mb-3 d-none d-lg-block zindex10"
|
||||
:src="logo"
|
||||
width=""
|
||||
alt="..."
|
||||
/>
|
||||
<b-button v-b-toggle.sidebar-mobile class="d-block d-lg-none">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</b-button>
|
||||
</b-navbar-brand>
|
||||
|
||||
<router-link to="/settings" class="d-block d-lg-none">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="mr-3">
|
||||
<avatar
|
||||
:username="username.username"
|
||||
:initials="username.initials"
|
||||
:color="'#fff'"
|
||||
:size="61"
|
||||
></avatar>
|
||||
<div class="navbar-component">
|
||||
<div class="navbar-element">
|
||||
<b-navbar toggleable="lg" class="pr-4">
|
||||
<b-navbar-brand>
|
||||
<b-img
|
||||
class="imgLogo mt-lg--2 mt-3 mb-3 d-none d-lg-block zindex10"
|
||||
:src="logo"
|
||||
width=""
|
||||
alt="..."
|
||||
/>
|
||||
<div v-b-toggle.sidebar-mobile variant="link" class="d-block d-lg-none">
|
||||
<span class="navbar-toggler-icon h2"></span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
<b-img class="sheet-img position-absolute zindex-1" :src="sheet"></b-img>
|
||||
<b-collapse id="nav-collapse" is-nav class="ml-5">
|
||||
<b-navbar-nav class="ml-auto" right>
|
||||
<div class="mb-2">
|
||||
<router-link to="/settings">
|
||||
<div>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="mr-3">
|
||||
<avatar
|
||||
:username="username.username"
|
||||
:initials="username.initials"
|
||||
:color="'#fff'"
|
||||
:size="81"
|
||||
></avatar>
|
||||
</div>
|
||||
<div>
|
||||
<div data-test="navbar-item-username">{{ username.username }}</div>
|
||||
</b-navbar-brand>
|
||||
|
||||
<div class="text-right" data-test="navbar-item-email">
|
||||
{{ $store.state.email }}
|
||||
</div>
|
||||
</div>
|
||||
<b-img class="sheet-img position-absolute zindex-1" :src="sheet"></b-img>
|
||||
|
||||
<b-navbar-nav class="ml-auto" right>
|
||||
<router-link to="/settings">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="mr-3">
|
||||
<avatar
|
||||
:username="username.username"
|
||||
:initials="username.initials"
|
||||
:color="'#fff'"
|
||||
:size="61"
|
||||
></avatar>
|
||||
</div>
|
||||
<div>
|
||||
<div data-test="navbar-item-username">{{ username.username }}</div>
|
||||
|
||||
<div data-test="navbar-item-email">
|
||||
{{ $store.state.email }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</b-navbar-nav>
|
||||
</b-collapse>
|
||||
</b-navbar>
|
||||
<!-- <div class="alertBox">
|
||||
</b-navbar>
|
||||
<!-- <div class="alertBox">
|
||||
<b-alert show dismissible variant="light" class="nav-alert text-dark">
|
||||
<small>{{ $t('1000thanks') }}</small>
|
||||
</b-alert>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -91,6 +76,10 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.navbar-element {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
font-family: 'Open Sans', sans-serif !important;
|
||||
height: 150px;
|
||||
@ -126,7 +115,7 @@ button.navbar-toggler > span.navbar-toggler-icon {
|
||||
}
|
||||
@media screen and (max-width: 1170px) {
|
||||
.sheet-img {
|
||||
left: 40%;
|
||||
left: 20%;
|
||||
}
|
||||
.alertBox {
|
||||
position: static;
|
||||
@ -136,10 +125,15 @@ button.navbar-toggler > span.navbar-toggler-icon {
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 450px) {
|
||||
.sheet-img {
|
||||
left: 37%;
|
||||
max-width: 61%;
|
||||
.navbar-element {
|
||||
z-index: 1000;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
background-color: #f5f5f5e6;
|
||||
}
|
||||
.sheet-img {
|
||||
left: 5%;
|
||||
max-width: 61%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user