Merge branch 'master' into statistics-resolver

This commit is contained in:
Alexander Friedland 2022-08-06 11:14:58 +02:00 committed by GitHub
commit 58512a9e32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
85 changed files with 3355 additions and 297 deletions

View File

@ -4,8 +4,55 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [1.11.0](https://github.com/gradido/gradido/compare/1.10.1...1.11.0)
- Fix navbar community item [`#2102`](https://github.com/gradido/gradido/pull/2102)
- Add validation date info to copied text after transaction link creation [`#2101`](https://github.com/gradido/gradido/pull/2101)
- Remove member area from menu (desktop and mobile), when user has no elopage account [`#2099`](https://github.com/gradido/gradido/pull/2099)
- fix: Use Inner Join for Contribution and User [`#2100`](https://github.com/gradido/gradido/pull/2100)
- Enable copying the link, username, amount, and memo text after transaction link creation [`#2098`](https://github.com/gradido/gradido/pull/2098)
- feat: Insert Missing Contributions Migration [`#2053`](https://github.com/gradido/gradido/pull/2053)
- [Bug] Wallet improvments for Contributions [`#2090`](https://github.com/gradido/gradido/pull/2090)
- [Fix] Add createdAt & contributionDate to ContributionListItems [`#2093`](https://github.com/gradido/gradido/pull/2093)
- fix: 🍰 Reset Amount In Contribution Form And Write A Test [`#2086`](https://github.com/gradido/gradido/pull/2086)
- [Feat] Replace logic to validation-provider. [`#2088`](https://github.com/gradido/gradido/pull/2088)
- fix: Add Confirm Dialog on Delete Contribution [`#2087`](https://github.com/gradido/gradido/pull/2087)
- fix: Admin Cannot Edit User Contribution [`#2085`](https://github.com/gradido/gradido/pull/2085)
- fix: Update contribution_date when Moved by Seed [`#2083`](https://github.com/gradido/gradido/pull/2083)
- chore: 🍰 Provide Volume For Backend Log-Files In Docker [`#2067`](https://github.com/gradido/gradido/pull/2067)
- feat: 🍰 Community Contribution Site And Form [`#2042`](https://github.com/gradido/gradido/pull/2042)
- [Refactor] Move MEMO_MIN_CHARS and MEMO_MAX_CHARS to const file [`#2082`](https://github.com/gradido/gradido/pull/2082)
- Fix: Test memo length on createContribution & updateContribution [`#2080`](https://github.com/gradido/gradido/pull/2080)
- chore: 🍰 Change `image` Entries In Docker Compose Files And Get Apple M1 Running [`#2050`](https://github.com/gradido/gradido/pull/2050)
- fix: Windows 0D 0A Linebreaks to Unix 0A [`#2064`](https://github.com/gradido/gradido/pull/2064)
- Add contributionDate to the Contribution object. [`#2066`](https://github.com/gradido/gradido/pull/2066)
- fix: Add Contributions to User [`#2062`](https://github.com/gradido/gradido/pull/2062)
- Feat: ContributionResolver - delete mutation [`#2035`](https://github.com/gradido/gradido/pull/2035)
- Fix: Add count to list contributions [`#2061`](https://github.com/gradido/gradido/pull/2061)
- docu: Explain how `.env` Files are Working [`#2022`](https://github.com/gradido/gradido/pull/2022)
- [WIP] 1794 feature event protocol 1 implement the basics of the business event protocol [`#1997`](https://github.com/gradido/gradido/pull/1997)
- docs: 🍰 Document The Setup Of The GraphQL Playground [`#2060`](https://github.com/gradido/gradido/pull/2060)
- Feat: List all contribution [`#2057`](https://github.com/gradido/gradido/pull/2057)
- feat: Do not log IntrospectionQuery from Query Browser [`#2059`](https://github.com/gradido/gradido/pull/2059)
- Feat: Add confirmedBy and confirmedAt for the contribution query. [`#2052`](https://github.com/gradido/gradido/pull/2052)
- Add open creations to webapp [`#2048`](https://github.com/gradido/gradido/pull/2048)
- Prevent session expiration modal from displaying negative seconds, when session is expired for more than 0 seconds [`#2054`](https://github.com/gradido/gradido/pull/2054)
- feat: mutation contribution update [`#2032`](https://github.com/gradido/gradido/pull/2032)
- feat: Login Returns Open Creations for User [`#2046`](https://github.com/gradido/gradido/pull/2046)
- Migrate transaction to valid dataset for gradido node [`#2029`](https://github.com/gradido/gradido/pull/2029)
- feat: implement contribution list query [`#2031`](https://github.com/gradido/gradido/pull/2031)
- add code for moving user creation date if transaction before exist [`#2034`](https://github.com/gradido/gradido/pull/2034)
- change text from page [`#2037`](https://github.com/gradido/gradido/pull/2037)
- Transaction link: copy link, text and more [`#2030`](https://github.com/gradido/gradido/pull/2030)
- change welcome in community text [`#2025`](https://github.com/gradido/gradido/pull/2025)
- changed link color in navbar and language switch [`#2024`](https://github.com/gradido/gradido/pull/2024)
- feat: ContributionResolver - createContribution [`#2009`](https://github.com/gradido/gradido/pull/2009)
#### [1.10.1](https://github.com/gradido/gradido/compare/1.10.0...1.10.1)
> 30 June 2022
- release: 1.10.1 [`#2021`](https://github.com/gradido/gradido/pull/2021)
- automatic session logout with info modal [`#2001`](https://github.com/gradido/gradido/pull/2001)
- 1910 separate text for the slideshow images. [`#1998`](https://github.com/gradido/gradido/pull/1998)
- Origin/1921 additional parameter checks for createContributionLinks [`#1996`](https://github.com/gradido/gradido/pull/1996)

44
DOCKER_MORE_CLOSELY.md Normal file
View File

@ -0,0 +1,44 @@
# Docker More Closely
## Apple M1 Platform
***Attention:** For using Docker commands in Apple M1 environments!*
### Enviroment Variable For Apple M1 Platform
To set the Docker platform environment variable in your terminal tab, run:
```bash
# set env variable for your shell
$ export DOCKER_DEFAULT_PLATFORM=linux/amd64
```
### Docker Compose Override File For Apple M1 Platform
For Docker compose `up` or `build` commands, you can use our Apple M1 override file that specifies the M1 platform:
```bash
# in main folder
# for development
$ docker compose -f docker-compose.yml -f docker-compose.override.yml -f docker-compose.apple-m1.override.yml up
# for production
$ docker compose -f docker-compose.yml -f docker-compose.apple-m1.override.yml up
```
## Analysing Docker Builds
To analyze a Docker build, there is a wonderful tool called [dive](https://github.com/wagoodman/dive). Please sponsor if you're using it!
The `dive build` command is exactly the right one to fulfill what we are looking for.
We can use it just like the `docker build` command and get an analysis afterwards.
So, in our main folder, we use it in the following way:
```bash
# in main folder
$ dive build --target <layer-name> -t "gradido/<app-name>:local-<layer-name>" <app-folder-name-or-dot>/
```
For the specific applications, see our [publish.yml](.github/workflows/publish.yml).

View File

@ -121,6 +121,19 @@ After generating a new version you should commit the changes. This will be the C
Note: The Changelog will be regenerated with all tags on release on the external builder tool, but will not be checked in there. The Changelog on the github release will therefore always be correct, on the repo it might be incorrect due to missing tags when executing the `yarn release` command.
## How the different .env work on deploy
Each component (frontend, admin, backend and database) has its own `.env` file. When running in development with docker and nginx you usually do not have to care about the `.env`. The defaults are set by the respective config file, found in the `src/config/` folder of each component. But if you have a local `.env`, the defaults set in the config are overwritten by the `.env`. If you do not use docker, you need the `.env` in the frontend and admin interface because nginx is not running in order to find the backend.
Each component has a `.env.dist` file. This file contains all environment variables used by the component and can be used as pattern. If you want to use a local `.env`, copy the `.env.dist` and adjust the variables accordingly.
Each component has a `.env.template` file. These files are very important on deploy.
There is one `.env.dist` in the `deployment/bare_metal/` folder. This `.env.dist` contains all variables used by the components, e.g. unites all `.env.dist` from the components. On deploy, we copy this `.env.dist` to `.env` and set all variables in this new file. The deploy script loads this variables and provides them by the `.env.templates` of each component, creating an `.env` for each component (see in `deployment/bare_metal/start.sh` the `envsubst`).
To avoid forgetting to update an existing `.env` in the `deployment/bare_metal/` folder when deploying, we have an environment version variable inside the codebase of each component. You should update this version, when environment variables must be changed or added on deploy. The code checks, that the environement version provided by the `.env` is the one expected by the codebase.
## Troubleshooting
| Problem | Issue | Solution | Description |

View File

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

View File

@ -28,7 +28,7 @@ const propsData = {
amount: 210,
memo: 'Aktives Grundeinkommen für Januar 2022',
date: '2022-01-01T00:00:00.000Z',
moderator: 1,
moderator: null,
creation: [790, 1000, 1000],
__typename: 'PendingCreation',
},
@ -66,7 +66,7 @@ const propsData = {
},
},
{ key: 'moderator', label: 'moderator' },
{ key: 'edit_creation', label: 'edit' },
{ key: 'editCreation', label: 'edit' },
{ key: 'confirm', label: 'save' },
],
toggleDetails: false,
@ -113,6 +113,10 @@ describe('OpenCreationsTable', () => {
expect(wrapper.findAll('tr').at(1).find('.bi-pencil-square').exists()).toBe(true)
})
it('has no button.bi-pencil-square for user contribution ', () => {
expect(wrapper.findAll('tr').at(2).find('.bi-pencil-square').exists()).toBe(false)
})
describe('show edit details', () => {
beforeEach(async () => {
await wrapper.findAll('tr').at(1).find('.bi-pencil-square').trigger('click')

View File

@ -11,8 +11,14 @@
<b-icon icon="x" variant="light"></b-icon>
</b-button>
</template>
<template #cell(edit_creation)="row">
<b-button variant="info" size="md" @click="rowToggleDetails(row, 0)" class="mr-2">
<template #cell(editCreation)="row">
<b-button
v-if="row.item.moderator"
variant="info"
size="md"
@click="rowToggleDetails(row, 0)"
class="mr-2"
>
<b-icon :icon="row.detailsShowing ? 'x' : 'pencil-square'" aria-label="Help"></b-icon>
</b-button>
</template>

View File

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

View File

@ -35,6 +35,7 @@
"creation_form": {
"creation_failed": "Ausstehende Schöpfung für {email} konnte nicht erzeugt werden.",
"creation_for": "Aktives Grundeinkommen für",
"deleteNow": "Möchtest du diesen Beitrag zur Gemeinschaft wirklich löschen?",
"enter_text": "Text eintragen",
"form": "Schöpfungsformular",
"min_characters": "Mindestens 10 Zeichen eingeben",

View File

@ -35,6 +35,7 @@
"creation_form": {
"creation_failed": "Could not create pending creation for {email}",
"creation_for": "Active Basic Income for",
"deleteNow": "Do you really want to delete this contribution to the community?",
"enter_text": "Enter text",
"form": "Creation form",
"min_characters": "Enter at least 10 characters",

View File

@ -29,6 +29,7 @@
per-page="perPage"
:total-rows="rows"
align="center"
:hide-ellipsis="true"
></b-pagination>
</b-col>
<b-col cols="12" lg="6" class="shadow p-3 mb-5 rounded bg-info">

View File

@ -18,7 +18,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
amount: 500,
memo: 'Danke für alles',
date: new Date(),
moderator: 0,
moderator: 2,
},
{
id: 2,
@ -28,7 +28,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({
amount: 1000000,
memo: 'Gut Ergattert',
date: new Date(),
moderator: 0,
moderator: 2,
},
],
},
@ -80,28 +80,54 @@ describe('CreationConfirm', () => {
})
describe('remove creation with success', () => {
beforeEach(async () => {
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
let spy
it('calls the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: adminDeleteContribution,
variables: { id: 1 },
describe('admin confirms deletion', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve('some value'))
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
it('opens a modal', () => {
expect(spy).toBeCalled()
})
it('calls the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).toBeCalledWith({
mutation: adminDeleteContribution,
variables: { id: 1 },
})
})
it('commits openCreationsMinus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
})
})
it('commits openCreationsMinus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
})
describe('admin cancels deletion', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve(false))
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('creation_form.toasted_delete')
it('does not call the adminDeleteContribution mutation', () => {
expect(apolloMutateMock).not.toBeCalled()
})
})
})
describe('remove creation with error', () => {
let spy
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve('some value'))
apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' })
await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click')
})

View File

@ -34,20 +34,23 @@ export default {
},
methods: {
removeCreation(item) {
this.$apollo
.mutate({
mutation: adminDeleteContribution,
variables: {
id: item.id,
},
})
.then((result) => {
this.updatePendingCreations(item.id)
this.toastSuccess(this.$t('creation_form.toasted_delete'))
})
.catch((error) => {
this.toastError(error.message)
})
this.$bvModal.msgBoxConfirm(this.$t('creation_form.deleteNow')).then(async (value) => {
if (value)
await this.$apollo
.mutate({
mutation: adminDeleteContribution,
variables: {
id: item.id,
},
})
.then((result) => {
this.updatePendingCreations(item.id)
this.toastSuccess(this.$t('creation_form.toasted_delete'))
})
.catch((error) => {
this.toastError(error.message)
})
})
},
confirmCreation() {
this.$apollo
@ -114,7 +117,7 @@ export default {
},
},
{ key: 'moderator', label: this.$t('moderator') },
{ key: 'edit_creation', label: this.$t('edit') },
{ key: 'editCreation', label: this.$t('edit') },
{ key: 'confirm', label: this.$t('save') },
]
},

View File

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

View File

@ -1,4 +1,4 @@
CONFIG_VERSION=v8.2022-06-20
CONFIG_VERSION=v9.2022-07-07
# Server
PORT=4000
@ -52,6 +52,9 @@ EMAIL_CODE_REQUEST_TIME=10
# Webhook
WEBHOOK_ELOPAGE_SECRET=secret
# EventProtocol
EVENT_PROTOCOL_DISABLED=false
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
# LOG_LEVEL=info

View File

@ -50,3 +50,6 @@ EMAIL_CODE_REQUEST_TIME=$EMAIL_CODE_REQUEST_TIME
# Webhook
WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET
# EventProtocol
EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED

View File

@ -1,16 +1,18 @@
# backend
## Project setup
```
```bash
yarn install
```
## Seed DB
```
```bash
yarn seed
```
Deletes all data in database. Then seeds data in database.
Deletes all data in database. Then seeds data in database.
## Seeded Users
@ -22,3 +24,47 @@ Deletes all data in database. Then seeds data in database.
| bob@baumeister.de | `Aa12345_` | `false` | `true` | `false` |
| garrick@ollivander.com | | `false` | `false` | `false` |
| stephen@hawking.uk | `Aa12345_` | `false` | `true` | `true` |
## Setup GraphQL Playground
### Setup In The Code
Setting up the GraphQL Playground in our code requires the following steps:
- Create an empty `.env` file in the `backend` folder and set "GRAPHIQL=true" there.
- Start or restart Docker Compose.
- For verification, Docker should display `GraphQL available at http://localhost:4000` in the terminal.
- If you open "http://localhost:4000/" in your browser, you should see the GraphQL Playground.
### Authentication
You need to authenticate yourself in GraphQL Playground to be able to send queries and mutations, to do so follow the steps below:
- in Firefox go to "Network Analysis" and delete all entries
- enter and send the login query:
```gql
{
login(email: "bibi@bloxberg.de", password:"Aa12345_") {
id
publisherId
email
firstName
lastName
emailChecked
language
hasElopage
}
}
```
- search in Firefox under „Network Analysis" for the smallest size of a header and copy the value of the token
- open the header section in GraphQL Playground and set your current token by filling in and replacing `XXX`:
```qgl
{
"Authorization": "XXX"
}
```
Now you can open a new tap in the Playground and enter your query or mutation there.

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "1.10.1",
"version": "1.11.0",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",

View File

@ -26,7 +26,9 @@ export enum RIGHTS {
LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS',
GDT_BALANCE = 'GDT_BALANCE',
CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION',
DELETE_CONTRIBUTION = 'DELETE_CONTRIBUTION',
LIST_CONTRIBUTIONS = 'LIST_CONTRIBUTIONS',
LIST_ALL_CONTRIBUTIONS = 'LIST_ALL_CONTRIBUTIONS',
UPDATE_CONTRIBUTION = 'UPDATE_CONTRIBUTION',
// Admin
SEARCH_USERS = 'SEARCH_USERS',

View File

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

View File

@ -10,14 +10,14 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0042-update_transactions_for_blockchain',
DB_VERSION: '0044-insert_missing_contributions',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v8.2022-06-20',
EXPECTED: 'v9.2022-07-07',
CURRENT: '',
},
}
@ -94,6 +94,11 @@ const webhook = {
WEBHOOK_ELOPAGE_SECRET: process.env.WEBHOOK_ELOPAGE_SECRET || 'secret',
}
const eventProtocol = {
// global switch to enable writing of EventProtocol-Entries
EVENT_PROTOCOL_DISABLED: process.env.EVENT_PROTOCOL_DISABLED === 'true' || false,
}
// This is needed by graphql-directive-auth
process.env.APP_SECRET = server.JWT_SECRET
@ -118,6 +123,7 @@ const CONFIG = {
...email,
...loginServer,
...webhook,
...eventProtocol,
}
export default CONFIG

301
backend/src/event/Event.ts Normal file
View File

@ -0,0 +1,301 @@
import { EventProtocol } from '@entity/EventProtocol'
import decimal from 'decimal.js-light'
import { EventProtocolType } from './EventProtocolType'
export class EventBasic {
type: string
createdAt: Date
}
export class EventBasicUserId extends EventBasic {
userId: number
}
export class EventBasicTx extends EventBasicUserId {
xUserId: number
xCommunityId: number
transactionId: number
amount: decimal
}
export class EventBasicCt extends EventBasicUserId {
contributionId: number
amount: decimal
}
export class EventBasicRedeem extends EventBasicUserId {
transactionId?: number
contributionId?: number
}
export class EventVisitGradido extends EventBasic {}
export class EventRegister extends EventBasicUserId {}
export class EventRedeemRegister extends EventBasicRedeem {}
export class EventInactiveAccount extends EventBasicUserId {}
export class EventSendConfirmationEmail extends EventBasicUserId {}
export class EventConfirmationEmail extends EventBasicUserId {}
export class EventRegisterEmailKlicktipp extends EventBasicUserId {}
export class EventLogin extends EventBasicUserId {}
export class EventRedeemLogin extends EventBasicRedeem {}
export class EventActivateAccount extends EventBasicUserId {}
export class EventPasswordChange extends EventBasicUserId {}
export class EventTransactionSend extends EventBasicTx {}
export class EventTransactionSendRedeem extends EventBasicTx {}
export class EventTransactionRepeateRedeem extends EventBasicTx {}
export class EventTransactionCreation extends EventBasicUserId {
transactionId: number
amount: decimal
}
export class EventTransactionReceive extends EventBasicTx {}
export class EventTransactionReceiveRedeem extends EventBasicTx {}
export class EventContributionCreate extends EventBasicCt {}
export class EventContributionConfirm extends EventBasicCt {
xUserId: number
xCommunityId: number
}
export class EventContributionLinkDefine extends EventBasicCt {}
export class EventContributionLinkActivateRedeem extends EventBasicCt {}
export class Event {
constructor()
constructor(event?: EventProtocol) {
if (event) {
this.id = event.id
this.type = event.type
this.createdAt = event.createdAt
this.userId = event.userId
this.xUserId = event.xUserId
this.xCommunityId = event.xCommunityId
this.transactionId = event.transactionId
this.contributionId = event.contributionId
this.amount = event.amount
}
}
public setEventBasic(): Event {
this.type = EventProtocolType.BASIC
this.createdAt = new Date()
return this
}
public setEventVisitGradido(): Event {
this.setEventBasic()
this.type = EventProtocolType.VISIT_GRADIDO
return this
}
public setEventRegister(ev: EventRegister): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.REGISTER
return this
}
public setEventRedeemRegister(ev: EventRedeemRegister): Event {
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
this.type = EventProtocolType.REDEEM_REGISTER
return this
}
public setEventInactiveAccount(ev: EventInactiveAccount): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.INACTIVE_ACCOUNT
return this
}
public setEventSendConfirmationEmail(ev: EventSendConfirmationEmail): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.SEND_CONFIRMATION_EMAIL
return this
}
public setEventConfirmationEmail(ev: EventConfirmationEmail): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.CONFIRM_EMAIL
return this
}
public setEventRegisterEmailKlicktipp(ev: EventRegisterEmailKlicktipp): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.REGISTER_EMAIL_KLICKTIPP
return this
}
public setEventLogin(ev: EventLogin): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.LOGIN
return this
}
public setEventRedeemLogin(ev: EventRedeemLogin): Event {
this.setByBasicRedeem(ev.userId, ev.transactionId, ev.contributionId)
this.type = EventProtocolType.REDEEM_LOGIN
return this
}
public setEventActivateAccount(ev: EventActivateAccount): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.ACTIVATE_ACCOUNT
return this
}
public setEventPasswordChange(ev: EventPasswordChange): Event {
this.setByBasicUser(ev.userId)
this.type = EventProtocolType.PASSWORD_CHANGE
return this
}
public setEventTransactionSend(ev: EventTransactionSend): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.type = EventProtocolType.TRANSACTION_SEND
return this
}
public setEventTransactionSendRedeem(ev: EventTransactionSendRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.type = EventProtocolType.TRANSACTION_SEND_REDEEM
return this
}
public setEventTransactionRepeateRedeem(ev: EventTransactionRepeateRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.type = EventProtocolType.TRANSACTION_REPEATE_REDEEM
return this
}
public setEventTransactionCreation(ev: EventTransactionCreation): Event {
this.setByBasicUser(ev.userId)
if (ev.transactionId) this.transactionId = ev.transactionId
if (ev.amount) this.amount = ev.amount
this.type = EventProtocolType.TRANSACTION_CREATION
return this
}
public setEventTransactionReceive(ev: EventTransactionReceive): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.type = EventProtocolType.TRANSACTION_RECEIVE
return this
}
public setEventTransactionReceiveRedeem(ev: EventTransactionReceiveRedeem): Event {
this.setByBasicTx(ev.userId, ev.xUserId, ev.xCommunityId, ev.transactionId, ev.amount)
this.type = EventProtocolType.TRANSACTION_RECEIVE_REDEEM
return this
}
public setEventContributionCreate(ev: EventContributionCreate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_CREATE
return this
}
public setEventContributionConfirm(ev: EventContributionConfirm): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
if (ev.xUserId) this.xUserId = ev.xUserId
if (ev.xCommunityId) this.xCommunityId = ev.xCommunityId
this.type = EventProtocolType.CONTRIBUTION_CONFIRM
return this
}
public setEventContributionLinkDefine(ev: EventContributionLinkDefine): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_LINK_DEFINE
return this
}
public setEventContributionLinkActivateRedeem(ev: EventContributionLinkActivateRedeem): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.CONTRIBUTION_LINK_ACTIVATE_REDEEM
return this
}
setByBasicUser(userId: number): Event {
this.setEventBasic()
this.userId = userId
return this
}
setByBasicTx(
userId: number,
xUserId?: number,
xCommunityId?: number,
transactionId?: number,
amount?: decimal,
): Event {
this.setByBasicUser(userId)
if (xUserId) this.xUserId = xUserId
if (xCommunityId) this.xCommunityId = xCommunityId
if (transactionId) this.transactionId = transactionId
if (amount) this.amount = amount
return this
}
setByBasicCt(userId: number, contributionId: number, amount?: decimal): Event {
this.setByBasicUser(userId)
if (contributionId) this.contributionId = contributionId
if (amount) this.amount = amount
return this
}
setByBasicRedeem(userId: number, transactionId?: number, contributionId?: number): Event {
this.setByBasicUser(userId)
if (transactionId) this.transactionId = transactionId
if (contributionId) this.contributionId = contributionId
return this
}
setByEventTransactionCreation(event: EventTransactionCreation): Event {
this.type = event.type
this.createdAt = event.createdAt
this.userId = event.userId
this.transactionId = event.transactionId
this.amount = event.amount
return this
}
setByEventContributionConfirm(event: EventContributionConfirm): Event {
this.type = event.type
this.createdAt = event.createdAt
this.userId = event.userId
this.xUserId = event.xUserId
this.xCommunityId = event.xCommunityId
this.amount = event.amount
return this
}
id: number
type: string
createdAt: Date
userId: number
xUserId?: number
xCommunityId?: number
transactionId?: number
contributionId?: number
amount?: decimal
}

View File

@ -0,0 +1,39 @@
import { Event } from '@/event/Event'
import { backendLogger as logger } from '@/server/logger'
import { EventProtocol } from '@entity/EventProtocol'
import CONFIG from '@/config'
class EventProtocolEmitter {
/* }extends EventEmitter { */
private events: Event[]
public addEvent(event: Event) {
this.events.push(event)
}
public getEvents(): Event[] {
return this.events
}
public isDisabled() {
logger.info(`EventProtocol - isDisabled=${CONFIG.EVENT_PROTOCOL_DISABLED}`)
return CONFIG.EVENT_PROTOCOL_DISABLED === true
}
public async writeEvent(event: Event): Promise<void> {
if (!eventProtocol.isDisabled()) {
logger.info(`writeEvent(${JSON.stringify(event)})`)
const dbEvent = new EventProtocol()
dbEvent.type = event.type
dbEvent.createdAt = event.createdAt
dbEvent.userId = event.userId
if (event.xUserId) dbEvent.xUserId = event.xUserId
if (event.xCommunityId) dbEvent.xCommunityId = event.xCommunityId
if (event.contributionId) dbEvent.contributionId = event.contributionId
if (event.transactionId) dbEvent.transactionId = event.transactionId
if (event.amount) dbEvent.amount = event.amount
await dbEvent.save()
}
}
}
export const eventProtocol = new EventProtocolEmitter()

View File

@ -0,0 +1,24 @@
export enum EventProtocolType {
BASIC = 'BASIC',
VISIT_GRADIDO = 'VISIT_GRADIDO',
REGISTER = 'REGISTER',
REDEEM_REGISTER = 'REDEEM_REGISTER',
INACTIVE_ACCOUNT = 'INACTIVE_ACCOUNT',
SEND_CONFIRMATION_EMAIL = 'SEND_CONFIRMATION_EMAIL',
CONFIRM_EMAIL = 'CONFIRM_EMAIL',
REGISTER_EMAIL_KLICKTIPP = 'REGISTER_EMAIL_KLICKTIPP',
LOGIN = 'LOGIN',
REDEEM_LOGIN = 'REDEEM_LOGIN',
ACTIVATE_ACCOUNT = 'ACTIVATE_ACCOUNT',
PASSWORD_CHANGE = 'PASSWORD_CHANGE',
TRANSACTION_SEND = 'TRANSACTION_SEND',
TRANSACTION_SEND_REDEEM = 'TRANSACTION_SEND_REDEEM',
TRANSACTION_REPEATE_REDEEM = 'TRANSACTION_REPEATE_REDEEM',
TRANSACTION_CREATION = 'TRANSACTION_CREATION',
TRANSACTION_RECEIVE = 'TRANSACTION_RECEIVE',
TRANSACTION_RECEIVE_REDEEM = 'TRANSACTION_RECEIVE_REDEEM',
CONTRIBUTION_CREATE = 'CONTRIBUTION_CREATE',
CONTRIBUTION_CONFIRM = 'CONTRIBUTION_CONFIRM',
CONTRIBUTION_LINK_DEFINE = 'CONTRIBUTION_LINK_DEFINE',
CONTRIBUTION_LINK_ACTIVATE_REDEEM = 'CONTRIBUTION_LINK_ACTIVATE_REDEEM',
}

View File

@ -7,18 +7,25 @@ import { User } from './User'
export class Contribution {
constructor(contribution: dbContribution, user: User) {
this.id = contribution.id
this.user = user
this.firstName = user ? user.firstName : null
this.lastName = user ? user.lastName : null
this.amount = contribution.amount
this.memo = contribution.memo
this.createdAt = contribution.createdAt
this.deletedAt = contribution.deletedAt
this.confirmedAt = contribution.confirmedAt
this.confirmedBy = contribution.confirmedBy
this.contributionDate = contribution.contributionDate
}
@Field(() => Number)
id: number
@Field(() => User)
user: User
@Field(() => String, { nullable: true })
firstName: string | null
@Field(() => String, { nullable: true })
lastName: string | null
@Field(() => Decimal)
amount: Decimal
@ -31,13 +38,27 @@ export class Contribution {
@Field(() => Date, { nullable: true })
deletedAt: Date | null
@Field(() => Date, { nullable: true })
confirmedAt: Date | null
@Field(() => Number, { nullable: true })
confirmedBy: number | null
@Field(() => Date)
contributionDate: Date
}
@ObjectType()
export class ContributionListResult {
constructor(count: number, list: Contribution[]) {
this.contributionCount = count
this.contributionList = list
}
@Field(() => Int)
linkCount: number
contributionCount: number
@Field(() => [Contribution])
linkList: Contribution[]
contributionList: Contribution[]
}

View File

@ -54,11 +54,11 @@ import {
updateCreations,
} from './util/creations'
import {
CONTRIBUTIONLINK_MEMO_MAX_CHARS,
CONTRIBUTIONLINK_MEMO_MIN_CHARS,
CONTRIBUTIONLINK_NAME_MAX_CHARS,
CONTRIBUTIONLINK_NAME_MIN_CHARS,
FULL_CREATION_AVAILABLE,
MEMO_MAX_CHARS,
MEMO_MIN_CHARS,
} from './const/const'
// const EMAIL_OPT_IN_REGISTER = 1
@ -595,11 +595,8 @@ export class AdminResolver {
logger.error(`The memo must be initialized!`)
throw new Error(`The memo must be initialized!`)
}
if (
memo.length < CONTRIBUTIONLINK_MEMO_MIN_CHARS ||
memo.length > CONTRIBUTIONLINK_MEMO_MAX_CHARS
) {
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_MEMO_MIN_CHARS} and max=${CONTRIBUTIONLINK_MEMO_MAX_CHARS}`
if (memo.length < MEMO_MIN_CHARS || memo.length > MEMO_MAX_CHARS) {
const msg = `The value of 'memo' with a length of ${memo.length} did not fulfill the requested bounderies min=${MEMO_MIN_CHARS} and max=${MEMO_MAX_CHARS}`
logger.error(`${msg}`)
throw new Error(`${msg}`)
}

View File

@ -4,10 +4,12 @@
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import {
adminUpdateContribution,
confirmContribution,
createContribution,
deleteContribution,
updateContribution,
} from '@/seeds/graphql/mutations'
import { listContributions, login } from '@/seeds/graphql/queries'
import { listAllContributions, listContributions, login } from '@/seeds/graphql/queries'
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
import { GraphQLError } from 'graphql'
import { userFactory } from '@/seeds/factory/user'
@ -64,6 +66,42 @@ describe('ContributionResolver', () => {
})
describe('input not valid', () => {
it('throws error when memo length smaller than 5 chars', async () => {
const date = new Date()
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test',
creationDate: date.toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
}),
)
})
it('throws error when memo length greater than 255 chars', async () => {
const date = new Date()
await expect(
mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test',
creationDate: date.toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
}),
)
})
it('throws error when creationDate not-valid', async () => {
await expect(
mutate({
@ -193,18 +231,21 @@ describe('ContributionResolver', () => {
).resolves.toEqual(
expect.objectContaining({
data: {
listContributions: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
listContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
@ -226,13 +267,16 @@ describe('ContributionResolver', () => {
).resolves.toEqual(
expect.objectContaining({
data: {
listContributions: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
listContributions: {
contributionCount: 1,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
@ -305,6 +349,48 @@ describe('ContributionResolver', () => {
})
})
describe('Memo length smaller than 5 chars', () => {
it('throws error', async () => {
const date = new Date()
await expect(
mutate({
mutation: updateContribution,
variables: {
contributionId: result.data.createContribution.id,
amount: 100.0,
memo: 'Test',
creationDate: date.toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too short (5 characters minimum)')],
}),
)
})
})
describe('Memo length greater than 255 chars', () => {
it('throws error', async () => {
const date = new Date()
await expect(
mutate({
mutation: updateContribution,
variables: {
contributionId: result.data.createContribution.id,
amount: 100.0,
memo: 'Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test Test',
creationDate: date.toString(),
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('memo text is too long (255 characters maximum)')],
}),
)
})
})
describe('wrong user tries to update the contribution', () => {
beforeAll(async () => {
await query({
@ -438,4 +524,212 @@ describe('ContributionResolver', () => {
})
})
})
describe('listAllContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
const bibisCreation = creations.find((creation) => creation.email === 'bibi@bloxberg.de')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await creationFactory(testEnv, bibisCreation!)
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
it('returns allCreation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
})
})
describe('deleteContribution', () => {
describe('unauthenticated', () => {
it('returns an error', async () => {
await expect(
query({
query: deleteContribution,
variables: {
id: -1,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('401 Unauthorized')],
}),
)
})
})
describe('authenticated', () => {
beforeAll(async () => {
await userFactory(testEnv, bibiBloxberg)
await userFactory(testEnv, peterLustig)
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
result = await mutate({
mutation: createContribution,
variables: {
amount: 100.0,
memo: 'Test env contribution',
creationDate: new Date().toString(),
},
})
})
afterAll(async () => {
await cleanDB()
resetToken()
})
describe('wrong contribution id', () => {
it('returns an error', async () => {
await expect(
mutate({
mutation: deleteContribution,
variables: {
id: -1,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Contribution not found for given id.')],
}),
)
})
})
describe('other user sends a deleteContribtuion', () => {
it('returns an error', async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await expect(
mutate({
mutation: deleteContribution,
variables: {
id: result.data.createContribution.id,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Can not delete contribution of another user')],
}),
)
})
})
describe('User deletes own contribution', () => {
it('deletes successfully', async () => {
await expect(
mutate({
mutation: deleteContribution,
variables: {
id: result.data.createContribution.id,
},
}),
).resolves.toBeTruthy()
})
})
describe('User deletes already confirmed contribution', () => {
it('throws an error', async () => {
await query({
query: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
})
await mutate({
mutation: confirmContribution,
variables: {
id: result.data.createContribution.id,
},
})
await query({
query: login,
variables: { email: 'bibi@bloxberg.de', password: 'Aa12345_' },
})
await expect(
mutate({
mutation: deleteContribution,
variables: {
id: result.data.createContribution.id,
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('A confirmed contribution can not be deleted')],
}),
)
})
})
})
})
})

View File

@ -3,14 +3,15 @@ import { Context, getUser } from '@/server/context'
import { backendLogger as logger } from '@/server/logger'
import { Contribution as dbContribution } from '@entity/Contribution'
import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql'
import { FindOperator, IsNull } from '@dbTools/typeorm'
import { FindOperator, IsNull, getConnection } from '@dbTools/typeorm'
import ContributionArgs from '@arg/ContributionArgs'
import Paginated from '@arg/Paginated'
import { Order } from '@enum/Order'
import { Contribution } from '@model/Contribution'
import { Contribution, ContributionListResult } from '@model/Contribution'
import { UnconfirmedContribution } from '@model/UnconfirmedContribution'
import { User } from '@model/User'
import { validateContribution, getUserCreation, updateCreations } from './util/creations'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
@Resolver()
export class ContributionResolver {
@ -20,6 +21,16 @@ export class ContributionResolver {
@Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context,
): Promise<UnconfirmedContribution> {
if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
}
if (memo.length < MEMO_MIN_CHARS) {
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
const user = getUser(context)
const creations = await getUserCreation(user.id)
logger.trace('creations', creations)
@ -38,30 +49,78 @@ export class ContributionResolver {
return new UnconfirmedContribution(contribution, user, creations)
}
@Authorized([RIGHTS.DELETE_CONTRIBUTION])
@Mutation(() => Boolean)
async deleteContribution(
@Arg('id', () => Int) id: number,
@Ctx() context: Context,
): Promise<boolean> {
const user = getUser(context)
const contribution = await dbContribution.findOne(id)
if (!contribution) {
throw new Error('Contribution not found for given id.')
}
if (contribution.userId !== user.id) {
throw new Error('Can not delete contribution of another user')
}
if (contribution.confirmedAt) {
throw new Error('A confirmed contribution can not be deleted')
}
const res = await contribution.softRemove()
return !!res
}
@Authorized([RIGHTS.LIST_CONTRIBUTIONS])
@Query(() => [Contribution])
@Query(() => ContributionListResult)
async listContributions(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Arg('filterConfirmed', () => Boolean)
filterConfirmed: boolean | null,
@Ctx() context: Context,
): Promise<Contribution[]> {
): Promise<ContributionListResult> {
const user = getUser(context)
const where: {
userId: number
confirmedBy?: FindOperator<number> | null
} = { userId: user.id }
if (filterConfirmed) where.confirmedBy = IsNull()
const contributions = await dbContribution.find({
const [contributions, count] = await dbContribution.findAndCount({
where,
order: {
createdAt: order,
},
withDeleted: true,
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
return contributions.map((contribution) => new Contribution(contribution, new User(user)))
return new ContributionListResult(
count,
contributions.map((contribution) => new Contribution(contribution, new User(user))),
)
}
@Authorized([RIGHTS.LIST_ALL_CONTRIBUTIONS])
@Query(() => ContributionListResult)
async listAllContributions(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
): Promise<ContributionListResult> {
const [dbContributions, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(dbContribution, 'c')
.innerJoinAndSelect('c.user', 'u')
.orderBy('c.createdAt', order)
.limit(pageSize)
.offset((currentPage - 1) * pageSize)
.getManyAndCount()
return new ContributionListResult(
count,
dbContributions.map(
(contribution) => new Contribution(contribution, new User(contribution.user)),
),
)
}
@Authorized([RIGHTS.UPDATE_CONTRIBUTION])
@ -72,6 +131,16 @@ export class ContributionResolver {
@Args() { amount, memo, creationDate }: ContributionArgs,
@Ctx() context: Context,
): Promise<UnconfirmedContribution> {
if (memo.length > MEMO_MAX_CHARS) {
logger.error(`memo text is too long: memo.length=${memo.length} > (${MEMO_MAX_CHARS}`)
throw new Error(`memo text is too long (${MEMO_MAX_CHARS} characters maximum)`)
}
if (memo.length < MEMO_MIN_CHARS) {
logger.error(`memo text is too short: memo.length=${memo.length} < (${MEMO_MIN_CHARS}`)
throw new Error(`memo text is too short (${MEMO_MIN_CHARS} characters minimum)`)
}
const user = getUser(context)
const contributionToUpdate = await dbContribution.findOne({

View File

@ -34,9 +34,7 @@ import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualT
import Decimal from 'decimal.js-light'
import { BalanceResolver } from './BalanceResolver'
const MEMO_MAX_CHARS = 255
const MEMO_MIN_CHARS = 5
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
export const executeTransaction = async (
amount: Decimal,

View File

@ -23,6 +23,14 @@ import { sendAccountMultiRegistrationEmail } from '@/mailer/sendAccountMultiRegi
import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import {
Event,
EventLogin,
EventRedeemRegister,
EventRegister,
EventSendConfirmationEmail,
} from '@/event/Event'
import { getUserCreation } from './util/creations'
// eslint-disable-next-line @typescript-eslint/no-var-requires
@ -291,6 +299,9 @@ export class UserResolver {
key: 'token',
value: encode(dbUser.pubKey),
})
const ev = new EventLogin()
ev.userId = user.id
eventProtocol.writeEvent(new Event().setEventLogin(ev))
logger.info('successful Login:' + user)
return user
}
@ -368,6 +379,9 @@ export class UserResolver {
// const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
const emailHash = getEmailHash(email)
const eventRegister = new EventRegister()
const eventRedeemRegister = new EventRedeemRegister()
const eventSendConfirmEmail = new EventSendConfirmationEmail()
const dbUser = new DbUser()
dbUser.email = email
dbUser.firstName = firstName
@ -385,12 +399,14 @@ export class UserResolver {
logger.info('redeemCode found contributionLink=' + contributionLink)
if (contributionLink) {
dbUser.contributionLinkId = contributionLink.id
eventRedeemRegister.contributionId = contributionLink.id
}
} else {
const transactionLink = await dbTransactionLink.findOne({ code: redeemCode })
logger.info('redeemCode found transactionLink=' + transactionLink)
if (transactionLink) {
dbUser.referrerId = transactionLink.userId
eventRedeemRegister.transactionId = transactionLink.id
}
}
}
@ -401,6 +417,7 @@ export class UserResolver {
// loginUser.pubKey = keyPair[0]
// loginUser.privKey = encryptedPrivkey
const event = new Event()
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('READ UNCOMMITTED')
@ -430,6 +447,9 @@ export class UserResolver {
duration: printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME),
})
logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`)
eventSendConfirmEmail.userId = dbUser.id
eventProtocol.writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmEmail))
/* uncomment this, when you need the activation link on the console */
// In case EMails are disabled log the activation link for the user
if (!emailSent) {
@ -446,6 +466,14 @@ export class UserResolver {
}
logger.info('createUser() successful...')
if (redeemCode) {
eventRedeemRegister.userId = dbUser.id
eventProtocol.writeEvent(event.setEventRedeemRegister(eventRedeemRegister))
} else {
eventRegister.userId = dbUser.id
eventProtocol.writeEvent(event.setEventRegister(eventRegister))
}
return new User(dbUser)
}

View File

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

View File

@ -35,12 +35,17 @@ export const creationFactory = async (
if (creation.confirmed) {
await mutate({ mutation: confirmContribution, variables: { id: pendingCreation.id } })
const confirmedCreation = await Contribution.findOneOrFail({ id: pendingCreation.id })
if (creation.moveCreationDate) {
const transaction = await Transaction.findOneOrFail({
where: { userId: user.id, creationDate: new Date(creation.creationDate) },
order: { balanceDate: 'DESC' },
})
if (transaction.decay.equals(0) && transaction.creationDate) {
confirmedCreation.contributionDate = new Date(
nMonthsBefore(transaction.creationDate, creation.moveCreationDate),
)
transaction.creationDate = new Date(
nMonthsBefore(transaction.creationDate, creation.moveCreationDate),
)
@ -48,6 +53,7 @@ export const creationFactory = async (
nMonthsBefore(transaction.balanceDate, creation.moveCreationDate),
)
await transaction.save()
await confirmedCreation.save()
}
}
} else {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,29 +6,30 @@ With the business event protocol the gradido application will capture and persis
The different event types will be defined as Enum. The following list is a first draft and will grow with further event types in the future.
| EventType | Value | Description |
| --------------------------- | ----- | ---------------------------------------------------------------------------------------------------- |
| BasicEvent | 0 | the basic event is the root of all further extending event types |
| VisitGradidoEvent | 10 | if a user visits a gradido page without login or register |
| RegisterEvent | 20 | the user presses the register button |
| RedeemRegisterEvent | 21 | the user presses the register button initiated by the redeem link |
| InActiveAccountEvent | 22 | the systems create an inactive account during the register process |
| SendConfirmEmailEvent | 23 | the system send a confirmation email to the user during the register process |
| ConfirmEmailEvent | 24 | the user confirms his email during the register process |
| RegisterEmailKlickTippEvent | 25 | the system registers the confirmed email at klicktipp |
| LoginEvent | 30 | the user presses the login button |
| RedeemLoginEvent | 31 | the user presses the login button initiated by the redeem link |
| ActivateAccountEvent | 32 | the system activates the users account during the first login process |
| PasswordChangeEvent | 33 | the user changes his password |
| TxSendEvent | 40 | the user creates a transaction and sends it online |
| TxSendRedeemEvent | 41 | the user creates a transaction and sends it per redeem link |
| TxRepeateRedeemEvent | 42 | the user recreates a redeem link of a still open transaction |
| TxCreationEvent | 50 | the user receives a creation transaction for his confirmed contribution |
| TxReceiveEvent | 51 | the user receives a transaction from an other user and posts the amount on his account |
| TxReceiveRedeemEvent | 52 | the user activates the redeem link and receives the transaction and posts the amount on his account |
| ContribCreateEvent | 60 | the user enters his contribution and asks for confirmation |
| ContribConfirmEvent | 61 | the user confirms a contribution of an other user (for future multi confirmation from several users) |
| | | |
| EventType | Value | Description |
| ----------------------------------- | ----- | ------------------------------------------------------------------------------------------------------ |
| BasicEvent | 0 | the basic event is the root of all further extending event types |
| VisitGradidoEvent | 10 | if a user visits a gradido page without login or register |
| RegisterEvent | 20 | the user presses the register button |
| RedeemRegisterEvent | 21 | the user presses the register button initiated by the redeem link |
| InActiveAccountEvent | 22 | the systems create an inactive account during the register process |
| SendConfirmEmailEvent | 23 | the system send a confirmation email to the user during the register process |
| ConfirmEmailEvent | 24 | the user confirms his email during the register process |
| RegisterEmailKlickTippEvent | 25 | the system registers the confirmed email at klicktipp |
| LoginEvent | 30 | the user presses the login button |
| RedeemLoginEvent | 31 | the user presses the login button initiated by the redeem link |
| ActivateAccountEvent | 32 | the system activates the users account during the first login process |
| PasswordChangeEvent | 33 | the user changes his password |
| TransactionSendEvent | 40 | the user creates a transaction and sends it online |
| TransactionSendRedeemEvent | 41 | the user creates a transaction and sends it per redeem link |
| TransactionRepeateRedeemEvent | 42 | the user recreates a redeem link of a still open transaction |
| TransactionCreationEvent | 50 | the user receives a creation transaction for his confirmed contribution |
| TransactionReceiveEvent | 51 | the user receives a transaction from an other user and posts the amount on his account |
| TransactionReceiveRedeemEvent | 52 | the user activates the redeem link and receives the transaction and posts the amount on his account |
| ContributionCreateEvent | 60 | the user enters his contribution and asks for confirmation |
| ContributionConfirmEvent | 61 | the user confirms a contribution of an other user (for future multi confirmation from several users) |
| ContributionLinkDefineEvent | 70 | the admin user defines a contributionLink, which could be send per Link/QR-Code on an other medium |
| ContributionLinkActivateRedeemEvent | 71 | the user activates a received contributionLink to create a contribution entry for the contributionLink |
## EventProtocol - Entity
@ -50,29 +51,30 @@ The business events will be stored in database in the new table `EventProtocol`.
The following table lists for each event type the mandatory attributes, which have to be initialized at event occurence and to be written in the database event protocol table:
| EventType | id | type | createdAt | userID | XuserID | XCommunityID | transactionID | contribID | amount |
| :-------------------------- | :-: | :--: | :-------: | :----: | :-----: | :----------: | :-----------: | :-------: | :----: |
| BasicEvent | x | x | x | | | | | | |
| VisitGradidoEvent | x | x | x | | | | | | |
| RegisterEvent | x | x | x | x | | | | | |
| RedeemRegisterEvent | x | x | x | x | | | | | |
| InActiveAccountEvent | x | x | x | x | | | | | |
| SendConfirmEmailEvent | x | x | x | x | | | | | |
| ConfirmEmailEvent | x | x | x | x | | | | | |
| RegisterEmailKlickTippEvent | x | x | x | x | | | | | |
| LoginEvent | x | x | x | x | | | | | |
| RedeemLoginEvent | x | x | x | x | | | | | |
| ActivateAccountEvent | x | x | x | x | | | | | |
| PasswordChangeEvent | x | x | x | x | | | | | |
| TxSendEvent | x | x | x | x | x | x | x | | x |
| TxSendRedeemEvent | x | x | x | x | x | x | x | | x |
| TxRepeateRedeemEvent | x | x | x | x | x | x | x | | x |
| TxCreationEvent | x | x | x | x | | | x | | x |
| TxReceiveEvent | x | x | x | x | x | x | x | | x |
| TxReceiveRedeemEvent | x | x | x | x | x | x | x | | x |
| ContribCreateEvent | x | x | x | x | | | | x | |
| ContribConfirmEvent | x | x | x | x | x | x | | x | |
| | | | | | | | | | |
| EventType | id | type | createdAt | userID | XuserID | XCommunityID | transactionID | contribID | amount |
| :---------------------------------- | :-: | :--: | :-------: | :----: | :-----: | :----------: | :-----------: | :-------: | :----: |
| BasicEvent | x | x | x | | | | | | |
| VisitGradidoEvent | x | x | x | | | | | | |
| RegisterEvent | x | x | x | x | | | | | |
| RedeemRegisterEvent | x | x | x | x | | | (x) | (x) | |
| InActiveAccountEvent | x | x | x | x | | | | | |
| SendConfirmEmailEvent | x | x | x | x | | | | | |
| ConfirmEmailEvent | x | x | x | x | | | | | |
| RegisterEmailKlickTippEvent | x | x | x | x | | | | | |
| LoginEvent | x | x | x | x | | | | | |
| RedeemLoginEvent | x | x | x | x | | | (x) | (x) | |
| ActivateAccountEvent | x | x | x | x | | | | | |
| PasswordChangeEvent | x | x | x | x | | | | | |
| TransactionSendEvent | x | x | x | x | x | x | x | | x |
| TransactionSendRedeemEvent | x | x | x | x | x | x | x | | x |
| TransactionRepeateRedeemEvent | x | x | x | x | x | x | x | | x |
| TransactionCreationEvent | x | x | x | x | | | x | | x |
| TransactionReceiveEvent | x | x | x | x | x | x | x | | x |
| TransactionReceiveRedeemEvent | x | x | x | x | x | x | x | | x |
| ContributionCreateEvent | x | x | x | x | | | | x | x |
| ContributionConfirmEvent | x | x | x | x | x | x | | x | x |
| ContributionLinkDefineEvent | x | x | x | x | | | | | x |
| ContributionLinkActivateRedeemEvent | x | x | x | x | | | | x | x |
## Event creation

View File

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

View File

@ -3,8 +3,11 @@
<b-input-group v-if="canCopyLink" size="lg" class="mb-3" prepend="Link">
<b-form-input :value="link" type="text" readonly></b-form-input>
<b-input-group-append>
<b-button size="sm" text="Button" variant="primary" @click="CopyLink">
{{ $t('gdd_per_link.copy') }}
<b-button size="sm" text="Button" variant="primary" @click="copyLinkWithText">
{{ $t('gdd_per_link.copy-link-with-text') }}
</b-button>
<b-button size="sm" text="Button" variant="primary" @click="copyLink">
{{ $t('gdd_per_link.copy-link') }}
</b-button>
<b-button variant="primary" class="text-light" @click="$emit('show-qr-code-button')">
<b-img src="img/svg/qr-code.svg" width="19" class="svg"></b-img>
@ -18,29 +21,10 @@
</div>
</template>
<script>
import { copyLinks } from '../mixins/copyLinks'
export default {
name: 'ClipboardCopy',
props: {
link: { type: String, required: true },
},
data() {
return {
canCopyLink: true,
}
},
methods: {
CopyLink() {
navigator.clipboard
.writeText(this.link)
.then(() => {
this.toastSuccess(this.$t('gdd_per_link.link-copied'))
})
.catch(() => {
this.canCopyLink = false
this.toastError(this.$t('gdd_per_link.not-copied'))
})
},
},
mixins: [copyLinks],
}
</script>
<style>

View File

@ -97,24 +97,14 @@ describe('ContentFooter', () => {
)
})
it('has a link to the members area', () => {
expect(wrapper.findAll('a.nav-link').at(2).text()).toEqual('navigation.members_area')
})
it('links to the elopage', () => {
expect(wrapper.findAll('a.nav-link').at(2).attributes('href')).toEqual(
'https://elopage.com/s/gradido/sign_in?locale=en',
)
})
it('links to the whitepaper', () => {
expect(wrapper.findAll('a.nav-link').at(3).attributes('href')).toEqual(
expect(wrapper.findAll('a.nav-link').at(2).attributes('href')).toEqual(
'https://docs.google.com/document/d/1kcX1guOi6tDgnFHD9tf7fB_MneKTx-0nHJxzdN8ygNs/edit?usp=sharing',
)
})
it('links to the support', () => {
expect(wrapper.findAll('a.nav-link').at(4).attributes('href')).toEqual(
expect(wrapper.findAll('a.nav-link').at(3).attributes('href')).toEqual(
'https://gradido.net/en/contact/',
)
})
@ -142,20 +132,14 @@ describe('ContentFooter', () => {
)
})
it('links to the German elopage when locale is de', () => {
expect(wrapper.findAll('a.nav-link').at(2).attributes('href')).toEqual(
'https://elopage.com/s/gradido/sign_in?locale=de',
)
})
it('links to the German whitepaper when locale is de', () => {
expect(wrapper.findAll('a.nav-link').at(3).attributes('href')).toEqual(
expect(wrapper.findAll('a.nav-link').at(2).attributes('href')).toEqual(
'https://docs.google.com/document/d/1jZp-DiiMPI9ZPNXmjsvOQ1BtnfDFfx8BX7CDmA8KKjY/edit?usp=sharing',
)
})
it('links to the German support-page when locale is de', () => {
expect(wrapper.findAll('a.nav-link').at(4).attributes('href')).toEqual(
expect(wrapper.findAll('a.nav-link').at(3).attributes('href')).toEqual(
'https://gradido.net/de/contact/',
)
})

View File

@ -34,12 +34,6 @@
<b-nav-item :href="`https://gradido.net/${$i18n.locale}/datenschutz/`" target="_blank">
{{ $t('footer.privacy_policy') }}
</b-nav-item>
<b-nav-item
:href="`https://elopage.com/s/gradido/sign_in?locale=${$i18n.locale}`"
target="_blank"
>
{{ $t('navigation.members_area') }}
</b-nav-item>
<b-nav-item
:href="
$i18n.locale === 'de'

View File

@ -0,0 +1,114 @@
import { mount } from '@vue/test-utils'
import ContributionForm from './ContributionForm.vue'
const localVue = global.localVue
describe('ContributionForm', () => {
let wrapper
const propsData = {
value: {
id: null,
date: '',
memo: '',
amount: '',
},
}
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$store: {
state: {
creation: ['1000', '1000', '1000'],
},
},
$i18n: {
locale: 'en',
},
}
const Wrapper = () => {
return mount(ContributionForm, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .contribution-form', () => {
expect(wrapper.find('div.contribution-form').exists()).toBe(true)
})
describe('empty form data', () => {
describe('buttons', () => {
it('has reset enabled', () => {
expect(wrapper.find('button[type="reset"]').attributes('disabled')).toBeFalsy()
})
it('has submit disabled', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
})
})
})
describe('set contrubtion', () => {
describe('fill in form data', () => {
const now = new Date().toISOString()
beforeEach(async () => {
await wrapper.setData({
form: {
id: null,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '200',
},
})
})
describe('buttons', () => {
it('has reset enabled', () => {
expect(wrapper.find('button[type="reset"]').attributes('disabled')).toBeFalsy()
})
it('has submit enabled', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeFalsy()
})
})
})
})
describe('update contrubtion', () => {
describe('fill in form data and "id"', () => {
const now = new Date().toISOString()
beforeEach(async () => {
await wrapper.setData({
form: {
id: 2,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '200',
},
})
})
describe('buttons', () => {
it('has reset enabled', () => {
expect(wrapper.find('button[type="reset"]').attributes('disabled')).toBeFalsy()
})
it('has submit enabled', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeFalsy()
})
})
})
})
})
})

View File

@ -0,0 +1,179 @@
<template>
<div class="container contribution-form">
<div class="my-3">
<h3>{{ $t('contribution.formText.yourContribution') }}</h3>
{{ $t('contribution.formText.bringYourTalentsTo') }}
<ul class="my-3">
<li v-html="lastMonthObject"></li>
<li v-html="thisMonthObject"></li>
</ul>
<div class="my-3">
<b>{{ $t('contribution.formText.describeYourCommunity') }}</b>
</div>
</div>
<b-form ref="form" @submit.prevent="submit" class="border p-3">
<label>{{ $t('contribution.selectDate') }}</label>
<b-form-datepicker
id="contribution-date"
v-model="form.date"
size="lg"
:locale="$i18n.locale"
:max="maximalDate"
:min="minimalDate"
class="mb-4"
reset-value=""
:label-no-date-selected="$t('contribution.noDateSelected')"
required
>
<template #nav-prev-year><span></span></template>
<template #nav-next-year><span></span></template>
</b-form-datepicker>
<validation-provider
:rules="{
required: true,
min: minlength,
max: maxlength,
}"
:name="$t('form.message')"
v-slot="{ errors }"
>
<label class="mt-3">{{ $t('contribution.activity') }}</label>
<b-form-textarea
id="contribution-memo"
v-model="form.memo"
rows="3"
max-rows="6"
required
></b-form-textarea>
<b-col v-if="errors">
<span v-for="error in errors" class="errors" :key="error">{{ error }}</span>
</b-col>
</validation-provider>
<label class="mt-3">{{ $t('form.amount') }}</label>
<b-input-group size="lg" prepend="GDD" append=".00">
<b-form-input
id="contribution-amount"
v-model="form.amount"
type="number"
min="1"
:max="isThisMonth ? maxGddThisMonth : maxGddLastMonth"
></b-form-input>
</b-input-group>
<div
v-if="isThisMonth && parseInt(form.amount) > parseInt(maxGddThisMonth)"
class="text-danger text-right"
>
{{ $t('contribution.formText.maxGDDforMonth', { amount: maxGddThisMonth }) }}
</div>
<div
v-if="!isThisMonth && parseInt(form.amount) > parseInt(maxGddLastMonth)"
class="text-danger text-right"
>
{{ $t('contribution.formText.maxGDDforMonth', { amount: maxGddLastMonth }) }}
</div>
<b-row class="mt-3">
<b-col>
<b-button class="test-cancel" type="reset" variant="secondary" @click="reset">
{{ $t('form.cancel') }}
</b-button>
</b-col>
<b-col class="text-right">
<b-button class="test-submit" type="submit" variant="primary" :disabled="disabled">
{{ form.id ? $t('form.change') : $t('contribution.submit') }}
</b-button>
</b-col>
</b-row>
</b-form>
</div>
</template>
<script>
export default {
name: 'ContributionForm',
props: {
value: { type: Object, required: true },
updateAmount: { type: String, required: false },
},
data() {
return {
minlength: 5,
maxlength: 255,
maximalDate: new Date(),
form: this.value, // includes 'id'
}
},
methods: {
submit() {
if (this.form.id) {
this.$emit('update-contribution', this.form)
} else {
this.$emit('set-contribution', this.form)
}
this.reset()
},
reset() {
this.$refs.form.reset()
this.form.id = null
this.form.date = ''
this.form.memo = ''
this.form.amount = ''
},
},
computed: {
/*
* minimalDate() = Sets the date to the 1st of the previous month.
*
*/
minimalDate() {
return new Date(this.maximalDate.getFullYear(), this.maximalDate.getMonth() - 1, 1)
},
disabled() {
if (
this.form.date === '' ||
this.form.memo.length < this.minlength ||
this.form.amount <= 0 ||
this.form.amount > 1000 ||
(this.isThisMonth && parseInt(this.form.amount) > parseInt(this.maxGddThisMonth)) ||
(!this.isThisMonth && parseInt(this.form.amount) > parseInt(this.maxGddLastMonth))
)
return true
return false
},
lastMonthObject() {
// new Date().getMonth === 1 If the current month is January, then one year must be gone back in the previous month
const obj = {
monthAndYear: this.$d(new Date(this.minimalDate), 'monthAndYear'),
creation: this.maxGddLastMonth,
}
return this.$t('contribution.formText.openAmountForMonth', obj)
},
thisMonthObject() {
const obj = {
monthAndYear: this.$d(new Date(), 'monthAndYear'),
creation: this.maxGddThisMonth,
}
return this.$t('contribution.formText.openAmountForMonth', obj)
},
isThisMonth() {
return new Date(this.form.date).getMonth() === new Date().getMonth()
},
maxGddLastMonth() {
// When edited, the amount is added back on top of the amount
return this.form.id && !this.isThisMonth
? parseInt(this.$store.state.creation[1]) + parseInt(this.updateAmount)
: this.$store.state.creation[1]
},
maxGddThisMonth() {
// When edited, the amount is added back on top of the amount
return this.form.id && this.isThisMonth
? parseInt(this.$store.state.creation[2]) + parseInt(this.updateAmount)
: this.$store.state.creation[2]
},
},
}
</script>
<style>
span.errors {
color: red;
}
</style>

View File

@ -0,0 +1,120 @@
import { mount } from '@vue/test-utils'
import ContributionList from './ContributionList.vue'
const localVue = global.localVue
describe('ContributionList', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {
contributionCount: 3,
showPagination: true,
pageSize: 25,
items: [
{
id: 0,
date: '07/06/2022',
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
amount: '200',
},
{
id: 1,
date: '06/22/2022',
memo: 'Ich habe 30 Stunden Frau Müller beim EInkaufen und im Haushalt geholfen.',
amount: '600',
},
{
id: 2,
date: '05/04/2022',
memo:
'Ich habe 50 Stunden den Nachbarkindern bei ihren Hausaufgaben geholfen und Nachhilfeunterricht gegeben.',
amount: '1000',
},
],
}
const Wrapper = () => {
return mount(ContributionList, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV .contribution-list', () => {
expect(wrapper.find('div.contribution-list').exists()).toBe(true)
})
describe('pagination', () => {
describe('list count smaller than page size', () => {
it('has no pagination buttons', () => {
expect(wrapper.find('ul.pagination').exists()).toBe(false)
})
})
describe('list count greater than page size', () => {
beforeEach(() => {
wrapper.setProps({ contributionCount: 33 })
})
it('has pagination buttons', () => {
expect(wrapper.find('ul.pagination').exists()).toBe(true)
})
})
describe('switch page', () => {
const scrollToMock = jest.fn()
window.scrollTo = scrollToMock
beforeEach(async () => {
await wrapper.setProps({ contributionCount: 33 })
wrapper.findComponent({ name: 'BPagination' }).vm.$emit('input', 2)
})
it('emits update contribution list', () => {
expect(wrapper.emitted('update-list-contributions')).toEqual([
[{ currentPage: 2, pageSize: 25 }],
])
})
it('scrolls to top', () => {
expect(scrollToMock).toBeCalledWith(0, 0)
})
})
})
describe('update contribution', () => {
beforeEach(() => {
wrapper
.findComponent({ name: 'ContributionListItem' })
.vm.$emit('update-contribution-form', 'item')
})
it('emits update contribution form', () => {
expect(wrapper.emitted('update-contribution-form')).toEqual([['item']])
})
})
describe('delete contribution', () => {
beforeEach(() => {
wrapper
.findComponent({ name: 'ContributionListItem' })
.vm.$emit('delete-contribution', { id: 2 })
})
it('emits delete contribution', () => {
expect(wrapper.emitted('delete-contribution')).toEqual([[{ id: 2 }]])
})
})
})
})

View File

@ -0,0 +1,77 @@
<template>
<div class="contribution-list container">
<div class="list-group" v-for="item in items" :key="item.id">
<contribution-list-item
v-bind="item"
@update-contribution-form="updateContributionForm"
@delete-contribution="deleteContribution"
/>
</div>
<b-pagination
v-if="isPaginationVisible"
class="mt-3"
pills
size="lg"
v-model="currentPage"
:per-page="pageSize"
:total-rows="contributionCount"
align="center"
:hide-ellipsis="true"
></b-pagination>
</div>
</template>
<script>
import ContributionListItem from '@/components/Contributions/ContributionListItem.vue'
export default {
name: 'ContributionList',
components: {
ContributionListItem,
},
props: {
items: {
type: Array,
required: true,
},
contributionCount: {
type: Number,
required: true,
},
showPagination: {
type: Boolean,
required: true,
},
pageSize: { type: Number, default: 25 },
},
data() {
return {
currentPage: 1,
}
},
methods: {
updateListContributions() {
this.$emit('update-list-contributions', {
currentPage: this.currentPage,
pageSize: this.pageSize,
})
window.scrollTo(0, 0)
},
updateContributionForm(item) {
this.$emit('update-contribution-form', item)
},
deleteContribution(item) {
this.$emit('delete-contribution', item)
},
},
computed: {
isPaginationVisible() {
return this.showPagination && this.pageSize < this.contributionCount
},
},
watch: {
currentPage() {
this.updateListContributions()
},
},
}
</script>

View File

@ -0,0 +1,145 @@
import { mount } from '@vue/test-utils'
import ContributionListItem from './ContributionListItem.vue'
const localVue = global.localVue
describe('ContributionListItem', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
}
const propsData = {
id: 1,
createdAt: '26/07/2022',
contributionDate: '07/06/2022',
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
amount: '200',
}
const Wrapper = () => {
return mount(ContributionListItem, {
localVue,
mocks,
propsData,
})
}
describe('mount', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = Wrapper()
})
it('has a DIV .contribution-list-item', () => {
expect(wrapper.find('div.contribution-list-item').exists()).toBe(true)
})
describe('contribution type', () => {
it('is pending by default', () => {
expect(wrapper.vm.type).toBe('pending')
})
it('is deleted when deletedAt is present', async () => {
await wrapper.setProps({ deletedAt: new Date().toISOString() })
expect(wrapper.vm.type).toBe('deleted')
})
it('is confirmed when confirmedAt is present', async () => {
await wrapper.setProps({ confirmedAt: new Date().toISOString() })
expect(wrapper.vm.type).toBe('confirmed')
})
})
describe('contribution icon', () => {
it('is bell-fill by default', () => {
expect(wrapper.vm.icon).toBe('bell-fill')
})
it('is x-circle when deletedAt is present', async () => {
await wrapper.setProps({ deletedAt: new Date().toISOString() })
expect(wrapper.vm.icon).toBe('x-circle')
})
it('is check when confirmedAt is present', async () => {
await wrapper.setProps({ confirmedAt: new Date().toISOString() })
expect(wrapper.vm.icon).toBe('check')
})
})
describe('contribution variant', () => {
it('is primary by default', () => {
expect(wrapper.vm.variant).toBe('primary')
})
it('is danger when deletedAt is present', async () => {
await wrapper.setProps({ deletedAt: new Date().toISOString() })
expect(wrapper.vm.variant).toBe('danger')
})
it('is success at when confirmedAt is present', async () => {
await wrapper.setProps({ confirmedAt: new Date().toISOString() })
expect(wrapper.vm.variant).toBe('success')
})
})
describe('date', () => {
it('is equal to createdAt', () => {
expect(wrapper.vm.date).toBe(wrapper.vm.createdAt)
})
})
describe('delete contribution', () => {
let spy
describe('edit contribution', () => {
beforeEach(() => {
wrapper.findAll('div.pointer').at(0).trigger('click')
})
it('emits update contribution form', () => {
expect(wrapper.emitted('update-contribution-form')).toEqual([
[
{
id: 1,
contributionDate: '07/06/2022',
memo: 'Ich habe 10 Stunden die Elbwiesen von Müll befreit.',
amount: '200',
},
],
])
})
})
describe('confirm deletion', () => {
beforeEach(() => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve(true))
wrapper.findAll('div.pointer').at(1).trigger('click')
})
it('opens the modal', () => {
expect(spy).toBeCalledWith('contribution.delete')
})
it('emits delete contribution', () => {
expect(wrapper.emitted('delete-contribution')).toEqual([[{ id: 1 }]])
})
})
describe('cancel deletion', () => {
beforeEach(async () => {
spy = jest.spyOn(wrapper.vm.$bvModal, 'msgBoxConfirm')
spy.mockImplementation(() => Promise.resolve(false))
await wrapper.findAll('div.pointer').at(1).trigger('click')
})
it('does not emit delete contribution', () => {
expect(wrapper.emitted('delete-contribution')).toBeFalsy()
})
})
})
})
})

View File

@ -0,0 +1,114 @@
<template>
<div class="contribution-list-item">
<slot>
<div class="border p-3 w-100 mb-1" :class="`border-${variant}`">
<div class="d-inline-flex">
<div class="mr-2"><b-icon :icon="icon" :variant="variant" class="h2"></b-icon></div>
<div v-if="firstName" class="mr-3">{{ firstName }} {{ lastName }}</div>
<div class="mr-2" :class="type != 'deleted' ? 'font-weight-bold' : ''">
{{ amount | GDD }}
</div>
{{ $t('math.minus') }}
<div class="mx-2">{{ $d(new Date(date), 'short') }}</div>
</div>
<div class="mr-2">
<span>{{ $t('contribution.date') }}</span>
<span>
{{ $d(new Date(contributionDate), 'monthAndYear') }}
</span>
</div>
<div class="mr-2">{{ memo }}</div>
<div v-if="type === 'pending' && !firstName" class="d-flex flex-row-reverse">
<div
class="pointer ml-5"
@click="
$emit('update-contribution-form', {
id: id,
contributionDate: contributionDate,
memo: memo,
amount: amount,
})
"
>
<b-icon icon="pencil" class="h2"></b-icon>
</div>
<div class="pointer" @click="deleteContribution({ id })">
<b-icon icon="trash" class="h2"></b-icon>
</div>
</div>
</div>
</slot>
</div>
</template>
<script>
export default {
name: 'ContributionListItem',
props: {
id: {
type: Number,
},
amount: {
type: String,
},
memo: {
type: String,
},
firstName: {
type: String,
required: false,
},
lastName: {
type: String,
required: false,
},
createdAt: {
type: String,
},
contributionDate: {
type: String,
},
deletedAt: {
type: String,
required: false,
},
confirmedBy: {
type: Number,
required: false,
},
confirmedAt: {
type: String,
required: false,
},
},
computed: {
type() {
if (this.deletedAt) return 'deleted'
if (this.confirmedAt) return 'confirmed'
return 'pending'
},
icon() {
if (this.deletedAt) return 'x-circle'
if (this.confirmedAt) return 'check'
return 'bell-fill'
},
variant() {
if (this.deletedAt) return 'danger'
if (this.confirmedAt) return 'success'
return 'primary'
},
date() {
// if (this.deletedAt) return this.deletedAt
// if (this.confirmedAt) return this.confirmedAt
// return this.contributionDate
return this.createdAt
},
},
methods: {
deleteContribution(item) {
this.$bvModal.msgBoxConfirm(this.$t('contribution.delete')).then(async (value) => {
if (value) this.$emit('delete-contribution', item)
})
},
},
}
</script>

View File

@ -3,7 +3,13 @@
<b-col>
<b-card class="p-0 gradido-custom-background">
<div class="h3 mb-4">{{ $t('gdd_per_link.created') }}</div>
<clipboard-copy :link="link" @show-qr-code-button="showQrCodeButton" />
<clipboard-copy
:link="link"
:amount="amount"
:memo="memo"
:validUntil="validUntil"
@show-qr-code-button="showQrCodeButton"
></clipboard-copy>
<div class="text-center">
<figure-qr-code v-if="showQrcode" :link="link" />
@ -27,10 +33,10 @@ export default {
FigureQrCode,
},
props: {
link: {
type: String,
required: true,
},
link: { type: String, required: true },
amount: { type: String, required: true },
memo: { type: String, required: true },
validUntil: { type: String, required: true },
},
data() {
return {

View File

@ -69,6 +69,7 @@
:per-page="pageSize"
:total-rows="transactionCount"
align="center"
:hide-ellipsis="true"
></b-pagination>
<div v-if="transactionCount <= 0" class="mt-4 text-center">

View File

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

View File

@ -17,7 +17,7 @@ const mocks = {
$t: jest.fn((t) => t),
$store: {
state: {
hasElopage: false,
hasElopage: true,
isAdmin: true,
},
},
@ -39,15 +39,17 @@ describe('Navbar', () => {
expect(wrapper.find('div.component-navbar').exists()).toBeTruthy()
})
describe('navigation Navbar', () => {
describe('navigation Navbar (general elements)', () => {
it('has .navbar-brand in the navbar', () => {
expect(wrapper.find('.navbar-brand').exists()).toBeTruthy()
})
it('has b-navbar-toggle in the navbar', () => {
expect(wrapper.find('.navbar-toggler').exists()).toBeTruthy()
})
it('has ten b-nav-item in the navbar', () => {
expect(wrapper.findAll('.nav-item')).toHaveLength(10)
expect(wrapper.findAll('.nav-item')).toHaveLength(11)
})
it('has first nav-item "amount GDD" in navbar', () => {
@ -57,31 +59,57 @@ describe('Navbar', () => {
it('has first nav-item "navigation.overview" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(3).text()).toEqual('navigation.overview')
})
it('has first nav-item "navigation.send" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(4).text()).toEqual('navigation.send')
})
it('has first nav-item "navigation.transactions" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(5).text()).toEqual('navigation.transactions')
})
it('has first nav-item "navigation.profile" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(6).text()).toEqual('navigation.profile')
it('has first nav-item "navigation.transactions" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(6).text()).toEqual('navigation.community')
})
it('has first nav-item "navigation.profile" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(7).text()).toEqual('navigation.profile')
})
})
describe('navigation Navbar (user has an elopage account)', () => {
it('has a link to the members area', () => {
expect(wrapper.findAll('.nav-item').at(7).text()).toContain('navigation.members_area')
expect(wrapper.findAll('.nav-item').at(7).find('a').attributes('href')).toBe(
expect(wrapper.findAll('.nav-item').at(8).text()).toContain('navigation.members_area')
expect(wrapper.findAll('.nav-item').at(8).find('a').attributes('href')).toBe(
'https://elopage.com',
)
})
it('has first nav-item "navigation.admin_area" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(9).text()).toEqual('navigation.admin_area')
})
it('has first nav-item "navigation.logout" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(10).text()).toEqual('navigation.logout')
})
})
describe('navigation Navbar (user has no elopage account)', () => {
beforeAll(() => {
mocks.$store.state.hasElopage = false
wrapper = Wrapper()
})
it('has first nav-item "navigation.admin_area" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(8).text()).toEqual('navigation.admin_area')
})
it('has first nav-item "navigation.logout" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(9).text()).toEqual('navigation.logout')
})
})
})
describe('check watch visible true', () => {
beforeEach(async () => {
await wrapper.setProps({ visible: true })

View File

@ -52,17 +52,18 @@
<b-icon icon="layout-text-sidebar-reverse" aria-hidden="true"></b-icon>
{{ $t('navigation.transactions') }}
</b-nav-item>
<b-nav-item to="/community" class="mb-3">
<b-icon icon="people" aria-hidden="true"></b-icon>
{{ $t('navigation.community') }}
</b-nav-item>
<b-nav-item to="/profile" class="mb-3">
<b-icon icon="gear" aria-hidden="true"></b-icon>
{{ $t('navigation.profile') }}
</b-nav-item>
<br />
<b-nav-item :href="elopageUri" class="mb-3" target="_blank">
<b-nav-item v-if="$store.state.hasElopage" :href="elopageUri" class="mb-3" target="_blank">
<b-icon icon="link45deg" aria-hidden="true"></b-icon>
{{ $t('navigation.members_area') }}
<b-badge v-if="!$store.state.hasElopage" pill variant="danger">
{{ $t('math.exclaim') }}
</b-badge>
</b-nav-item>
<b-nav-item class="mb-3" v-if="$store.state.isAdmin" @click="$emit('admin')">
<b-icon icon="shield-check" aria-hidden="true"></b-icon>

View File

@ -27,15 +27,12 @@ describe('Sidebar', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the component', () => {
expect(wrapper.find('div#component-sidebar').exists()).toBeTruthy()
})
describe('navigation Navbar', () => {
it('has seven b-nav-item in the navbar', () => {
expect(wrapper.findAll('.nav-item')).toHaveLength(7)
})
describe('navigation Navbar (general elements)', () => {
it('has first nav-item "navigation.overview" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(0).text()).toEqual('navigation.overview')
})
@ -47,16 +44,49 @@ describe('Sidebar', () => {
it('has first nav-item "navigation.transactions" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(2).text()).toEqual('navigation.transactions')
})
it('has first nav-item "navigation.community" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(3).text()).toContain('navigation.community')
})
it('has first nav-item "navigation.profile" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(3).text()).toEqual('navigation.profile')
expect(wrapper.findAll('.nav-item').at(4).text()).toEqual('navigation.profile')
})
})
describe('navigation Navbar (user has an elopage account)', () => {
it('has eight b-nav-item in the navbar', () => {
expect(wrapper.findAll('.nav-item')).toHaveLength(8)
})
it('has a link to the members area', () => {
expect(wrapper.findAll('.nav-item').at(4).text()).toContain('navigation.members_area')
expect(wrapper.findAll('.nav-item').at(4).find('a').attributes('href')).toBe('#')
expect(wrapper.findAll('.nav-item').at(5).text()).toEqual('navigation.members_area')
expect(wrapper.findAll('.nav-item').at(5).find('a').attributes('href')).toBe('#')
})
it('has first nav-item "navigation.admin_area" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(6).text()).toEqual('navigation.admin_area')
})
it('has first nav-item "navigation.logout" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(7).text()).toEqual('navigation.logout')
})
})
describe('navigation Navbar (user has no elopage account)', () => {
beforeAll(() => {
mocks.$store.state.hasElopage = false
wrapper = Wrapper()
})
it('has seven b-nav-item in the navbar', () => {
expect(wrapper.findAll('.nav-item')).toHaveLength(7)
})
it('has first nav-item "navigation.admin_area" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(5).text()).toEqual('navigation.admin_area')
})
it('has first nav-item "navigation.logout" in navbar', () => {
expect(wrapper.findAll('.nav-item').at(6).text()).toEqual('navigation.logout')
})

View File

@ -16,6 +16,10 @@
<b-icon icon="layout-text-sidebar-reverse" aria-hidden="true"></b-icon>
{{ $t('navigation.transactions') }}
</b-nav-item>
<b-nav-item to="/community" class="mb-3">
<b-icon icon="people" aria-hidden="true"></b-icon>
{{ $t('navigation.community') }}
</b-nav-item>
<b-nav-item to="/profile" class="mb-3">
<b-icon icon="gear" aria-hidden="true"></b-icon>
{{ $t('navigation.profile') }}
@ -23,12 +27,14 @@
</b-nav>
<hr />
<b-nav vertical class="w-100">
<b-nav-item class="mb-3" :href="elopageUri" target="_blank">
<b-nav-item
v-if="$store.state.hasElopage"
class="mb-3"
:href="elopageUri"
target="_blank"
>
<b-icon icon="link45deg" aria-hidden="true"></b-icon>
{{ $t('navigation.members_area') }}
<b-badge v-if="!$store.state.hasElopage" pill variant="danger">
{{ $t('math.exclaim') }}
</b-badge>
</b-nav-item>
<b-nav-item class="mb-3" v-if="$store.state.isAdmin" @click="$emit('admin')">
<b-icon icon="shield-check" aria-hidden="true"></b-icon>

View File

@ -62,12 +62,16 @@ describe('SessionLogoutTimeout', () => {
})
})
describe('token is expired', () => {
describe('token is expired for several seconds', () => {
beforeEach(() => {
mocks.$store.state.tokenTime = setTokenTime(-60)
wrapper = Wrapper()
})
it('has value for remaining seconds equal 0', () => {
expect(wrapper.tokenExpiresInSeconds === 0)
})
it('emits logout', () => {
expect(wrapper.emitted('logout')).toBeTruthy()
})

View File

@ -65,7 +65,7 @@ export default {
this.$timer.restart('tokenExpires')
this.$bvModal.show('modalSessionTimeOut')
}
if (this.tokenExpiresInSeconds <= 0) {
if (this.tokenExpiresInSeconds === 0) {
this.$timer.stop('tokenExpires')
this.$emit('logout')
}
@ -90,7 +90,10 @@ export default {
},
computed: {
tokenExpiresInSeconds() {
return Math.floor((new Date(this.$store.state.tokenTime * 1000).getTime() - this.now) / 1000)
const remainingSecs = Math.floor(
(new Date(this.$store.state.tokenTime * 1000).getTime() - this.now) / 1000,
)
return remainingSecs <= 0 ? 0 : remainingSecs
},
},
beforeDestroy() {

View File

@ -18,17 +18,17 @@
<b-icon icon="three-dots-vertical"></b-icon>
</template>
<b-dropdown-item v-if="validLink" class="test-copy-link" @click="copy">
<b-dropdown-item v-if="validLink" class="test-copy-link" @click="copyLink">
<b-icon icon="clipboard"></b-icon>
{{ $t('gdd_per_link.copy') }}
{{ $t('gdd_per_link.copy-link') }}
</b-dropdown-item>
<b-dropdown-item
v-if="validLink"
class="test-copy-text pt-3"
@click="copyLinkWithText()"
@click="copyLinkWithText"
>
<b-icon icon="clipboard-plus"></b-icon>
{{ $t('gdd_per_link.copy-with-text') }}
{{ $t('gdd_per_link.copy-link-with-text') }}
</b-dropdown-item>
<b-dropdown-item
v-if="validLink"
@ -76,9 +76,11 @@ import MemoRow from '../TransactionRows/MemoRow'
import DateRow from '../TransactionRows/DateRow'
import DecayRow from '../TransactionRows/DecayRow'
import FigureQrCode from '@/components/QrCode/FigureQrCode.vue'
import { copyLinks } from '../../mixins/copyLinks'
export default {
name: 'TransactionLink',
mixins: [copyLinks],
components: {
TypeIcon,
AmountAndNameRow,
@ -88,43 +90,10 @@ export default {
FigureQrCode,
},
props: {
amount: { type: String, required: true },
link: { type: String, required: true },
holdAvailableAmount: { type: String, required: true },
id: { type: Number, required: true },
memo: { type: String, required: true },
validUntil: { type: String, required: true },
},
methods: {
copy() {
navigator.clipboard
.writeText(this.link)
.then(() => {
this.toastSuccess(this.$t('gdd_per_link.link-copied'))
})
.catch(() => {
this.$bvModal.show('modalPopoverCopyError' + this.id)
this.toastError(this.$t('gdd_per_link.not-copied'))
})
},
copyLinkWithText() {
navigator.clipboard
.writeText(
`${this.link}
${this.$store.state.firstName} ${this.$t('transaction-link.send_you')} ${this.amount} Gradido.
"${this.memo}"
${this.$t('gdd_per_link.credit-your-gradido')} ${this.$t('gdd_per_link.validUntilDate', {
date: this.$d(new Date(this.validUntil), 'short'),
})}`,
)
.then(() => {
this.toastSuccess(this.$t('gdd_per_link.link-and-text-copied'))
})
.catch(() => {
this.$bvModal.show('modalPopoverCopyError' + this.id)
this.toastError(this.$t('gdd_per_link.not-copied'))
})
},
deleteLink() {
this.$bvModal.msgBoxConfirm(this.$t('gdd_per_link.delete-the-link')).then(async (value) => {
if (value)

View File

@ -74,6 +74,9 @@ export const createTransactionLink = gql`
mutation($amount: Decimal!, $memo: String!) {
createTransactionLink(amount: $amount, memo: $memo) {
link
amount
memo
validUntil
}
}
`
@ -89,3 +92,33 @@ export const redeemTransactionLink = gql`
redeemTransactionLink(code: $code)
}
`
export const createContribution = gql`
mutation($creationDate: String!, $memo: String!, $amount: Decimal!) {
createContribution(creationDate: $creationDate, memo: $memo, amount: $amount) {
amount
memo
}
}
`
export const updateContribution = gql`
mutation($contributionId: Int!, $amount: Decimal!, $memo: String!, $creationDate: String!) {
updateContribution(
contributionId: $contributionId
amount: $amount
memo: $memo
creationDate: $creationDate
) {
id
amount
memo
}
}
`
export const deleteContribution = gql`
mutation($id: Int!) {
deleteContribution(id: $id)
}
`

View File

@ -13,6 +13,7 @@ export const login = gql`
hasElopage
publisherId
isAdmin
creation
}
}
`
@ -30,6 +31,7 @@ export const verifyLogin = gql`
hasElopage
publisherId
isAdmin
creation
}
}
`
@ -160,3 +162,50 @@ export const listTransactionLinks = gql`
}
}
`
export const listContributions = gql`
query(
$currentPage: Int = 1
$pageSize: Int = 25
$order: Order = DESC
$filterConfirmed: Boolean = false
) {
listContributions(
currentPage: $currentPage
pageSize: $pageSize
order: $order
filterConfirmed: $filterConfirmed
) {
contributionCount
contributionList {
id
amount
memo
createdAt
contributionDate
confirmedAt
confirmedBy
deletedAt
}
}
}
`
export const listAllContributions = gql`
query($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) {
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
contributionCount
contributionList {
id
firstName
lastName
amount
memo
createdAt
contributionDate
confirmedAt
confirmedBy
}
}
}
`

View File

@ -75,6 +75,19 @@ const dateTimeFormats = {
hour: 'numeric',
minute: 'numeric',
},
monthShort: {
month: 'short',
},
month: {
month: 'long',
},
year: {
year: 'numeric',
},
monthAndYear: {
month: 'long',
year: 'numeric',
},
},
de: {
short: {
@ -90,6 +103,19 @@ const dateTimeFormats = {
hour: 'numeric',
minute: 'numeric',
},
monthShort: {
month: 'short',
},
month: {
month: 'long',
},
year: {
year: 'numeric',
},
monthAndYear: {
month: 'long',
year: 'numeric',
},
},
}

View File

@ -26,9 +26,37 @@
"community": "Gemeinschaft",
"continue-to-registration": "Weiter zur Registrierung",
"current-community": "Aktuelle Gemeinschaft",
"myContributions": "Meine Beiträge zum Gemeinwohl",
"other-communities": "Weitere Gemeinschaften",
"submitContribution": "Beitrag einreichen",
"switch-to-this-community": "zu dieser Gemeinschaft wechseln"
},
"contribution": {
"activity": "Tätigkeit",
"alert": {
"communityNoteList": "Hier findest du alle eingereichten und bestätigten Beiträge von allen Mitgliedern aus dieser Gemeinschaft.",
"confirm": "bestätigt",
"myContributionNoteList": "Eingereichte Beiträge, die noch nicht bestätigt wurden, kannst du jederzeit bearbeiten oder löschen.",
"myContributionNoteSupport": "Es wird bald an dieser Stelle die Möglichkeit geben das ein Dialog zwischen Moderatoren und dir stattfinden kann. Solltest du jetzt Probleme haben bitte nimm Kontakt mit dem Support auf.",
"pending": "Eingereicht und wartet auf Bestätigung",
"rejected": "abgelehnt"
},
"date": "Beitrag für:",
"delete": "Beitrag löschen! Bist du sicher?",
"deleted": "Der Beitrag wurde gelöscht! Wird aber sichtbar bleiben.",
"formText": {
"bringYourTalentsTo": "Bring dich mit deinen Talenten in die Gemeinschaft ein! Dein freiwilliges Engagement honorieren wir mit 20 GDD pro Stunde bis maximal 1.000 GDD im Monat.",
"describeYourCommunity": "Beschreibe deine Gemeinwohl-Tätigkeit mit Angabe der Stunden und trage einen Betrag von 20 GDD pro Stunde ein! Nach Bestätigung durch einen Moderator wird der Betrag deinem Konto gutgeschrieben.",
"maxGDDforMonth": "Du kannst für den ausgewählten Monat nur noch maximal {amount} GDD einreichen.",
"openAmountForMonth": "Für <b>{monthAndYear}</b> kannst du noch <b>{creation}</b> GDD einreichen.",
"yourContribution": "Dein Beitrag zum Gemeinwohl"
},
"noDateSelected": "Wähle irgendein Datum im Monat",
"selectDate": "Wann war dein Beitrag?",
"submit": "Einreichen",
"submitted": "Der Beitrag wurde eingereicht.",
"updated": "Der Beitrag wurde geändert."
},
"contribution-link": {
"thanksYouWith": "dankt dir mit"
},
@ -78,12 +106,12 @@
"amount": "Betrag",
"at": "am",
"cancel": "Abbrechen",
"change": "Ändern",
"check_now": "Jetzt prüfen",
"close": "Schließen",
"current_balance": "Aktueller Kontostand",
"date": "Datum",
"description": "Beschreibung",
"edit": "Bearbeiten",
"email": "E-Mail",
"firstname": "Vorname",
"from": "Von",
@ -123,8 +151,8 @@
"GDD": "GDD",
"gdd_per_link": {
"choose-amount": "Wähle einen Betrag aus, welchen du per Link versenden möchtest. Du kannst auch noch eine Nachricht eintragen. Beim Klick „Jetzt generieren“ wird ein Link erstellt, den du versenden kannst.",
"copy": "kopieren",
"copy-with-text": "Link und Text kopieren",
"copy-link": "Link kopieren",
"copy-link-with-text": "Link und Text kopieren",
"created": "Der Link wurde erstellt!",
"credit-your-gradido": "Damit die Gradido gutgeschrieben werden können, klicke auf den Link!",
"decay-14-day": "Vergänglichkeit für 14 Tage",
@ -177,7 +205,6 @@
"math": {
"aprox": "~",
"equal": "=",
"exclaim": "!",
"minus": "",
"pipe": "|"
},
@ -193,6 +220,7 @@
},
"navigation": {
"admin_area": "Adminbereich",
"community": "Gemeinschaft",
"logout": "Abmelden",
"members_area": "Mitgliederbereich",
"overview": "Übersicht",
@ -271,6 +299,7 @@
"days": "Tage",
"hours": "Stunden",
"minutes": "Minuten",
"month": "Monat",
"months": "Monate",
"seconds": "Sekunden",
"years": "Jahr"

View File

@ -26,9 +26,37 @@
"community": "Community",
"continue-to-registration": "Continue to registration",
"current-community": "Current community",
"myContributions": "My contributions to the common good",
"other-communities": "Other communities",
"submitContribution": "Submit contribution",
"switch-to-this-community": "Switch to this community"
},
"contribution": {
"activity": "Activity",
"alert": {
"communityNoteList": "Here you will find all submitted and confirmed contributions from all members of this community.",
"confirm": "confirmed",
"myContributionNoteList": "You can edit or delete entries that have not yet been confirmed at any time.",
"myContributionNoteSupport": "Soon there will be the possibility for a dialogue between moderators and you. If you have any problems now, please contact the support.",
"pending": "Submitted and waiting for confirmation",
"rejected": "deleted"
},
"date": "Contribution for:",
"delete": "Delete Contribution! Are you sure?",
"deleted": "The contribution has been deleted! But it will remain visible.",
"formText": {
"bringYourTalentsTo": "Bring your talents to the community! Your voluntary commitment will be rewarded with 20 GDD per hour up to a maximum of 1,000 GDD per month.",
"describeYourCommunity": "Describe your community service activity with hours and enter an amount of 20 GDD per hour! After confirmation by a moderator, the amount will be credited to your account.",
"maxGDDforMonth": "You can only submit a maximum of {amount} GDD for the selected month.",
"openAmountForMonth": "For <b>{monthAndYear}</b>, you can still submit <b>{creation}</b> GDD.",
"yourContribution": "Your contribution to the common good"
},
"noDateSelected": "Choose any date in the month",
"selectDate": "When was your contribution?",
"submit": "Submit",
"submitted": "The contribution was submitted.",
"updated": "The contribution was changed."
},
"contribution-link": {
"thanksYouWith": "thanks you with"
},
@ -78,12 +106,12 @@
"amount": "Amount",
"at": "at",
"cancel": "Cancel",
"change": "Change",
"check_now": "Check now",
"close": "Close",
"current_balance": "Current Balance",
"date": "Date",
"description": "Description",
"edit": "Edit",
"email": "Email",
"firstname": "Firstname",
"from": "from",
@ -123,8 +151,8 @@
"GDD": "GDD",
"gdd_per_link": {
"choose-amount": "Select an amount that you would like to send via link. You can also enter a message. Click 'Generate now' to create a link that you can share.",
"copy": "copy",
"copy-with-text": "Copy link and text",
"copy-link": "Copy link",
"copy-link-with-text": "Copy link and text",
"created": "Link was created!",
"credit-your-gradido": "For the Gradido to be credited, click on the link!",
"decay-14-day": "Decay for 14 days",
@ -177,7 +205,6 @@
"math": {
"aprox": "~",
"equal": "=",
"exclaim": "!",
"minus": "",
"pipe": "|"
},
@ -193,6 +220,7 @@
},
"navigation": {
"admin_area": "Admin Area",
"community": "Community",
"logout": "Logout",
"members_area": "Members area",
"overview": "Overview",
@ -271,6 +299,7 @@
"days": "Days",
"hours": "Hours",
"minutes": "Minutes",
"month": "Month",
"months": "Months",
"seconds": "Seconds",
"years": "Year"

View File

@ -0,0 +1,44 @@
export const copyLinks = {
props: {
link: { type: String, required: true },
amount: { type: String, required: true },
memo: { type: String, required: true },
validUntil: { type: String, required: true },
},
data() {
return {
canCopyLink: true,
}
},
methods: {
copyLink() {
navigator.clipboard
.writeText(this.link)
.then(() => {
this.toastSuccess(this.$t('gdd_per_link.link-copied'))
})
.catch(() => {
this.canCopyLink = false
this.toastError(this.$t('gdd_per_link.not-copied'))
})
},
copyLinkWithText() {
navigator.clipboard
.writeText(
`${this.link}
${this.$store.state.firstName} ${this.$t('transaction-link.send_you')} ${this.amount} Gradido.
"${this.memo}"
${this.$t('gdd_per_link.credit-your-gradido')} ${this.$t('gdd_per_link.validUntilDate', {
date: this.$d(new Date(this.validUntil), 'short'),
})}`,
)
.then(() => {
this.toastSuccess(this.$t('gdd_per_link.link-and-text-copied'))
})
.catch(() => {
this.canCopyLink = false
this.toastError(this.$t('gdd_per_link.not-copied'))
})
},
},
}

View File

@ -0,0 +1,408 @@
import { mount } from '@vue/test-utils'
import Community from './Community'
import { toastErrorSpy, toastSuccessSpy } from '@test/testSetup'
import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations'
import { listContributions, listAllContributions, verifyLogin } from '@/graphql/queries'
const localVue = global.localVue
const mockStoreDispach = jest.fn()
const apolloQueryMock = jest.fn()
const apolloMutationMock = jest.fn()
describe('Community', () => {
let wrapper
const mocks = {
$t: jest.fn((t) => t),
$d: jest.fn((d) => d),
$apollo: {
query: apolloQueryMock,
mutate: apolloMutationMock,
},
$store: {
dispatch: mockStoreDispach,
state: {
creation: ['1000', '1000', '1000'],
},
},
$i18n: {
locale: 'en',
},
}
const Wrapper = () => {
return mount(Community, {
localVue,
mocks,
})
}
describe('mount', () => {
beforeEach(() => {
apolloQueryMock.mockResolvedValue({
data: {
listContributions: {
contributionList: [
{
id: 1555,
amount: '200',
memo: 'Fleisig, fleisig am Arbeiten mein lieber Freund, 50 Zeichen sind viel',
createdAt: '2022-07-15T08:47:06.000Z',
deletedAt: null,
confirmedBy: null,
confirmedAt: null,
},
],
contributionCount: 1,
},
listAllContributions: {
contributionList: [
{
id: 1555,
amount: '200',
memo: 'Fleisig, fleisig am Arbeiten mein lieber Freund, 50 Zeichen sind viel',
createdAt: '2022-07-15T08:47:06.000Z',
deletedAt: null,
confirmedBy: null,
confirmedAt: null,
},
{
id: 1556,
amount: '400',
memo: 'Ein anderer lieber Freund ist auch sehr felißig am Arbeiten!!!!',
createdAt: '2022-07-16T08:47:06.000Z',
deletedAt: null,
confirmedBy: null,
confirmedAt: null,
},
],
contributionCount: 2,
},
},
})
wrapper = Wrapper()
})
it('has a DIV .community-page', () => {
expect(wrapper.find('div.community-page').exists()).toBe(true)
})
describe('tabs', () => {
it('has three tabs', () => {
expect(wrapper.findAll('div[role="tabpanel"]')).toHaveLength(3)
})
it('has first tab active by default', () => {
expect(wrapper.findAll('div[role="tabpanel"]').at(0).classes('active')).toBe(true)
})
})
describe('API calls after creation', () => {
it('emits update transactions', () => {
expect(wrapper.emitted('update-transactions')).toEqual([[0]])
})
it('queries list of own contributions', () => {
expect(apolloQueryMock).toBeCalledWith({
fetchPolicy: 'no-cache',
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
})
})
it('queries list of all contributions', () => {
expect(apolloQueryMock).toBeCalledWith({
fetchPolicy: 'no-cache',
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
})
})
describe('server response is error', () => {
beforeEach(() => {
jest.clearAllMocks()
apolloQueryMock.mockRejectedValue({ message: 'Ups' })
wrapper = Wrapper()
})
it('toasts two errors', () => {
expect(toastErrorSpy).toBeCalledTimes(2)
expect(toastErrorSpy).toBeCalledWith('Ups')
})
})
})
describe('set contrubtion', () => {
describe('with success', () => {
const now = new Date().toISOString()
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockResolvedValue({
data: {
createContribution: true,
},
})
await wrapper.setData({
form: {
id: null,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '200',
},
})
await wrapper.find('form').trigger('submit')
})
it('calls the create contribution mutation', () => {
expect(apolloMutationMock).toBeCalledWith({
fetchPolicy: 'no-cache',
mutation: createContribution,
variables: {
creationDate: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '200',
},
})
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('contribution.submitted')
})
it('updates the contribution list', () => {
expect(apolloQueryMock).toBeCalledWith({
fetchPolicy: 'no-cache',
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
})
})
it('verifies the login (to get the new creations available)', () => {
expect(apolloQueryMock).toBeCalledWith({
query: verifyLogin,
fetchPolicy: 'network-only',
})
})
it('set all data to the default values)', () => {
expect(wrapper.vm.form.id).toBe(null)
expect(wrapper.vm.form.date).toBe('')
expect(wrapper.vm.form.memo).toBe('')
expect(wrapper.vm.form.amount).toBe('')
})
})
describe('with error', () => {
const now = new Date().toISOString()
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({
message: 'Ouch!',
})
await wrapper.setData({
form: {
id: null,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '200',
},
})
await wrapper.find('form').trigger('submit')
})
it('toasts the error message', () => {
expect(toastErrorSpy).toBeCalledWith('Ouch!')
})
})
})
describe('update contrubtion', () => {
describe('with success', () => {
const now = new Date().toISOString()
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockResolvedValue({
data: {
updateContribution: true,
},
})
await wrapper
.findComponent({ name: 'ContributionForm' })
.vm.$emit('update-contribution', {
id: 2,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '400',
})
})
it('calls the update contribution mutation', () => {
expect(apolloMutationMock).toBeCalledWith({
fetchPolicy: 'no-cache',
mutation: updateContribution,
variables: {
contributionId: 2,
creationDate: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '400',
},
})
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('contribution.updated')
})
it('updates the contribution list', () => {
expect(apolloQueryMock).toBeCalledWith({
fetchPolicy: 'no-cache',
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
})
})
it('verifies the login (to get the new creations available)', () => {
expect(apolloQueryMock).toBeCalledWith({
query: verifyLogin,
fetchPolicy: 'network-only',
})
})
it('set all data to the default values)', () => {
expect(wrapper.vm.form.id).toBe(null)
expect(wrapper.vm.form.date).toBe('')
expect(wrapper.vm.form.memo).toBe('')
expect(wrapper.vm.form.amount).toBe('')
})
})
describe('with error', () => {
const now = new Date().toISOString()
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({
message: 'Oh No!',
})
await wrapper
.findComponent({ name: 'ContributionForm' })
.vm.$emit('update-contribution', {
id: 2,
date: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '400',
})
})
it('toasts the error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh No!')
})
})
})
describe('delete contribution', () => {
let contributionListComponent
beforeEach(async () => {
await wrapper.setData({ tabIndex: 1 })
contributionListComponent = await wrapper.findComponent({ name: 'ContributionList' })
})
describe('with success', () => {
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockResolvedValue({
data: {
deleteContribution: true,
},
})
contributionListComponent.vm.$emit('delete-contribution', { id: 2 })
})
it('calls the API', () => {
expect(apolloMutationMock).toBeCalledWith({
fetchPolicy: 'no-cache',
mutation: deleteContribution,
variables: {
id: 2,
},
})
})
it('toasts a success message', () => {
expect(toastSuccessSpy).toBeCalledWith('contribution.deleted')
})
it('updates the contribution list', () => {
expect(apolloQueryMock).toBeCalledWith({
fetchPolicy: 'no-cache',
query: listContributions,
variables: {
currentPage: 1,
pageSize: 25,
},
})
})
it('verifies the login (to get the new creations available)', () => {
expect(apolloQueryMock).toBeCalledWith({
query: verifyLogin,
fetchPolicy: 'network-only',
})
})
})
describe('with error', () => {
beforeEach(async () => {
jest.clearAllMocks()
apolloMutationMock.mockRejectedValue({
message: 'Oh my god!',
})
contributionListComponent.vm.$emit('delete-contribution', { id: 2 })
})
it('toasts the error message', () => {
expect(toastErrorSpy).toBeCalledWith('Oh my god!')
})
})
})
describe('update contribution form', () => {
const now = new Date().toISOString()
beforeEach(async () => {
await wrapper.setData({ tabIndex: 1 })
await wrapper
.findComponent({ name: 'ContributionList' })
.vm.$emit('update-contribution-form', {
id: 2,
contributionDate: now,
memo: 'Mein Beitrag zur Gemeinschaft für diesen Monat ...',
amount: '400',
})
})
it('sets the form data to the new values', () => {
expect(wrapper.vm.form.id).toBe(2)
expect(wrapper.vm.form.date).toBe(now)
expect(wrapper.vm.form.memo).toBe('Mein Beitrag zur Gemeinschaft für diesen Monat ...')
expect(wrapper.vm.form.amount).toBe('400')
})
it('sets tab index back to 0', () => {
expect(wrapper.vm.tabIndex).toBe(0)
})
})
})
})

View File

@ -0,0 +1,276 @@
<template>
<div class="community-page">
<div>
<b-tabs v-model="tabIndex" content-class="mt-3" align="center">
<b-tab :title="$t('community.submitContribution')" active>
<contribution-form
@set-contribution="setContribution"
@update-contribution="updateContribution"
v-model="form"
:updateAmount="updateAmount"
/>
</b-tab>
<b-tab :title="$t('community.myContributions')">
<div>
<b-alert show dismissible fade variant="secondary" class="text-dark">
<h4 class="alert-heading">{{ $t('community.myContributions') }}</h4>
<p>
{{ $t('contribution.alert.myContributionNoteList') }}
</p>
<ul class="h2">
<li>
<b-icon icon="bell-fill" variant="primary"></b-icon>
{{ $t('contribution.alert.pending') }}
</li>
<li>
<b-icon icon="check" variant="success"></b-icon>
{{ $t('contribution.alert.confirm') }}
</li>
<li>
<b-icon icon="x-circle" variant="danger"></b-icon>
{{ $t('contribution.alert.rejected') }}
</li>
</ul>
<hr />
<p class="mb-0">
{{ $t('contribution.alert.myContributionNoteSupport') }}
</p>
</b-alert>
</div>
<contribution-list
:items="items"
@update-list-contributions="updateListContributions"
@update-contribution-form="updateContributionForm"
@delete-contribution="deleteContribution"
:contributionCount="contributionCount"
:showPagination="true"
:pageSize="pageSize"
/>
</b-tab>
<b-tab :title="$t('navigation.community')">
<b-alert show dismissible fade variant="secondary" class="text-dark">
<h4 class="alert-heading">{{ $t('navigation.community') }}</h4>
<p>
{{ $t('contribution.alert.communityNoteList') }}
</p>
<ul class="h2">
<li>
<b-icon icon="bell-fill" variant="primary"></b-icon>
{{ $t('contribution.alert.pending') }}
</li>
<li>
<b-icon icon="check" variant="success"></b-icon>
{{ $t('contribution.alert.confirm') }}
</li>
</ul>
</b-alert>
<contribution-list
:items="itemsAll"
@update-list-contributions="updateListAllContributions"
@update-contribution-form="updateContributionForm"
:contributionCount="contributionCountAll"
:showPagination="true"
:pageSize="pageSizeAll"
/>
</b-tab>
</b-tabs>
</div>
</div>
</template>
<script>
import ContributionForm from '@/components/Contributions/ContributionForm.vue'
import ContributionList from '@/components/Contributions/ContributionList.vue'
import { createContribution, updateContribution, deleteContribution } from '@/graphql/mutations'
import { listContributions, listAllContributions, verifyLogin } from '@/graphql/queries'
export default {
name: 'Community',
components: {
ContributionForm,
ContributionList,
},
data() {
return {
tabIndex: 0,
items: [],
itemsAll: [],
currentPage: 1,
pageSize: 25,
pageSizeAll: 25,
contributionCount: 0,
contributionCountAll: 0,
form: {
id: null,
date: '',
memo: '',
amount: '',
},
updateAmount: '',
}
},
methods: {
setContribution(data) {
this.$apollo
.mutate({
fetchPolicy: 'no-cache',
mutation: createContribution,
variables: {
creationDate: data.date,
memo: data.memo,
amount: data.amount,
},
})
.then((result) => {
this.toastSuccess(this.$t('contribution.submitted'))
this.updateListContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateListAllContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.verifyLogin()
})
.catch((err) => {
this.toastError(err.message)
})
},
updateContribution(data) {
this.$apollo
.mutate({
fetchPolicy: 'no-cache',
mutation: updateContribution,
variables: {
contributionId: data.id,
creationDate: data.date,
memo: data.memo,
amount: data.amount,
},
})
.then((result) => {
this.toastSuccess(this.$t('contribution.updated'))
this.updateListContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateListAllContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.verifyLogin()
})
.catch((err) => {
this.toastError(err.message)
})
},
deleteContribution(data) {
this.$apollo
.mutate({
fetchPolicy: 'no-cache',
mutation: deleteContribution,
variables: {
id: data.id,
},
})
.then((result) => {
this.toastSuccess(this.$t('contribution.deleted'))
this.updateListContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateListAllContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.verifyLogin()
})
.catch((err) => {
this.toastError(err.message)
})
},
updateListAllContributions(pagination) {
this.$apollo
.query({
fetchPolicy: 'no-cache',
query: listAllContributions,
variables: {
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
},
})
.then((result) => {
const {
data: { listAllContributions },
} = result
this.contributionCountAll = listAllContributions.contributionCount
this.itemsAll = listAllContributions.contributionList
})
.catch((err) => {
this.toastError(err.message)
})
},
updateListContributions(pagination) {
this.$apollo
.query({
fetchPolicy: 'no-cache',
query: listContributions,
variables: {
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
},
})
.then((result) => {
const {
data: { listContributions },
} = result
this.contributionCount = listContributions.contributionCount
this.items = listContributions.contributionList
})
.catch((err) => {
this.toastError(err.message)
})
},
verifyLogin() {
this.$apollo
.query({
query: verifyLogin,
fetchPolicy: 'network-only',
})
.then((result) => {
const {
data: { verifyLogin },
} = result
this.$store.dispatch('login', verifyLogin)
})
.catch(() => {
this.$emit('logout')
})
},
updateContributionForm(item) {
this.form.id = item.id
this.form.date = item.contributionDate
this.form.memo = item.memo
this.form.amount = item.amount
this.updateAmount = item.amount
this.tabIndex = 0
},
updateTransactions(pagination) {
this.$emit('update-transactions', pagination)
},
},
created() {
// verifyLogin is important at this point so that creation is updated on reload if they are deleted in a session in the admin area.
this.verifyLogin()
this.updateListContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateListAllContributions({
currentPage: this.currentPage,
pageSize: this.pageSize,
})
this.updateTransactions(0)
},
}
</script>

View File

@ -25,9 +25,11 @@ describe('Send', () => {
const mocks = {
$t: jest.fn((t) => t),
$n: jest.fn((n) => String(n)),
$d: jest.fn((d) => d),
$store: {
state: {
email: 'sender@example.org',
firstName: 'Testy',
},
},
$apollo: {
@ -160,11 +162,15 @@ describe('Send', () => {
})
describe('transaction form link', () => {
const now = new Date().toISOString()
beforeEach(async () => {
apolloMutationMock.mockResolvedValue({
data: {
createTransactionLink: {
link: 'http://localhost/redeem/0123456789',
amount: '56.78',
memo: 'Make the best of the link!',
validUntil: now,
},
},
})
@ -228,18 +234,64 @@ describe('Send', () => {
navigator.clipboard = navigatorClipboard
})
describe('copy with success', () => {
describe('copy link with success', () => {
beforeEach(async () => {
navigatorClipboardMock.mockResolvedValue()
await wrapper.findAll('button').at(0).trigger('click')
await wrapper.findAll('button').at(1).trigger('click')
})
it('should call clipboard.writeText', () => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
'http://localhost/redeem/0123456789',
)
})
it('toasts success message', () => {
expect(toastSuccessSpy).toBeCalledWith('gdd_per_link.link-copied')
})
})
describe('copy with error', () => {
describe('copy link with error', () => {
beforeEach(async () => {
navigatorClipboardMock.mockRejectedValue()
await wrapper.findAll('button').at(1).trigger('click')
})
it('toasts error message', () => {
expect(toastErrorSpy).toBeCalledWith('gdd_per_link.not-copied')
})
})
})
describe('copy link and text with success', () => {
const navigatorClipboard = navigator.clipboard
beforeAll(() => {
delete navigator.clipboard
navigator.clipboard = { writeText: navigatorClipboardMock }
})
afterAll(() => {
navigator.clipboard = navigatorClipboard
})
describe('copy link and text with success', () => {
beforeEach(async () => {
navigatorClipboardMock.mockResolvedValue()
await wrapper.findAll('button').at(0).trigger('click')
})
it('should call clipboard.writeText', () => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
'http://localhost/redeem/0123456789\n' +
'Testy transaction-link.send_you 56.78 Gradido.\n' +
'"Make the best of the link!"\n' +
'gdd_per_link.credit-your-gradido gdd_per_link.validUntilDate',
)
})
it('toasts success message', () => {
expect(toastSuccessSpy).toBeCalledWith('gdd_per_link.link-and-text-copied')
})
})
describe('copy link and text with error', () => {
beforeEach(async () => {
navigatorClipboardMock.mockRejectedValue()
await wrapper.findAll('button').at(0).trigger('click')
@ -253,7 +305,7 @@ describe('Send', () => {
describe('close button click', () => {
beforeEach(async () => {
await wrapper.findAll('button').at(2).trigger('click')
await wrapper.findAll('button').at(3).trigger('click')
})
it('Shows the TransactionForm', () => {

View File

@ -41,7 +41,13 @@
></transaction-result-send-error>
</template>
<template #transactionResultLink>
<transaction-result-link :link="link" @on-reset="onReset"></transaction-result-link>
<transaction-result-link
:link="link"
:amount="amount"
:memo="memo"
:validUntil="validUntil"
@on-reset="onReset"
></transaction-result-link>
</template>
</gdd-send>
<hr />
@ -144,7 +150,15 @@ export default {
})
.then((result) => {
this.$emit('set-tunneled-email', null)
this.link = result.data.createTransactionLink.link
const {
data: {
createTransactionLink: { link, amount, memo, validUntil },
},
} = result
this.link = link
this.amount = amount
this.memo = memo
this.validUntil = validUntil
this.transactionData = { ...EMPTY_TRANSACTION_DATA }
this.currentTransactionStep = TRANSACTION_STEPS.transactionResultLink
this.updateTransactions({})

View File

@ -109,7 +109,7 @@ export default {
return this.$route.params.code.search(/^CL-/) === 0
},
itemType() {
// link wurde gelöscht: am, von
// link is deleted: at, from
if (this.linkData.deletedAt) {
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
this.redeemedBoxText = this.$t('gdd_per_link.link-deleted', {

View File

@ -50,7 +50,7 @@ describe('router', () => {
})
it('has sixteen routes defined', () => {
expect(routes).toHaveLength(16)
expect(routes).toHaveLength(17)
})
describe('overview', () => {
@ -75,6 +75,17 @@ describe('router', () => {
})
})
describe('community', () => {
it('requires authorization', () => {
expect(routes.find((r) => r.path === '/community').meta.requiresAuth).toBeTruthy()
})
it('loads the "Community" page', async () => {
const component = await routes.find((r) => r.path === '/community').component()
expect(component.default.name).toBe('Community')
})
})
describe('profile', () => {
it('requires authorization', () => {
expect(routes.find((r) => r.path === '/profile').meta.requiresAuth).toBeTruthy()

View File

@ -38,6 +38,13 @@ const routes = [
requiresAuth: true,
},
},
{
path: '/community',
component: () => import('@/pages/Community.vue'),
meta: {
requiresAuth: true,
},
},
{
path: '/login/:code?',
component: () => import('@/pages/Login.vue'),

View File

@ -47,6 +47,9 @@ export const mutations = {
hasElopage: (state, hasElopage) => {
state.hasElopage = hasElopage
},
creation: (state, creation) => {
state.creation = creation
},
}
export const actions = {
@ -60,6 +63,7 @@ export const actions = {
commit('hasElopage', data.hasElopage)
commit('publisherId', data.publisherId)
commit('isAdmin', data.isAdmin)
commit('creation', data.creation)
},
logout: ({ commit, state }) => {
commit('token', null)
@ -71,6 +75,7 @@ export const actions = {
commit('hasElopage', false)
commit('publisherId', null)
commit('isAdmin', false)
commit('creation', null)
localStorage.clear()
},
}
@ -96,6 +101,7 @@ try {
newsletterState: null,
hasElopage: false,
publisherId: null,
creation: null,
},
getters: {},
// Syncronous mutation of the state

View File

@ -30,6 +30,7 @@ const {
publisherId,
isAdmin,
hasElopage,
creation,
} = mutations
const { login, logout } = actions
@ -139,6 +140,14 @@ describe('Vuex store', () => {
expect(state.hasElopage).toBeTruthy()
})
})
describe('creation', () => {
it('sets the state of creation', () => {
const state = { creation: null }
creation(state, true)
expect(state.creation).toEqual(true)
})
})
})
describe('actions', () => {
@ -156,11 +165,12 @@ describe('Vuex store', () => {
hasElopage: false,
publisherId: 1234,
isAdmin: true,
creation: ['1000', '1000', '1000'],
}
it('calls nine commits', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenCalledTimes(8)
expect(commit).toHaveBeenCalledTimes(9)
})
it('commits email', () => {
@ -202,6 +212,11 @@ describe('Vuex store', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(8, 'isAdmin', true)
})
it('commits creation', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(9, 'creation', ['1000', '1000', '1000'])
})
})
describe('logout', () => {
@ -210,7 +225,7 @@ describe('Vuex store', () => {
it('calls nine commits', () => {
logout({ commit, state })
expect(commit).toHaveBeenCalledTimes(8)
expect(commit).toHaveBeenCalledTimes(9)
})
it('commits token', () => {
@ -253,6 +268,11 @@ describe('Vuex store', () => {
expect(commit).toHaveBeenNthCalledWith(8, 'isAdmin', false)
})
it('commits creation', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(9, 'creation', null)
})
// how to get this working?
it.skip('calls localStorage.clear()', () => {
const clearStorageMock = jest.fn()

View File

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