Merge branch 'master' into 2590-To-long-text-has-submit-button-still-enabled

This commit is contained in:
Alexander Friedland 2023-02-10 08:32:49 +01:00 committed by GitHub
commit d1f1a249b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 7332 additions and 529 deletions

98
.github/workflows/test_dht-node.yml vendored Normal file
View File

@ -0,0 +1,98 @@
name: gradido test_dht-node CI
on: push
jobs:
##############################################################################
# JOB: DOCKER BUILD TEST #####################################################
##############################################################################
build:
name: Docker Build Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build `test` image
run: |
docker build --target test -t "gradido/dht-node:test" -f dht-node/Dockerfile .
docker save "gradido/dht-node:test" > /tmp/dht-node.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: docker-dht-node-test
path: /tmp/dht-node.tar
##############################################################################
# JOB: LINT ##################################################################
##############################################################################
lint:
name: Lint
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker Image
uses: actions/download-artifact@v3
with:
name: docker-dht-node-test
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/dht-node.tar
- name: Lint
run: docker run --rm gradido/dht-node:test yarn run lint
##############################################################################
# JOB: UNIT TEST #############################################################
##############################################################################
unit_test:
name: Unit tests
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker Image
uses: actions/download-artifact@v3
with:
name: docker-dht-node-test
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/dht-node.tar
- name: docker-compose mariadb
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
- name: Sleep for 30 seconds
run: sleep 30s
shell: bash
- name: docker-compose database
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
- name: Sleep for 30 seconds
run: sleep 30s
shell: bash
#- name: Unit tests
# run: cd database && yarn && yarn build && cd ../dht-node && yarn && yarn test
- name: Unit tests
run: |
docker run --env NODE_ENV=test --env DB_HOST=mariadb --network gradido_internal-net -v ~/coverage:/app/coverage --rm gradido/dht-node:test yarn run test
cp -r ~/coverage ./coverage
- name: Coverage check
uses: webcraftmedia/coverage-check-action@master
with:
report_name: Coverage dht-node
type: lcov
#result_path: ./dht-node/coverage/lcov.info
result_path: ./coverage/lcov.info
min_coverage: 79
token: ${{ github.token }}

View File

@ -4,8 +4,42 @@ 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.18.0](https://github.com/gradido/gradido/compare/1.17.1...1.18.0)
- refactor(frontend): toast by automatically logged out [`#2681`](https://github.com/gradido/gradido/pull/2681)
- refactor(frontend): change text for gdd_per_link.choose-amount [`#2638`](https://github.com/gradido/gradido/pull/2638)
- fix(backend): emails for deny and delete contribution [`#2688`](https://github.com/gradido/gradido/pull/2688)
- refactor(other): remove config version from `.env.dist` [`#2686`](https://github.com/gradido/gradido/pull/2686)
- refactor(backend): use LogError on contributionMessageResolver [`#2663`](https://github.com/gradido/gradido/pull/2663)
- refactor(backend): get last transaction by only one function [`#2668`](https://github.com/gradido/gradido/pull/2668)
- refactor(other): don't rebuild modul if unit test file has been changed [`#2667`](https://github.com/gradido/gradido/pull/2667)
- refactor(backend): use LogError on contributionLinkResolver [`#2662`](https://github.com/gradido/gradido/pull/2662)
- refactor(backend): remove event protocol config switch [`#2670`](https://github.com/gradido/gradido/pull/2670)
- refactor(backend): event protocol [`#2652`](https://github.com/gradido/gradido/pull/2652)
- refactor(frontend): sidebar becomes smaller when critical phase [`#2649`](https://github.com/gradido/gradido/pull/2649)
- refactor(backend): use LogError on sendEMailTranslated [`#2656`](https://github.com/gradido/gradido/pull/2656)
- refactor(backend): log error class [`#2640`](https://github.com/gradido/gradido/pull/2640)
- feat(backend): add filterState parameter to listAllContributions query [`#2619`](https://github.com/gradido/gradido/pull/2619)
- refactor(frontend): there is no message when a month is fully created [`#2626`](https://github.com/gradido/gradido/pull/2626)
- refactor(frontend): better text alignment on send via link [`#2637`](https://github.com/gradido/gradido/pull/2637)
- refactor(frontend): text changed as indicated in the issues [`#2642`](https://github.com/gradido/gradido/pull/2642)
- refactor(frontend): when you click on create, you will be directed to the form [`#2645`](https://github.com/gradido/gradido/pull/2645)
- feat(backend): federation: separated dht-hub features in new dht-node modul [`#2510`](https://github.com/gradido/gradido/pull/2510)
- refactor(backend): refine assembly of error message in user resolver [`#2636`](https://github.com/gradido/gradido/pull/2636)
- fix(backend): unit tests creations for 31st day [`#2641`](https://github.com/gradido/gradido/pull/2641)
- fix(workflow): properly lint pr - prevent requirement to restart linting [`#2635`](https://github.com/gradido/gradido/pull/2635)
- feat(frontend): 'yes'-button shows which dialog is currently open with a different color [`#2629`](https://github.com/gradido/gradido/pull/2629)
- feat(backend): federation implement multiple apollo graphql endpoints [`#2459`](https://github.com/gradido/gradido/pull/2459)
- refactor(frontend): add legend to all contribution tab, and add tests. [`#2625`](https://github.com/gradido/gradido/pull/2625)
- feat(frontend): unit tests community page [`#2587`](https://github.com/gradido/gradido/pull/2587)
- feat(backend): deny contributions [`#2461`](https://github.com/gradido/gradido/pull/2461)
- refactor(admin): update yarn.lock file of admin. [`#2579`](https://github.com/gradido/gradido/pull/2579)
#### [1.17.1](https://github.com/gradido/gradido/compare/1.17.0...1.17.1)
> 20 January 2023
- chore(release): v1.17.1 [`#2588`](https://github.com/gradido/gradido/pull/2588)
- refactor(frontend): change contribution memo add word-break [`#2583`](https://github.com/gradido/gradido/pull/2583)
- refactor(admin): add text-break on all table memo fields [`#2584`](https://github.com/gradido/gradido/pull/2584)
- fix(frontend): throw proper frontend warning errors [`#2586`](https://github.com/gradido/gradido/pull/2586)

View File

@ -1,5 +1,3 @@
CONFIG_VERSION=v1.2022-03-18
GRAPHQL_URI=http://localhost:4000/graphql
WALLET_AUTH_URL=http://localhost/authenticate?token={token}
WALLET_URL=http://localhost/login

View File

@ -3,7 +3,7 @@
"description": "Administraion Interface for Gradido",
"main": "index.js",
"author": "Moriz Wahl",
"version": "1.17.1",
"version": "1.18.0",
"license": "Apache-2.0",
"private": false,
"scripts": {
@ -86,5 +86,10 @@
"> 1%",
"last 2 versions",
"not ie <= 10"
],
"nodemonConfig": {
"ignore": [
"**/*.spec.js"
]
}
}

View File

@ -1,5 +1,3 @@
CONFIG_VERSION=v14.2022-12-22
# Server
PORT=4000
JWT_SECRET=secret123
@ -55,9 +53,6 @@ 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

@ -54,9 +54,6 @@ EMAIL_CODE_REQUEST_TIME=$EMAIL_CODE_REQUEST_TIME
# Webhook
WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET
# EventProtocol
EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED
# Federation
FEDERATION_DHT_TOPIC=$FEDERATION_DHT_TOPIC
FEDERATION_DHT_SEED=$FEDERATION_DHT_SEED

View File

@ -1,6 +1,6 @@
{
"name": "gradido-backend",
"version": "1.17.1",
"version": "1.18.0",
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/backend",
@ -72,5 +72,10 @@
"ts-node": "^10.0.0",
"tsconfig-paths": "^3.14.0",
"typescript": "^4.3.4"
},
"nodemonConfig": {
"ignore": [
"**/*.test.ts"
]
}
}

View File

@ -17,7 +17,7 @@ const constants = {
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v14.2022-12-22',
EXPECTED: 'v15.2023-02-07',
CURRENT: '',
},
}
@ -99,11 +99,6 @@ 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
@ -139,7 +134,6 @@ const CONFIG = {
...email,
...loginServer,
...webhook,
...eventProtocol,
...federation,
}

View File

@ -4,6 +4,7 @@ import path from 'path'
import { createTransport } from 'nodemailer'
import Email from 'email-templates'
import i18n from 'i18n'
import LogError from '@/server/LogError'
export const sendEmailTranslated = async (params: {
receiver: {
@ -73,8 +74,7 @@ export const sendEmailTranslated = async (params: {
logger.info('Result: ', result)
})
.catch((error: unknown) => {
logger.error('Error sending notification email: ', error)
throw new Error('Error sending notification email!')
throw new LogError('Error sending notification email', error)
})
i18n.setLocale(rememberLocaleToRestore)

View File

@ -10,6 +10,7 @@ import {
sendAccountMultiRegistrationEmail,
sendContributionConfirmedEmail,
sendContributionDeniedEmail,
sendContributionDeletedEmail,
sendResetPasswordEmail,
sendTransactionLinkRedeemedEmail,
sendTransactionReceivedEmail,
@ -438,6 +439,84 @@ describe('sendEmailVariants', () => {
})
})
describe('sendContributionDeletedEmail', () => {
beforeAll(async () => {
result = await sendContributionDeletedEmail({
firstName: 'Peter',
lastName: 'Lustig',
email: 'peter@lustig.de',
language: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
})
})
describe('calls "sendEmailTranslated"', () => {
it('with expected parameters', () => {
expect(sendEmailTranslated).toBeCalledWith({
receiver: {
to: 'Peter Lustig <peter@lustig.de>',
},
template: 'contributionDeleted',
locals: {
firstName: 'Peter',
lastName: 'Lustig',
locale: 'en',
senderFirstName: 'Bibi',
senderLastName: 'Bloxberg',
contributionMemo: 'My contribution.',
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
supportEmail: CONFIG.COMMUNITY_SUPPORT_MAIL,
communityURL: CONFIG.COMMUNITY_URL,
},
})
})
it('has expected result', () => {
expect(result).toMatchObject({
envelope: {
from: 'info@gradido.net',
to: ['peter@lustig.de'],
},
message: expect.any(String),
originalMessage: expect.objectContaining({
to: 'Peter Lustig <peter@lustig.de>',
from: 'Gradido (do not answer) <info@gradido.net>',
attachments: [],
subject: 'Gradido: Your common good contribution was deleted',
html: expect.any(String),
text: expect.stringContaining('GRADIDO: YOUR COMMON GOOD CONTRIBUTION WAS DELETED'),
}),
})
expect(result.originalMessage.html).toContain('<!DOCTYPE html>')
expect(result.originalMessage.html).toContain('<html lang="en">')
expect(result.originalMessage.html).toContain(
'<title>Gradido: Your common good contribution was deleted</title>',
)
expect(result.originalMessage.html).toContain(
'>Gradido: Your common good contribution was deleted</h1>',
)
expect(result.originalMessage.html).toContain('Hello Peter Lustig')
expect(result.originalMessage.html).toContain(
'Your public good contribution “My contribution.” was deleted by Bibi Bloxberg.',
)
expect(result.originalMessage.html).toContain(
'To see your common good contributions and related messages, go to the “Community” menu in your Gradido account and click on the “My contributions to the common good” tab!',
)
expect(result.originalMessage.html).toContain(
`Link to your account: <a href="${CONFIG.EMAIL_LINK_OVERVIEW}">${CONFIG.EMAIL_LINK_OVERVIEW}</a>`,
)
expect(result.originalMessage.html).toContain('Please do not reply to this email!')
expect(result.originalMessage.html).toContain('Kind regards,<br>your Gradido team')
expect(result.originalMessage.html).toContain('—————')
expect(result.originalMessage.html).toContain(
'<div style="position: relative; left: -22px;"><img src="https://gdd.gradido.net/img/brand/green.png" width="200" alt="Gradido-Akademie Logo"></div><br>Gradido-Akademie<br>Institut für Wirtschaftsbionik<br>Pfarrweg 2<br>74653 Künzelsau<br>Deutschland<br><a href="mailto:support@supportmail.com">support@supportmail.com</a><br><a href="http://localhost/">http://localhost/</a>',
)
})
})
})
describe('sendResetPasswordEmail', () => {
beforeAll(async () => {
result = await sendResetPasswordEmail({

View File

@ -103,6 +103,32 @@ export const sendContributionConfirmedEmail = (data: {
})
}
export const sendContributionDeletedEmail = (data: {
firstName: string
lastName: string
email: string
language: string
senderFirstName: string
senderLastName: string
contributionMemo: string
}): Promise<Record<string, unknown> | null> => {
return sendEmailTranslated({
receiver: { to: `${data.firstName} ${data.lastName} <${data.email}>` },
template: 'contributionDeleted',
locals: {
firstName: data.firstName,
lastName: data.lastName,
locale: data.language,
senderFirstName: data.senderFirstName,
senderLastName: data.senderLastName,
contributionMemo: data.contributionMemo,
overviewURL: CONFIG.EMAIL_LINK_OVERVIEW,
supportEmail: CONFIG.COMMUNITY_SUPPORT_MAIL,
communityURL: CONFIG.COMMUNITY_URL,
},
})
}
export const sendContributionDeniedEmail = (data: {
firstName: string
lastName: string

View File

@ -0,0 +1,16 @@
doctype html
html(lang=locale)
head
title= t('emails.contributionDeleted.subject')
body
h1(style='margin-bottom: 24px;')= t('emails.contributionDeleted.subject')
#container.col
include ../hello.pug
p= t('emails.contributionDeleted.commonGoodContributionDeleted', { senderFirstName, senderLastName, contributionMemo })
p= t('emails.contributionDeleted.toSeeContributionsAndMessages')
p
= t('emails.general.linkToYourAccount')
= " "
a(href=overviewURL) #{overviewURL}
p= t('emails.general.pleaseDoNotReply')
include ../greatingFormularImprint.pug

View File

@ -0,0 +1 @@
= t('emails.contributionDeleted.subject')

View File

@ -1,4 +1,3 @@
import { EventProtocol } from '@entity/EventProtocol'
import decimal from 'decimal.js-light'
import { EventProtocolType } from './EventProtocolType'
@ -68,6 +67,7 @@ export class EventTransactionReceiveRedeem extends EventBasicTxX {}
export class EventContributionCreate extends EventBasicCt {}
export class EventAdminContributionCreate extends EventBasicCt {}
export class EventAdminContributionDelete extends EventBasicCt {}
export class EventAdminContributionDeny extends EventBasicCt {}
export class EventAdminContributionUpdate extends EventBasicCt {}
export class EventUserCreateContributionMessage extends EventBasicCtMsg {}
export class EventAdminCreateContributionMessage extends EventBasicCtMsg {}
@ -87,21 +87,6 @@ export class EventDeleteContributionLink extends EventBasicCt {}
export class EventUpdateContributionLink 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()
@ -314,6 +299,13 @@ export class Event {
return this
}
public setEventAdminContributionDeny(ev: EventAdminContributionDeny): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_CONTRIBUTION_DENY
return this
}
public setEventAdminContributionUpdate(ev: EventAdminContributionUpdate): Event {
this.setByBasicCt(ev.userId, ev.contributionId, ev.amount)
this.type = EventProtocolType.ADMIN_CONTRIBUTION_UPDATE

View File

@ -1,41 +1,17 @@
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)})`)
export const writeEvent = async (event: Event): Promise<EventProtocol | null> => {
logger.info('writeEvent', 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()
}
}
dbEvent.xUserId = event.xUserId || null
dbEvent.xCommunityId = event.xCommunityId || null
dbEvent.contributionId = event.contributionId || null
dbEvent.transactionId = event.transactionId || null
dbEvent.amount = event.amount || null
return dbEvent.save()
}
export const eventProtocol = new EventProtocolEmitter()

View File

@ -35,6 +35,7 @@ export enum EventProtocolType {
CONTRIBUTION_UPDATE = 'CONTRIBUTION_UPDATE',
ADMIN_CONTRIBUTION_CREATE = 'ADMIN_CONTRIBUTION_CREATE',
ADMIN_CONTRIBUTION_DELETE = 'ADMIN_CONTRIBUTION_DELETE',
ADMIN_CONTRIBUTION_DENY = 'ADMIN_CONTRIBUTION_DENY',
ADMIN_CONTRIBUTION_UPDATE = 'ADMIN_CONTRIBUTION_UPDATE',
USER_CREATE_CONTRIBUTION_MESSAGE = 'USER_CREATE_CONTRIBUTION_MESSAGE',
ADMIN_CREATE_CONTRIBUTION_MESSAGE = 'ADMIN_CREATE_CONTRIBUTION_MESSAGE',

View File

@ -15,6 +15,8 @@ import { calculateDecay } from '@/util/decay'
import { RIGHTS } from '@/auth/RIGHTS'
import { GdtResolver } from './GdtResolver'
import { getLastTransaction } from './util/getLastTransaction'
@Resolver()
export class BalanceResolver {
@Authorized([RIGHTS.BALANCE])
@ -32,7 +34,7 @@ export class BalanceResolver {
const lastTransaction = context.lastTransaction
? context.lastTransaction
: await dbTransaction.findOne({ userId: user.id }, { order: { id: 'DESC' } })
: await getLastTransaction(user.id)
logger.debug(`lastTransaction=${lastTransaction}`)

View File

@ -246,6 +246,7 @@ describe('Contribution Links', () => {
})
it('returns an error if missing startDate', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionLink,
@ -270,6 +271,7 @@ describe('Contribution Links', () => {
})
it('returns an error if missing endDate', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionLink,
@ -292,6 +294,7 @@ describe('Contribution Links', () => {
})
it('returns an error if endDate is before startDate', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionLink,
@ -316,27 +319,8 @@ describe('Contribution Links', () => {
)
})
it('returns an error if name is an empty string', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
name: '',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('The name must be initialized!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The name must be initialized!')
})
it('returns an error if name is shorter than 5 characters', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionLink,
@ -347,22 +331,17 @@ describe('Contribution Links', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
`The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`,
),
],
errors: [new GraphQLError('The value of name is too short')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'name' with a length of 3 did not fulfill the requested bounderies min=5 and max=100`,
)
expect(logger.error).toBeCalledWith('The value of name is too short', 3)
})
it('returns an error if name is longer than 100 characters', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionLink,
@ -373,42 +352,17 @@ describe('Contribution Links', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
`The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`,
),
],
errors: [new GraphQLError('The value of name is too long')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'name' with a length of 101 did not fulfill the requested bounderies min=5 and max=100`,
)
})
it('returns an error if memo is an empty string', async () => {
await expect(
mutate({
mutation: createContributionLink,
variables: {
...variables,
memo: '',
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('The memo must be initialized!')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The memo must be initialized!')
expect(logger.error).toBeCalledWith('The value of name is too long', 101)
})
it('returns an error if memo is shorter than 5 characters', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionLink,
@ -419,22 +373,17 @@ describe('Contribution Links', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
`The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`,
),
],
errors: [new GraphQLError('The value of memo is too short')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'memo' with a length of 3 did not fulfill the requested bounderies min=5 and max=255`,
)
expect(logger.error).toBeCalledWith('The value of memo is too short', 3)
})
it('returns an error if memo is longer than 255 characters', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionLink,
@ -445,22 +394,17 @@ describe('Contribution Links', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new GraphQLError(
`The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`,
),
],
errors: [new GraphQLError('The value of memo is too long')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
`The value of 'memo' with a length of 256 did not fulfill the requested bounderies min=5 and max=255`,
)
expect(logger.error).toBeCalledWith('The value of memo is too long', 256)
})
it('returns an error if amount is not positive', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionLink,
@ -471,15 +415,13 @@ describe('Contribution Links', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('The amount=0 must be initialized with a positiv value!')],
errors: [new GraphQLError('The amount must be a positiv value')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The amount=0 must be initialized with a positiv value!',
)
expect(logger.error).toBeCalledWith('The amount must be a positiv value', new Decimal(0))
})
})
@ -530,14 +472,14 @@ describe('Contribution Links', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Contribution Link not found to given id.')],
errors: [new GraphQLError('Contribution Link not found')],
}),
)
})
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
expect(logger.error).toBeCalledWith('Contribution Link not found', -1)
})
describe('valid id', () => {
@ -601,13 +543,13 @@ describe('Contribution Links', () => {
mutate({ mutation: deleteContributionLink, variables: { id: -1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Contribution Link not found to given id.')],
errors: [new GraphQLError('Contribution Link not found')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Contribution Link not found to given id: -1')
expect(logger.error).toBeCalledWith('Contribution Link not found', -1)
})
})

View File

@ -20,6 +20,7 @@ import Paginated from '@arg/Paginated'
// TODO: this is a strange construct
import { transactionLinkCode as contributionLinkCode } from './TransactionLinkResolver'
import LogError from '@/server/LogError'
@Resolver()
export class ContributionLinkResolver {
@ -39,35 +40,22 @@ export class ContributionLinkResolver {
}: ContributionLinkArgs,
): Promise<ContributionLink> {
isStartEndDateValid(validFrom, validTo)
if (!name) {
logger.error(`The name must be initialized!`)
throw new Error(`The name must be initialized!`)
if (name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS) {
throw new LogError('The value of name is too short', name.length)
}
if (
name.length < CONTRIBUTIONLINK_NAME_MIN_CHARS ||
name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS
) {
const msg = `The value of 'name' with a length of ${name.length} did not fulfill the requested bounderies min=${CONTRIBUTIONLINK_NAME_MIN_CHARS} and max=${CONTRIBUTIONLINK_NAME_MAX_CHARS}`
logger.error(`${msg}`)
throw new Error(`${msg}`)
if (name.length > CONTRIBUTIONLINK_NAME_MAX_CHARS) {
throw new LogError('The value of name is too long', name.length)
}
if (!memo) {
logger.error(`The memo must be initialized!`)
throw new Error(`The memo must be initialized!`)
if (memo.length < MEMO_MIN_CHARS) {
throw new LogError('The value of memo is too short', memo.length)
}
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}`)
}
if (!amount) {
logger.error(`The amount must be initialized!`)
throw new Error('The amount must be initialized!')
if (memo.length > MEMO_MAX_CHARS) {
throw new LogError('The value of memo is too long', memo.length)
}
if (!new Decimal(amount).isPositive()) {
logger.error(`The amount=${amount} must be initialized with a positiv value!`)
throw new Error(`The amount=${amount} must be initialized with a positiv value!`)
throw new LogError('The amount must be a positiv value', amount)
}
const dbContributionLink = new DbContributionLink()
dbContributionLink.amount = amount
dbContributionLink.name = name
@ -107,8 +95,7 @@ export class ContributionLinkResolver {
async deleteContributionLink(@Arg('id', () => Int) id: number): Promise<Date | null> {
const contributionLink = await DbContributionLink.findOne(id)
if (!contributionLink) {
logger.error(`Contribution Link not found to given id: ${id}`)
throw new Error('Contribution Link not found to given id.')
throw new LogError('Contribution Link not found', id)
}
await contributionLink.softRemove()
logger.debug(`deleteContributionLink successful!`)
@ -134,8 +121,7 @@ export class ContributionLinkResolver {
): Promise<ContributionLink> {
const dbContributionLink = await DbContributionLink.findOne(id)
if (!dbContributionLink) {
logger.error(`Contribution Link not found to given id: ${id}`)
throw new Error('Contribution Link not found to given id.')
throw new LogError('Contribution Link not found', id)
}
dbContributionLink.amount = amount
dbContributionLink.name = name

View File

@ -88,6 +88,7 @@ describe('ContributionMessageResolver', () => {
describe('input not valid', () => {
it('throws error when contribution does not exist', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: adminCreateContributionMessage,
@ -100,14 +101,22 @@ describe('ContributionMessageResolver', () => {
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not successful: Error: Contribution not found',
'ContributionMessage was not sent successfully: Error: Contribution not found',
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'ContributionMessage was not sent successfully: Error: Contribution not found',
new Error('Contribution not found'),
)
})
it('throws error when contribution.userId equals user.id', async () => {
jest.clearAllMocks()
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
@ -132,12 +141,19 @@ describe('ContributionMessageResolver', () => {
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not successful: Error: Admin can not answer on own contribution',
'ContributionMessage was not sent successfully: Error: Admin can not answer on his own contribution',
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'ContributionMessage was not sent successfully: Error: Admin can not answer on his own contribution',
new Error('Admin can not answer on his own contribution'),
)
})
})
describe('valid input', () => {
@ -210,6 +226,7 @@ describe('ContributionMessageResolver', () => {
describe('input not valid', () => {
it('throws error when contribution does not exist', async () => {
jest.clearAllMocks()
await expect(
mutate({
mutation: createContributionMessage,
@ -222,14 +239,22 @@ describe('ContributionMessageResolver', () => {
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not successful: Error: Contribution not found',
'ContributionMessage was not sent successfully: Error: Contribution not found',
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'ContributionMessage was not sent successfully: Error: Contribution not found',
new Error('Contribution not found'),
)
})
it('throws error when other user tries to send createContributionMessage', async () => {
jest.clearAllMocks()
await mutate({
mutation: login,
variables: { email: 'peter@lustig.de', password: 'Aa12345_' },
@ -246,12 +271,19 @@ describe('ContributionMessageResolver', () => {
expect.objectContaining({
errors: [
new GraphQLError(
'ContributionMessage was not successful: Error: Can not send message to contribution of another user',
'ContributionMessage was not sent successfully: Error: Can not send message to contribution of another user',
),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'ContributionMessage was not sent successfully: Error: Can not send message to contribution of another user',
new Error('Can not send message to contribution of another user'),
)
})
})
describe('valid input', () => {

View File

@ -12,10 +12,10 @@ import { ContributionStatus } from '@enum/ContributionStatus'
import { Order } from '@enum/Order'
import Paginated from '@arg/Paginated'
import { backendLogger as logger } from '@/server/logger'
import { RIGHTS } from '@/auth/RIGHTS'
import { Context, getUser } from '@/server/context'
import { sendAddedContributionMessageEmail } from '@/emails/sendEmailVariants'
import LogError from '@/server/LogError'
@Resolver()
export class ContributionMessageResolver {
@ -54,8 +54,7 @@ export class ContributionMessageResolver {
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`ContributionMessage was not successful: ${e}`)
throw new Error(`ContributionMessage was not successful: ${e}`)
throw new LogError(`ContributionMessage was not sent successfully: ${e}`, e)
} finally {
await queryRunner.release()
}
@ -95,9 +94,7 @@ export class ContributionMessageResolver {
@Ctx() context: Context,
): Promise<ContributionMessage> {
const user = getUser(context)
if (!user.emailContact) {
user.emailContact = await UserContact.findOneOrFail({ where: { id: user.emailId } })
}
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ')
@ -108,12 +105,10 @@ export class ContributionMessageResolver {
relations: ['user'],
})
if (!contribution) {
logger.error('Contribution not found')
throw new Error('Contribution not found')
throw new LogError('Contribution not found', contributionId)
}
if (contribution.userId === user.id) {
logger.error('Admin can not answer on own contribution')
throw new Error('Admin can not answer on own contribution')
throw new LogError('Admin can not answer on his own contribution', contributionId)
}
if (!contribution.user.emailContact) {
contribution.user.emailContact = await UserContact.findOneOrFail({
@ -149,8 +144,7 @@ export class ContributionMessageResolver {
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`ContributionMessage was not successful: ${e}`)
throw new Error(`ContributionMessage was not successful: ${e}`)
throw new LogError(`ContributionMessage was not sent successfully: ${e}`, e)
} finally {
await queryRunner.release()
}

View File

@ -22,11 +22,7 @@ import {
listContributions,
listUnconfirmedContributions,
} from '@/seeds/graphql/queries'
import {
// sendAccountActivationEmail,
sendContributionConfirmedEmail,
// sendContributionRejectedEmail,
} from '@/emails/sendEmailVariants'
import { sendContributionConfirmedEmail } from '@/emails/sendEmailVariants'
import {
cleanDB,
resetToken,
@ -45,8 +41,8 @@ import { Transaction as DbTransaction } from '@entity/Transaction'
import { User } from '@entity/User'
import { EventProtocolType } from '@/event/EventProtocolType'
import { logger, i18n as localization } from '@test/testSetup'
import { UserInputError } from 'apollo-server-express'
// mock account activation email to avoid console spam
// mock account activation email to avoid console spam
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
@ -681,7 +677,7 @@ describe('ContributionResolver', () => {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
statusFilter: null,
},
}),
).resolves.toEqual(
@ -718,7 +714,76 @@ describe('ContributionResolver', () => {
resetToken()
})
it('returns allCreation', async () => {
it('throws an error with "NOT_VALID" in statusFilter', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['NOT_VALID'],
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new UserInputError(
'Variable "$statusFilter" got invalid value "NOT_VALID" at "statusFilter[0]"; Value "NOT_VALID" does not exist in "ContributionStatus" enum.',
),
],
}),
)
})
it('throws an error with a null in statusFilter', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: [null],
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new UserInputError(
'Variable "$statusFilter" got invalid value null at "statusFilter[0]"; Expected non-nullable type "ContributionStatus!" not to be null.',
),
],
}),
)
})
it('throws an error with null and "NOT_VALID" in statusFilter', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: [null, 'NOT_VALID'],
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new UserInputError(
'Variable "$statusFilter" got invalid value null at "statusFilter[0]"; Expected non-nullable type "ContributionStatus!" not to be null.',
),
new UserInputError(
'Variable "$statusFilter" got invalid value "NOT_VALID" at "statusFilter[1]"; Value "NOT_VALID" does not exist in "ContributionStatus" enum.',
),
],
}),
)
})
it('returns all contributions without statusFilter', async () => {
await expect(
query({
query: listAllContributions,
@ -726,7 +791,6 @@ describe('ContributionResolver', () => {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
},
}),
).resolves.toEqual(
@ -737,11 +801,301 @@ describe('ContributionResolver', () => {
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all contributions for statusFilter = null', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: null,
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all contributions for statusFilter = []', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: [],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all CONFIRMED contributions', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['CONFIRMED'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 1,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.not.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all PENDING contributions', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['PENDING'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 1,
contributionList: expect.arrayContaining([
expect.not.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all IN_PROGRESS Creation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['IN_PROGRESS'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 0,
contributionList: expect.not.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all DENIED Creation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['DENIED'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 0,
contributionList: expect.not.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all DELETED Creation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['DELETED'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 0,
contributionList: expect.not.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all CONFIRMED and PENDING Creation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['CONFIRMED', 'PENDING'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),

View File

@ -44,16 +44,20 @@ import {
EventContributionConfirm,
EventAdminContributionCreate,
EventAdminContributionDelete,
EventAdminContributionDeny,
EventAdminContributionUpdate,
} from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { writeEvent } from '@/event/EventProtocolEmitter'
import { calculateDecay } from '@/util/decay'
import {
sendContributionConfirmedEmail,
sendContributionDeletedEmail,
sendContributionDeniedEmail,
} from '@/emails/sendEmailVariants'
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { getLastTransaction } from './util/getLastTransaction'
@Resolver()
export class ContributionResolver {
@Authorized([RIGHTS.CREATE_CONTRIBUTION])
@ -97,7 +101,7 @@ export class ContributionResolver {
eventCreateContribution.userId = user.id
eventCreateContribution.amount = amount
eventCreateContribution.contributionId = contribution.id
await eventProtocol.writeEvent(event.setEventContributionCreate(eventCreateContribution))
await writeEvent(event.setEventContributionCreate(eventCreateContribution))
return new UnconfirmedContribution(contribution, user, creations)
}
@ -133,7 +137,7 @@ export class ContributionResolver {
eventDeleteContribution.userId = user.id
eventDeleteContribution.contributionId = contribution.id
eventDeleteContribution.amount = contribution.amount
await eventProtocol.writeEvent(event.setEventContributionDelete(eventDeleteContribution))
await writeEvent(event.setEventContributionDelete(eventDeleteContribution))
const res = await contribution.softRemove()
return !!res
@ -179,12 +183,23 @@ export class ContributionResolver {
async listAllContributions(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Arg('statusFilter', () => [ContributionStatus], { nullable: true })
statusFilter?: ContributionStatus[],
): Promise<ContributionListResult> {
const where: {
contributionStatus?: FindOperator<string> | null
} = {}
if (statusFilter && statusFilter.length) {
where.contributionStatus = In(statusFilter)
}
const [dbContributions, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.innerJoinAndSelect('c.user', 'u')
.where(where)
.orderBy('c.createdAt', order)
.limit(pageSize)
.offset((currentPage - 1) * pageSize)
@ -279,7 +294,7 @@ export class ContributionResolver {
eventUpdateContribution.userId = user.id
eventUpdateContribution.contributionId = contributionId
eventUpdateContribution.amount = amount
await eventProtocol.writeEvent(event.setEventContributionUpdate(eventUpdateContribution))
await writeEvent(event.setEventContributionUpdate(eventUpdateContribution))
return new UnconfirmedContribution(contributionToUpdate, user, creations)
}
@ -346,9 +361,7 @@ export class ContributionResolver {
eventAdminCreateContribution.userId = moderator.id
eventAdminCreateContribution.amount = amount
eventAdminCreateContribution.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionCreate(eventAdminCreateContribution),
)
await writeEvent(event.setEventAdminContributionCreate(eventAdminCreateContribution))
return getUserCreation(emailContact.userId, clientTimezoneOffset)
}
@ -458,9 +471,7 @@ export class ContributionResolver {
eventAdminContributionUpdate.userId = user.id
eventAdminContributionUpdate.amount = amount
eventAdminContributionUpdate.contributionId = contributionToUpdate.id
await eventProtocol.writeEvent(
event.setEventAdminContributionUpdate(eventAdminContributionUpdate),
)
await writeEvent(event.setEventAdminContributionUpdate(eventAdminContributionUpdate))
return result
}
@ -538,10 +549,8 @@ export class ContributionResolver {
eventAdminContributionDelete.userId = contribution.userId
eventAdminContributionDelete.amount = contribution.amount
eventAdminContributionDelete.contributionId = contribution.id
await eventProtocol.writeEvent(
event.setEventAdminContributionDelete(eventAdminContributionDelete),
)
sendContributionDeniedEmail({
await writeEvent(event.setEventAdminContributionDelete(eventAdminContributionDelete))
sendContributionDeletedEmail({
firstName: user.firstName,
lastName: user.lastName,
email: user.emailContact.email,
@ -602,16 +611,11 @@ export class ContributionResolver {
const queryRunner = getConnection().createQueryRunner()
await queryRunner.connect()
await queryRunner.startTransaction('REPEATABLE READ') // 'READ COMMITTED')
try {
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
.select('transaction')
.from(DbTransaction, 'transaction')
.where('transaction.userId = :id', { id: contribution.userId })
.orderBy('transaction.id', 'DESC')
.getOne()
const lastTransaction = await getLastTransaction(contribution.userId)
logger.info('lastTransaction ID', lastTransaction ? lastTransaction.id : 'undefined')
try {
let newBalance = new Decimal(0)
let decay: Decay | null = null
if (lastTransaction) {
@ -668,7 +672,7 @@ export class ContributionResolver {
eventContributionConfirm.userId = user.id
eventContributionConfirm.amount = contribution.amount
eventContributionConfirm.contributionId = contribution.id
await eventProtocol.writeEvent(event.setEventContributionConfirm(eventContributionConfirm))
await writeEvent(event.setEventContributionConfirm(eventContributionConfirm))
} finally {
releaseLock()
}
@ -762,6 +766,13 @@ export class ContributionResolver {
contributionToUpdate.deniedAt = new Date()
const res = await contributionToUpdate.save()
const event = new Event()
const eventAdminContributionDeny = new EventAdminContributionDeny()
eventAdminContributionDeny.userId = contributionToUpdate.userId
eventAdminContributionDeny.amount = contributionToUpdate.amount
eventAdminContributionDeny.contributionId = contributionToUpdate.id
await writeEvent(event.setEventAdminContributionDeny(eventAdminContributionDeny))
sendContributionDeniedEmail({
firstName: user.firstName,
lastName: user.lastName,

View File

@ -75,7 +75,7 @@ describe('EmailOptinCodes', () => {
query({ query: queryOptIn, variables: { optIn: optinCode } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email was sent more than 24 hours ago')],
errors: [new GraphQLError('Email was sent more than 24 hours ago')],
})
})
@ -84,7 +84,7 @@ describe('EmailOptinCodes', () => {
mutate({ mutation: setPassword, variables: { code: optinCode, password: 'Aa12345_' } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email was sent more than 24 hours ago')],
errors: [new GraphQLError('Email was sent more than 24 hours ago')],
})
})
})
@ -96,7 +96,7 @@ describe('EmailOptinCodes', () => {
mutate({ mutation: forgotPassword, variables: { email: 'peter@lustig.de' } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email already sent less than 10 minutes ago')],
errors: [new GraphQLError('Email already sent less than 10 minutes ago')],
})
})

View File

@ -33,6 +33,8 @@ import { executeTransaction } from './TransactionResolver'
import QueryLinkResult from '@union/QueryLinkResult'
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { getLastTransaction } from './util/getLastTransaction'
// TODO: do not export, test it inside the resolver
export const transactionLinkCode = (date: Date): string => {
const time = date.getTime().toString(16)
@ -275,13 +277,7 @@ export class TransactionLinkResolver {
await queryRunner.manager.insert(DbContribution, contribution)
const lastTransaction = await queryRunner.manager
.createQueryBuilder()
.select('transaction')
.from(DbTransaction, 'transaction')
.where('transaction.userId = :id', { id: user.id })
.orderBy('transaction.id', 'DESC')
.getOne()
const lastTransaction = await getLastTransaction(user.id)
let newBalance = new Decimal(0)
let decay: Decay | null = null

View File

@ -89,7 +89,7 @@ describe('send coins', () => {
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`UserContact with email=wrong@email.com does not exists`)
expect(logger.error).toBeCalledWith('No user with this credentials', 'wrong@email.com')
})
describe('deleted recipient', () => {

View File

@ -30,7 +30,7 @@ import {
sendTransactionReceivedEmail,
} from '@/emails/sendEmailVariants'
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { writeEvent } from '@/event/EventProtocolEmitter'
import { BalanceResolver } from './BalanceResolver'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from './const/const'
@ -38,6 +38,8 @@ import { findUserByEmail } from './UserResolver'
import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { getLastTransaction } from './util/getLastTransaction'
export const executeTransaction = async (
amount: Decimal,
memo: string,
@ -144,16 +146,14 @@ export const executeTransaction = async (
eventTransactionSend.xUserId = transactionSend.linkedUserId
eventTransactionSend.transactionId = transactionSend.id
eventTransactionSend.amount = transactionSend.amount.mul(-1)
await eventProtocol.writeEvent(new Event().setEventTransactionSend(eventTransactionSend))
await writeEvent(new Event().setEventTransactionSend(eventTransactionSend))
const eventTransactionReceive = new EventTransactionReceive()
eventTransactionReceive.userId = transactionReceive.userId
eventTransactionReceive.xUserId = transactionReceive.linkedUserId
eventTransactionReceive.transactionId = transactionReceive.id
eventTransactionReceive.amount = transactionReceive.amount
await eventProtocol.writeEvent(
new Event().setEventTransactionReceive(eventTransactionReceive),
)
await writeEvent(new Event().setEventTransactionReceive(eventTransactionReceive))
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`Transaction was not successful: ${e}`)
@ -208,10 +208,7 @@ export class TransactionResolver {
logger.info(`transactionList(user=${user.firstName}.${user.lastName}, ${user.emailId})`)
// find current balance
const lastTransaction = await dbTransaction.findOne(
{ userId: user.id },
{ order: { id: 'DESC' }, relations: ['contribution'] },
)
const lastTransaction = await getLastTransaction(user.id, ['contribution'])
logger.debug(`lastTransaction=${lastTransaction}`)
const balanceResolver = new BalanceResolver()

View File

@ -549,7 +549,9 @@ describe('UserResolver', () => {
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Password entered is lexically invalid')
expect(logger.error).toBeCalledWith(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
})
})
@ -606,9 +608,7 @@ describe('UserResolver', () => {
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'UserContact with email=bibi@bloxberg.de does not exists',
)
expect(logger.error).toBeCalledWith('No user with this credentials', variables.email)
})
})
@ -668,7 +668,112 @@ describe('UserResolver', () => {
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The User has no valid credentials.')
expect(logger.error).toBeCalledWith('No user with this credentials', variables.email)
})
})
describe('user is in database but deleted', () => {
beforeAll(async () => {
jest.clearAllMocks()
await userFactory(testEnv, stephenHawking)
const variables = {
email: stephenHawking.email,
password: 'Aa12345_',
publisherId: 1234,
}
result = await mutate({ mutation: login, variables })
})
afterAll(async () => {
await cleanDB()
})
it('returns an error', () => {
expect(result).toEqual(
expect.objectContaining({
errors: [
new GraphQLError('This user was permanently deleted. Contact support for questions'),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'This user was permanently deleted. Contact support for questions',
expect.objectContaining({
firstName: stephenHawking.firstName,
lastName: stephenHawking.lastName,
}),
)
})
})
describe('user is in database but email not confirmed', () => {
beforeAll(async () => {
jest.clearAllMocks()
await userFactory(testEnv, garrickOllivander)
const variables = {
email: garrickOllivander.email,
password: 'Aa12345_',
publisherId: 1234,
}
result = await mutate({ mutation: login, variables })
})
afterAll(async () => {
await cleanDB()
})
it('returns an error', () => {
expect(result).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The Users email is not validate yet')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The Users email is not validate yet',
expect.objectContaining({
firstName: garrickOllivander.firstName,
lastName: garrickOllivander.lastName,
}),
)
})
})
describe.skip('user is in database but password is not set', () => {
beforeAll(async () => {
jest.clearAllMocks()
// TODO: we need an user without password set
const user = await userFactory(testEnv, bibiBloxberg)
user.password = BigInt(0)
await user.save()
result = await mutate({ mutation: login, variables })
})
afterAll(async () => {
await cleanDB()
})
it('returns an error', () => {
expect(result).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The User has not set a password yet')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The User has not set a password yet',
expect.objectContaining({
firstName: bibiBloxberg.firstName,
lastName: bibiBloxberg.lastName,
}),
)
})
})
})
@ -828,7 +933,7 @@ describe('UserResolver', () => {
expect.objectContaining({
errors: [
new GraphQLError(
`email already sent less than ${printTimeDuration(
`Email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} ago`,
),
@ -870,13 +975,13 @@ describe('UserResolver', () => {
CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('email already sent less than 10 minutes ago')],
errors: [new GraphQLError('Email already sent less than 10 minutes ago')],
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`email already sent less than 10 minutes ago`)
expect(logger.error).toBeCalledWith(`Email already sent less than 10 minutes ago`)
})
})
})
@ -1001,13 +1106,13 @@ describe('UserResolver', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`"not-valid" isn't a valid language`)],
errors: [new GraphQLError('Given language is not a valid language')],
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`"not-valid" isn't a valid language`)
expect(logger.error).toBeCalledWith('Given language is not a valid language', 'not-valid')
})
})
@ -1058,7 +1163,9 @@ describe('UserResolver', () => {
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('newPassword does not fullfil the rules')
expect(logger.error).toBeCalledWith(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
})
})
@ -1116,7 +1223,9 @@ describe('UserResolver', () => {
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The User has no valid credentials.')
expect(logger.error).toBeCalledWith(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
})
})
})
@ -1322,13 +1431,13 @@ describe('UserResolver', () => {
mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
errors: [new GraphQLError('Could not find user with given ID')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
expect(logger.error).toBeCalledWith('Could not find user with given ID', admin.id + 1)
})
})
@ -1379,12 +1488,12 @@ describe('UserResolver', () => {
mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Administrator can not change his own role!')],
errors: [new GraphQLError('Administrator can not change his own role')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Administrator can not change his own role!')
expect(logger.error).toBeCalledWith('Administrator can not change his own role')
})
})
@ -1400,13 +1509,13 @@ describe('UserResolver', () => {
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('User is already admin!')],
errors: [new GraphQLError('User is already admin')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already admin!')
expect(logger.error).toBeCalledWith('User is already admin')
})
})
@ -1421,13 +1530,13 @@ describe('UserResolver', () => {
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('User is already a usual user!')],
errors: [new GraphQLError('User is already an usual user')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already a usual user!')
expect(logger.error).toBeCalledWith('User is already an usual user')
})
})
})
@ -1494,13 +1603,13 @@ describe('UserResolver', () => {
mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
errors: [new GraphQLError('Could not find user with given ID')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
expect(logger.error).toBeCalledWith('Could not find user with given ID', admin.id + 1)
})
})
@ -1511,13 +1620,13 @@ describe('UserResolver', () => {
mutate({ mutation: deleteUser, variables: { userId: admin.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Moderator can not delete his own account!')],
errors: [new GraphQLError('Moderator can not delete his own account')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not delete his own account!')
expect(logger.error).toBeCalledWith('Moderator can not delete his own account')
})
})
@ -1545,13 +1654,13 @@ describe('UserResolver', () => {
mutate({ mutation: deleteUser, variables: { userId: user.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)],
errors: [new GraphQLError('Could not find user with given ID')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`)
expect(logger.error).toBeCalledWith('Could not find user with given ID', user.id)
})
})
})
@ -1617,13 +1726,13 @@ describe('UserResolver', () => {
mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
errors: [new GraphQLError('Could not find user with given ID')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
expect(logger.error).toBeCalledWith('Could not find user with given ID', admin.id + 1)
})
})

View File

@ -48,7 +48,7 @@ import { klicktippNewsletterStateMiddleware } from '@/middleware/klicktippMiddle
import { klicktippSignIn } from '@/apis/KlicktippController'
import { RIGHTS } from '@/auth/RIGHTS'
import { hasElopageBuys } from '@/util/hasElopageBuys'
import { eventProtocol } from '@/event/EventProtocolEmitter'
import { writeEvent } from '@/event/EventProtocolEmitter'
import {
Event,
EventLogin,
@ -63,6 +63,7 @@ import { isValidPassword } from '@/password/EncryptorUtils'
import { FULL_CREATION_AVAILABLE } from './const/const'
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
import LogError from '@/server/LogError'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
@ -134,22 +135,19 @@ export class UserResolver {
email = email.trim().toLowerCase()
const dbUser = await findUserByEmail(email)
if (dbUser.deletedAt) {
logger.error('The User was permanently deleted in database.')
throw new Error('This user was permanently deleted. Contact support for questions.')
throw new LogError('This user was permanently deleted. Contact support for questions', dbUser)
}
if (!dbUser.emailContact.emailChecked) {
logger.error('The Users email is not validate yet.')
throw new Error('User email not validated')
throw new LogError('The Users email is not validate yet', dbUser)
}
// TODO: at least in test this does not work since `dbUser.password = 0` and `BigInto(0) = 0n`
if (dbUser.password === BigInt(0)) {
logger.error('The User has not set a password yet.')
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new Error('User has no password set yet')
throw new LogError('The User has not set a password yet', dbUser)
}
if (!verifyPassword(dbUser, password)) {
logger.error('The User has no valid credentials.')
throw new Error('No user with this credentials')
throw new LogError('No user with this credentials', dbUser)
}
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
@ -181,7 +179,7 @@ export class UserResolver {
})
const ev = new EventLogin()
ev.userId = user.id
eventProtocol.writeEvent(new Event().setEventLogin(ev))
writeEvent(new Event().setEventLogin(ev))
logger.info(`successful Login: ${JSON.stringify(user, null, 2)}`)
return user
}
@ -253,9 +251,7 @@ export class UserResolver {
})
const eventSendAccountMultiRegistrationEmail = new EventSendAccountMultiRegistrationEmail()
eventSendAccountMultiRegistrationEmail.userId = foundUser.id
eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail),
)
writeEvent(event.setEventSendConfirmationEmail(eventSendAccountMultiRegistrationEmail))
logger.info(
`sendAccountMultiRegistrationEmail by ${firstName} ${lastName} to ${foundUser.firstName} ${foundUser.lastName} <${email}>`,
)
@ -309,30 +305,19 @@ export class UserResolver {
await queryRunner.startTransaction('REPEATABLE READ')
try {
dbUser = await queryRunner.manager.save(dbUser).catch((error) => {
logger.error('Error while saving dbUser', error)
throw new Error('error saving user')
throw new LogError('Error while saving dbUser', error)
})
let emailContact = newEmailContact(email, dbUser.id)
emailContact = await queryRunner.manager.save(emailContact).catch((error) => {
logger.error('Error while saving emailContact', error)
throw new Error('error saving email user contact')
throw new LogError('Error while saving user email contact', error)
})
dbUser.emailContact = emailContact
dbUser.emailId = emailContact.id
await queryRunner.manager.save(dbUser).catch((error) => {
logger.error('Error while updating dbUser', error)
throw new Error('error updating user')
throw new LogError('Error while updating dbUser', error)
})
/*
const emailOptIn = newEmailOptIn(dbUser.id)
await queryRunner.manager.save(emailOptIn).catch((error) => {
logger.error('Error while saving emailOptIn', error)
throw new Error('error saving email opt in')
})
*/
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
emailContact.emailVerificationCode.toString(),
@ -349,7 +334,7 @@ export class UserResolver {
})
logger.info(`sendAccountActivationEmail of ${firstName}.${lastName} to ${email}`)
eventSendConfirmEmail.userId = dbUser.id
eventProtocol.writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmEmail))
writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmEmail))
if (!emailSent) {
logger.debug(`Account confirmation link: ${activationLink}`)
@ -358,9 +343,8 @@ export class UserResolver {
await queryRunner.commitTransaction()
logger.addContext('user', dbUser.id)
} catch (e) {
logger.error(`error during create user with ${e}`)
await queryRunner.rollbackTransaction()
throw e
throw new LogError('Error creating user', e)
} finally {
await queryRunner.release()
}
@ -368,10 +352,10 @@ export class UserResolver {
if (redeemCode) {
eventRedeemRegister.userId = dbUser.id
await eventProtocol.writeEvent(event.setEventRedeemRegister(eventRedeemRegister))
await writeEvent(event.setEventRedeemRegister(eventRedeemRegister))
} else {
eventRegister.userId = dbUser.id
await eventProtocol.writeEvent(event.setEventRegister(eventRegister))
await writeEvent(event.setEventRegister(eventRegister))
}
return new User(dbUser)
@ -392,11 +376,9 @@ export class UserResolver {
}
if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) {
const errorMessage = `email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} ago`
logger.error(errorMessage)
throw new Error(errorMessage)
throw new LogError(
`Email already sent less than ${printTimeDuration(CONFIG.EMAIL_CODE_REQUEST_TIME)} ago`,
)
}
user.emailContact.updatedAt = new Date()
@ -404,8 +386,7 @@ export class UserResolver {
user.emailContact.emailVerificationCode = random(64)
user.emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_RESET_PASSWORD
await user.emailContact.save().catch(() => {
logger.error('Unable to save email verification code= ' + user.emailContact)
throw new Error('Unable to save email verification code.')
throw new LogError('Unable to save email verification code', user.emailContact)
})
logger.info(`optInCode for ${email}=${user.emailContact}`)
@ -440,34 +421,23 @@ export class UserResolver {
logger.info(`setPassword(${code}, ***)...`)
// Validate Password
if (!isValidPassword(password)) {
logger.error('Password entered is lexically invalid')
throw new Error(
throw new LogError(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
}
// Load code
/*
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => {
logger.error('Could not login with emailVerificationCode')
throw new Error('Could not login with emailVerificationCode')
})
*/
// load code
const userContact = await DbUserContact.findOneOrFail(
{ emailVerificationCode: code },
{ relations: ['user'] },
).catch(() => {
logger.error('Could not login with emailVerificationCode')
throw new Error('Could not login with emailVerificationCode')
throw new LogError('Could not login with emailVerificationCode')
})
logger.debug('userContact loaded...')
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
throw new LogError(
`Email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
}
logger.debug('EmailVerificationCode is valid...')
@ -493,13 +463,11 @@ export class UserResolver {
try {
// Save user
await queryRunner.manager.save(user).catch((error) => {
logger.error('error saving user: ' + error)
throw new Error('error saving user: ' + error)
throw new LogError('Error saving user', error)
})
// Save userContact
await queryRunner.manager.save(userContact).catch((error) => {
logger.error('error saving userContact: ' + error)
throw new Error('error saving userContact: ' + error)
throw new LogError('Error saving userContact', error)
})
await queryRunner.commitTransaction()
@ -507,11 +475,10 @@ export class UserResolver {
const eventActivateAccount = new EventActivateAccount()
eventActivateAccount.userId = user.id
eventProtocol.writeEvent(event.setEventActivateAccount(eventActivateAccount))
writeEvent(event.setEventActivateAccount(eventActivateAccount))
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error('Error on writing User and UserContact data:' + e)
throw e
throw new LogError('Error on writing User and User Contact data', e)
} finally {
await queryRunner.release()
}
@ -525,7 +492,7 @@ export class UserResolver {
`klicktippSignIn(${userContact.email}, ${user.language}, ${user.firstName}, ${user.lastName})`,
)
} catch (e) {
logger.error('Error subscribe to klicktipp:' + e)
logger.error('Error subscribing to klicktipp', e)
// TODO is this a problem?
// eslint-disable-next-line no-console
/* uncomment this, when you need the activation link on the console
@ -545,11 +512,8 @@ export class UserResolver {
logger.debug(`found optInCode=${userContact}`)
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
throw new LogError(
`Email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
}
logger.info(`queryOptIn(${optIn}) successful...`)
@ -584,8 +548,7 @@ export class UserResolver {
if (language) {
if (!isLanguage(language)) {
logger.error(`"${language}" isn't a valid language`)
throw new Error(`"${language}" isn't a valid language`)
throw new LogError('Given language is not a valid language', language)
}
userEntity.language = language
i18n.setLocale(language)
@ -594,15 +557,13 @@ export class UserResolver {
if (password && passwordNew) {
// Validate Password
if (!isValidPassword(passwordNew)) {
logger.error('newPassword does not fullfil the rules')
throw new Error(
throw new LogError(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
}
if (!verifyPassword(userEntity, password)) {
logger.error(`Old password is invalid`)
throw new Error(`Old password is invalid`)
throw new LogError(`Old password is invalid`)
}
// Save new password hash and newly encrypted private key
@ -625,16 +586,14 @@ export class UserResolver {
try {
await queryRunner.manager.save(userEntity).catch((error) => {
logger.error('error saving user: ' + error)
throw new Error('error saving user: ' + error)
throw new LogError('Error saving user', error)
})
await queryRunner.commitTransaction()
logger.debug('writing User data successful...')
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`error on writing updated user data: ${e}`)
throw e
throw new LogError('Error on writing updated user data', e)
} finally {
await queryRunner.release()
}
@ -761,14 +720,12 @@ export class UserResolver {
const user = await DbUser.findOne({ id: userId })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
throw new LogError('Could not find user with given ID', userId)
}
// administrator user changes own role?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
logger.error('Administrator can not change his own role!')
throw new Error('Administrator can not change his own role!')
throw new LogError('Administrator can not change his own role')
}
// change isAdmin
switch (user.isAdmin) {
@ -776,16 +733,14 @@ export class UserResolver {
if (isAdmin === true) {
user.isAdmin = new Date()
} else {
logger.error('User is already a usual user!')
throw new Error('User is already a usual user!')
throw new LogError('User is already an usual user')
}
break
default:
if (isAdmin === false) {
user.isAdmin = null
} else {
logger.error('User is already admin!')
throw new Error('User is already admin!')
throw new LogError('User is already admin')
}
break
}
@ -803,14 +758,12 @@ export class UserResolver {
const user = await DbUser.findOne({ id: userId })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
throw new LogError('Could not find user with given ID', userId)
}
// moderator user disabled own account?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
logger.error('Moderator can not delete his own account!')
throw new Error('Moderator can not delete his own account!')
throw new LogError('Moderator can not delete his own account')
}
// soft-delete user
await user.softRemove()
@ -823,12 +776,10 @@ export class UserResolver {
async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> {
const user = await DbUser.findOne({ id: userId }, { withDeleted: true })
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
throw new LogError('Could not find user with given ID', userId)
}
if (!user.deletedAt) {
logger.error('User is not deleted')
throw new Error('User is not deleted')
throw new LogError('User is not deleted')
}
await user.recover()
return null
@ -841,17 +792,14 @@ export class UserResolver {
// const user = await dbUser.findOne({ id: emailContact.userId })
const user = await findUserByEmail(email)
if (!user) {
logger.error(`Could not find User to emailContact: ${email}`)
throw new Error(`Could not find User to emailContact: ${email}`)
throw new LogError('Could not find user to given email contact', email)
}
if (user.deletedAt) {
logger.error(`User with emailContact: ${email} is deleted.`)
throw new Error(`User with emailContact: ${email} is deleted.`)
throw new LogError('User with given email contact is deleted', email)
}
const emailContact = user.emailContact
if (emailContact.deletedAt) {
logger.error(`The emailContact: ${email} of this User is deleted.`)
throw new Error(`The emailContact: ${email} of this User is deleted.`)
throw new LogError('The given email contact for this user is deleted', email)
}
emailContact.emailResendCount++
@ -874,9 +822,7 @@ export class UserResolver {
const event = new Event()
const eventSendConfirmationEmail = new EventSendConfirmationEmail()
eventSendConfirmationEmail.userId = user.id
await eventProtocol.writeEvent(
event.setEventSendConfirmationEmail(eventSendConfirmationEmail),
)
await writeEvent(event.setEventSendConfirmationEmail(eventSendConfirmationEmail))
}
return true
@ -888,8 +834,7 @@ export async function findUserByEmail(email: string): Promise<DbUser> {
{ email: email },
{ withDeleted: true, relations: ['user'] },
).catch(() => {
logger.error(`UserContact with email=${email} does not exists`)
throw new Error('No user with this credentials')
throw new LogError('No user with this credentials', email)
})
const dbUser = dbUserContact.user
dbUser.emailContact = dbUserContact
@ -904,31 +849,16 @@ async function checkEmailExists(email: string): Promise<boolean> {
return false
}
/*
const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {
const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime()
// time is given in minutes
return timeElapsed <= duration * 60 * 1000
}
*/
const isTimeExpired = (updatedAt: Date, duration: number): boolean => {
const timeElapsed = Date.now() - new Date(updatedAt).getTime()
// time is given in minutes
return timeElapsed <= duration * 60 * 1000
}
/*
const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
}
*/
const isEmailVerificationCodeValid = (updatedAt: Date): boolean => {
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
}
/*
const canResendOptIn = (optIn: LoginEmailOptIn): boolean => {
return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME)
}
*/
const canEmailResend = (updatedAt: Date): boolean => {
return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME)
}

View File

@ -0,0 +1,14 @@
import { Transaction as DbTransaction } from '@entity/Transaction'
export const getLastTransaction = async (
userId: number,
relations?: string[],
): Promise<DbTransaction | undefined> => {
return DbTransaction.findOne(
{ userId },
{
order: { balanceDate: 'DESC', id: 'DESC' },
relations,
},
)
}

View File

@ -23,8 +23,13 @@
"commonGoodContributionConfirmed": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde soeben von {senderFirstName} {senderLastName} bestätigt und in deinem Gradido-Konto gutgeschrieben.",
"subject": "Gradido: Dein Gemeinwohl-Beitrag wurde bestätigt"
},
"contributionRejected": {
"commonGoodContributionRejected": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde von {senderFirstName} {senderLastName} abgelehnt.",
"contributionDeleted": {
"commonGoodContributionDeleted": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde von {senderFirstName} {senderLastName} gelöscht.",
"subject": "Gradido: Dein Gemeinwohl-Beitrag wurde gelöscht",
"toSeeContributionsAndMessages": "Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü „Gemeinschaft“ auf den Tab „Meine Beiträge zum Gemeinwohl“!"
},
"contributionDenied": {
"commonGoodContributionDenied": "dein Gemeinwohl-Beitrag „{contributionMemo}“ wurde von {senderFirstName} {senderLastName} abgelehnt.",
"subject": "Gradido: Dein Gemeinwohl-Beitrag wurde abgelehnt",
"toSeeContributionsAndMessages": "Um deine Gemeinwohl-Beiträge und dazugehörige Nachrichten zu sehen, gehe in deinem Gradido-Konto ins Menü „Gemeinschaft“ auf den Tab „Meine Beiträge zum Gemeinwohl“!"
},

View File

@ -23,6 +23,11 @@
"commonGoodContributionConfirmed": "Your public good contribution “{contributionMemo}” has just been confirmed by {senderFirstName} {senderLastName} and credited to your Gradido account.",
"subject": "Gradido: Your contribution to the common good was confirmed"
},
"contributionDeleted": {
"commonGoodContributionDeleted": "Your public good contribution “{contributionMemo}” was deleted by {senderFirstName} {senderLastName}.",
"subject": "Gradido: Your common good contribution was deleted",
"toSeeContributionsAndMessages": "To see your common good contributions and related messages, go to the “Community” menu in your Gradido account and click on the “My contributions to the common good” tab!"
},
"contributionDenied": {
"commonGoodContributionDenied": "Your public good contribution “{contributionMemo}” was rejected by {senderFirstName} {senderLastName}.",
"subject": "Gradido: Your common good contribution was rejected",

View File

@ -172,8 +172,8 @@ export const listContributions = gql`
`
export const listAllContributions = `
query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC) {
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC, $statusFilter: [ContributionStatus!]) {
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order, statusFilter: $statusFilter) {
contributionCount
contributionList {
id
@ -184,6 +184,11 @@ query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC) {
createdAt
confirmedAt
confirmedBy
contributionDate
state
messagesCount
deniedAt
deniedBy
}
}
}

View File

@ -0,0 +1,26 @@
import { logger } from '@test/testSetup'
import LogError from './LogError'
describe('LogError', () => {
it('logs an Error when created', () => {
/* eslint-disable-next-line no-new */
new LogError('new LogError')
expect(logger.error).toBeCalledWith('new LogError')
})
it('logs an Error including additional data when created', () => {
/* eslint-disable-next-line no-new */
new LogError('new LogError', { some: 'data' })
expect(logger.error).toBeCalledWith('new LogError', { some: 'data' })
})
it('does not contain additional data in Error object when thrown', () => {
try {
throw new LogError('new LogError', { someWeirdValue123: 'arbitraryData456' })
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
} catch (e: any) {
expect(e.stack).not.toMatch(/(someWeirdValue123|arbitraryData456)/i)
}
})
})

View File

@ -0,0 +1,9 @@
import { backendLogger as logger } from './logger'
export default class LogError extends Error {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(msg: string, ...details: any[]) {
super(msg)
logger.error(msg, ...details)
}
}

View File

@ -1,10 +1,10 @@
import { calculateDecay } from './decay'
import Decimal from 'decimal.js-light'
import { Transaction } from '@entity/Transaction'
import { Decay } from '@model/Decay'
import { getCustomRepository } from '@dbTools/typeorm'
import { TransactionLinkRepository } from '@repository/TransactionLink'
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { getLastTransaction } from '../graphql/resolver/util/getLastTransaction'
function isStringBoolean(value: string): boolean {
const lowerValue = value.toLowerCase()
@ -20,7 +20,7 @@ async function calculateBalance(
time: Date,
transactionLink?: dbTransactionLink | null,
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> {
const lastTransaction = await Transaction.findOne({ userId }, { order: { id: 'DESC' } })
const lastTransaction = await getLastTransaction(userId)
if (!lastTransaction) return null
const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time)

View File

@ -1,5 +1,3 @@
CONFIG_VERSION=v1.2022-03-18
DB_HOST=localhost
DB_PORT=3306
DB_USER=root

View File

@ -16,17 +16,17 @@ export class EventProtocol extends BaseEntity {
@Column({ name: 'user_id', unsigned: true, nullable: false })
userId: number
@Column({ name: 'x_user_id', unsigned: true, nullable: true })
xUserId: number
@Column({ name: 'x_user_id', type: 'int', unsigned: true, nullable: true })
xUserId: number | null
@Column({ name: 'x_community_id', unsigned: true, nullable: true })
xCommunityId: number
@Column({ name: 'x_community_id', type: 'int', unsigned: true, nullable: true })
xCommunityId: number | null
@Column({ name: 'transaction_id', unsigned: true, nullable: true })
transactionId: number
@Column({ name: 'transaction_id', type: 'int', unsigned: true, nullable: true })
transactionId: number | null
@Column({ name: 'contribution_id', unsigned: true, nullable: true })
contributionId: number
@Column({ name: 'contribution_id', type: 'int', unsigned: true, nullable: true })
contributionId: number | null
@Column({
type: 'decimal',
@ -35,8 +35,8 @@ export class EventProtocol extends BaseEntity {
nullable: true,
transformer: DecimalTransformer,
})
amount: Decimal
amount: Decimal | null
@Column({ name: 'message_id', unsigned: true, nullable: true })
messageId: number
@Column({ name: 'message_id', type: 'int', unsigned: true, nullable: true })
messageId: number | null
}

View File

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

View File

@ -27,7 +27,7 @@ COMMUNITY_DESCRIPTION="Gradido Development Stage1 Test Community"
COMMUNITY_SUPPORT_MAIL=support@supportmail.com
# backend
BACKEND_CONFIG_VERSION=v14.2022-12-22
BACKEND_CONFIG_VERSION=v15.2023-02-07
JWT_EXPIRES_IN=10m
GDT_API_URL=https://gdt.gradido.net
@ -56,9 +56,6 @@ EMAIL_CODE_REQUEST_TIME=10
WEBHOOK_ELOPAGE_SECRET=secret
# EventProtocol
EVENT_PROTOCOL_DISABLED=false
# Federation
# if you set the value of FEDERATION_DHT_TOPIC, the DHT hyperswarm will start to announce and listen
# on an hash created from this topic

17
dht-node/.env.dist Normal file
View File

@ -0,0 +1,17 @@
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_DATABASE=gradido_community
TYPEORM_LOGGING_RELATIVE_PATH=typeorm.dht-node.log
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
# LOG_LEVEL=info
# Federation
# if you set the value of FEDERATION_DHT_TOPIC, the DHT hyperswarm will start to announce and listen
# on an hash created from this topic
FEDERATION_DHT_TOPIC=GRADIDO_HUB
# FEDERATION_DHT_SEED=64ebcb0e3ad547848fef4197c6e2332f

14
dht-node/.env.template Normal file
View File

@ -0,0 +1,14 @@
CONFIG_VERSION=$BACKEND_CONFIG_VERSION
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=$DB_USER
DB_PASSWORD=$DB_PASSWORD
DB_DATABASE=gradido_community
TYPEORM_LOGGING_RELATIVE_PATH=$TYPEORM_LOGGING_RELATIVE_PATH
# Federation
FEDERATION_DHT_TOPIC=$FEDERATION_DHT_TOPIC
FEDERATION_DHT_SEED=$FEDERATION_DHT_SEED
FEDERATION_COMMUNITY_URL=$FEDERATION_COMMUNITY_URL

3
dht-node/.eslintignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
**/*.min.js
build

26
dht-node/.eslintrc.js Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
root: true,
env: {
node: true,
// jest: true,
},
parser: '@typescript-eslint/parser',
plugins: ['prettier', '@typescript-eslint' /*, 'jest' */],
extends: [
'standard',
'eslint:recommended',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
],
// add your custom rules here
rules: {
'no-console': ['error'],
'no-debugger': 'error',
'prettier/prettier': [
'error',
{
htmlWhitespaceSensitivity: 'ignore',
},
],
},
}

8
dht-node/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/node_modules/
/.env
/.env.bak
/build/
package-json.lock
coverage
# emacs
*~

9
dht-node/.prettierrc.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
semi: false,
printWidth: 100,
singleQuote: true,
trailingComma: "all",
tabWidth: 2,
bracketSpacing: true,
endOfLine: "auto",
};

119
dht-node/Dockerfile Normal file
View File

@ -0,0 +1,119 @@
##################################################################################
# BASE ###########################################################################
##################################################################################
FROM node:19.5.0-alpine3.17 as base
#FROM ubuntu:latest as base
# ENVs (available in production aswell, can be overwritten by commandline or env file)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame
ENV DOCKER_WORKDIR="/app"
## We Cannot do `$(date -u +'%Y-%m-%dT%H:%M:%SZ')` here so we use unix timestamp=0
ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
## We cannot do $(npm run version).${BUILD_NUMBER} here so we default to 0.0.0.0
ENV BUILD_VERSION="0.0.0.0"
## We cannot do `$(git rev-parse --short HEAD)` here so we default to 0000000
ENV BUILD_COMMIT="0000000"
## SET NODE_ENV
ENV NODE_ENV="production"
## App relevant Envs
#ENV PORT="5000"
# Labels
LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="gradido:dht-node"
LABEL org.label-schema.description="Gradido dht-node"
LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md"
LABEL org.label-schema.url="https://gradido.net"
LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/dht-node"
LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}"
LABEL org.label-schema.vendor="Gradido Community"
LABEL org.label-schema.version="${BUILD_VERSION}"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="support@gradido.net"
# Install Additional Software
## install: sodium requirements
RUN apk add --no-cache --virtual build-deps python3 alpine-sdk autoconf libtool automake && \
mkdir -p /prebuilds && cd /prebuilds && npm init -y && npm install sodium-native@3.1.1 && \
apk del build-deps
ENV SODIUM_NATIVE_PREBUILD=/prebuilds/node_modules/sodium-native/
# Settings
## Expose Container Port
#EXPOSE ${PORT}
## Workdir
RUN mkdir -p ${DOCKER_WORKDIR}
WORKDIR ${DOCKER_WORKDIR}
RUN mkdir -p /database
##################################################################################
# DEVELOPMENT (Connected to the local environment, to reload on demand) ##########
##################################################################################
FROM base as development
# We don't need to copy or build anything since we gonna bind to the
# local filesystem which will need a rebuild anyway
# Run command
# (for development we need to execute yarn install since the
# node_modules are on another volume and need updating)
CMD /bin/sh -c "cd /database && yarn install && yarn build && cd /app && yarn install && yarn run dev"
##################################################################################
# BUILD (Does contain all files and is therefore bloated) ########################
##################################################################################
FROM base as build
# Copy everything from dht-node
COPY ./dht-node/ ./
# Copy everything from database
COPY ./database/ ../database/
# yarn install dht-node
RUN yarn install --production=false --frozen-lockfile --non-interactive
# yarn install database
RUN cd ../database && yarn install --production=false --frozen-lockfile --non-interactive
# yarn build
RUN yarn run build
# yarn build database
RUN cd ../database && yarn run build
##################################################################################
# TEST ###########################################################################
##################################################################################
FROM build as test
# Run command
CMD /bin/sh -c "yarn run start"
##################################################################################
# PRODUCTION (Does contain only "binary"- and static-files to reduce image size) #
##################################################################################
FROM base as production
# Copy "binary"-files from build image
COPY --from=build ${DOCKER_WORKDIR}/build ./build
COPY --from=build ${DOCKER_WORKDIR}/../database/build ../database/build
# We also copy the node_modules express and serve-static for the run script
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
COPY --from=build ${DOCKER_WORKDIR}/../database/node_modules ../database/node_modules
# Copy static files
# COPY --from=build ${DOCKER_WORKDIR}/public ./public
# Copy package.json for script definitions (lock file should not be needed)
COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
# Copy tsconfig.json to provide alias path definitions
COPY --from=build ${DOCKER_WORKDIR}/tsconfig.json ./tsconfig.json
# Copy log4js-config.json to provide log configuration
COPY --from=build ${DOCKER_WORKDIR}/log4js-config.json ./log4js-config.json
# Copy run scripts run/
# COPY --from=build ${DOCKER_WORKDIR}/run ./run
# Run command
CMD /bin/sh -c "yarn run start"

22
dht-node/jest.config.js Normal file
View File

@ -0,0 +1,22 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
verbose: true,
preset: 'ts-jest',
collectCoverage: true,
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
setupFiles: ['<rootDir>/test/testSetup.ts'],
setupFilesAfterEnv: [],
modulePathIgnorePatterns: ['<rootDir>/build/'],
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
'@test/(.*)': '<rootDir>/test/$1',
'@entity/(.*)':
process.env.NODE_ENV === 'development'
? '<rootDir>/../database/entity/$1'
: '<rootDir>/../database/build/entity/$1',
'@dbTools/(.*)':
process.env.NODE_ENV === 'development'
? '<rootDir>/../database/src/$1'
: '<rootDir>/../database/build/src/$1',
},
}

View File

@ -0,0 +1,69 @@
{
"appenders":
{
"dht":
{
"type": "dateFile",
"filename": "../logs/dht-node/apiversion-%v.log",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_",
"numBackups" : 30
},
"errorFile":
{
"type": "dateFile",
"filename": "../logs/dht-node/errors.log",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_",
"numBackups" : 30
},
"errors":
{
"type": "logLevelFilter",
"level": "error",
"appender": "errorFile"
},
"out":
{
"type": "stdout",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
}
}
},
"categories":
{
"default":
{
"appenders":
[
"out",
"errors"
],
"level": "debug",
"enableCallStack": true
},
"dht":
{
"appenders":
[
"dht",
"out",
"errors"
],
"level": "debug",
"enableCallStack": true
}
}
}

45
dht-node/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "gradido-dht-node",
"version": "1.0.0",
"description": "Gradido dht-node module",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/",
"author": "Claus-Peter Huebner",
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --build",
"clean": "tsc --build --clean",
"start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js",
"dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r dotenv/config -r tsconfig-paths/register src/index.ts",
"lint": "eslint --max-warnings=0 --ext .js,.ts .",
"test": "cross-env TZ=UTC NODE_ENV=development jest --runInBand --coverage --forceExit --detectOpenHandles"
},
"dependencies": {
"@hyperswarm/dht": "^6.4.4",
"cross-env": "^7.0.3",
"dotenv": "10.0.0",
"log4js": "^6.7.1",
"nodemon": "^2.0.20",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.2",
"typescript": "^4.9.4"
},
"devDependencies": {
"@types/dotenv": "^8.2.0",
"@types/jest": "^27.0.2",
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.48.0",
"@typescript-eslint/parser": "^5.48.0",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^17.0.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-n": "^15.6.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^6.1.1",
"jest": "^27.2.4",
"prettier": "^2.3.1",
"ts-jest": "^27.0.5"
}
}

View File

@ -0,0 +1,56 @@
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
import dotenv from 'dotenv'
dotenv.config()
const constants = {
DB_VERSION: '0059-add_hide_amount_to_users',
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: 'v2.2023-02-07',
CURRENT: '',
},
}
const server = {
PRODUCTION: process.env.NODE_ENV === 'production' || false,
}
const database = {
DB_HOST: process.env.DB_HOST || 'localhost',
DB_PORT: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306,
DB_USER: process.env.DB_USER || 'root',
DB_PASSWORD: process.env.DB_PASSWORD || '',
DB_DATABASE: process.env.DB_DATABASE || 'gradido_community',
TYPEORM_LOGGING_RELATIVE_PATH:
process.env.TYPEORM_LOGGING_RELATIVE_PATH || 'typeorm.dht-node.log',
}
const federation = {
FEDERATION_DHT_TOPIC: process.env.FEDERATION_DHT_TOPIC || 'GRADIDO_HUB',
FEDERATION_DHT_SEED: process.env.FEDERATION_DHT_SEED || null,
FEDERATION_COMMUNITY_URL: process.env.FEDERATION_COMMUNITY_URL || null,
}
// Check config version
constants.CONFIG_VERSION.CURRENT = process.env.CONFIG_VERSION || constants.CONFIG_VERSION.DEFAULT
if (
![constants.CONFIG_VERSION.EXPECTED, constants.CONFIG_VERSION.DEFAULT].includes(
constants.CONFIG_VERSION.CURRENT,
)
) {
throw new Error(
`Fatal: Config Version incorrect - expected "${constants.CONFIG_VERSION.EXPECTED}" or "${constants.CONFIG_VERSION.DEFAULT}", but found "${constants.CONFIG_VERSION.CURRENT}"`,
)
}
const CONFIG = {
...constants,
...server,
...database,
...federation,
}
export default CONFIG

View File

@ -0,0 +1 @@
declare module '@hyperswarm/dht'

View File

@ -0,0 +1,798 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { startDHT } from './index'
import DHT from '@hyperswarm/dht'
import CONFIG from '@/config'
import { logger } from '@test/testSetup'
import { Community as DbCommunity } from '@entity/Community'
import { testEnvironment, cleanDB } from '@test/helpers'
CONFIG.FEDERATION_DHT_SEED = '64ebcb0e3ad547848fef4197c6e2332f'
jest.mock('@hyperswarm/dht')
const TEST_TOPIC = 'gradido_test_topic'
const keyPairMock = {
publicKey: Buffer.from('publicKey'),
secretKey: Buffer.from('secretKey'),
}
const serverListenSpy = jest.fn()
const serverEventMocks: { [key: string]: any } = {}
const serverOnMock = jest.fn().mockImplementation((key: string, callback) => {
serverEventMocks[key] = callback
})
const nodeCreateServerMock = jest.fn().mockImplementation(() => {
return {
on: serverOnMock,
listen: serverListenSpy,
}
})
const nodeAnnounceMock = jest.fn().mockImplementation(() => {
return {
finished: jest.fn(),
}
})
const lookupResultMock = {
token: Buffer.from(TEST_TOPIC),
from: {
id: Buffer.from('somone'),
host: '188.95.53.5',
port: 63561,
},
to: { id: null, host: '83.53.31.27', port: 55723 },
peers: [
{
publicKey: Buffer.from('some-public-key'),
relayAddresses: [],
},
],
}
const nodeLookupMock = jest.fn().mockResolvedValue([lookupResultMock])
const socketEventMocks: { [key: string]: any } = {}
const socketOnMock = jest.fn().mockImplementation((key: string, callback) => {
socketEventMocks[key] = callback
})
const socketWriteMock = jest.fn()
const nodeConnectMock = jest.fn().mockImplementation(() => {
return {
on: socketOnMock,
once: socketOnMock,
write: socketWriteMock,
}
})
DHT.hash.mockImplementation(() => {
return Buffer.from(TEST_TOPIC)
})
DHT.keyPair.mockImplementation(() => {
return keyPairMock
})
DHT.mockImplementation(() => {
return {
createServer: nodeCreateServerMock,
announce: nodeAnnounceMock,
lookup: nodeLookupMock,
connect: nodeConnectMock,
}
})
let con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('federation', () => {
beforeAll(() => {
jest.useFakeTimers()
})
describe('call startDHT', () => {
const hashSpy = jest.spyOn(DHT, 'hash')
const keyPairSpy = jest.spyOn(DHT, 'keyPair')
beforeEach(async () => {
DHT.mockClear()
jest.clearAllMocks()
await startDHT(TEST_TOPIC)
})
it('calls DHT.hash', () => {
expect(hashSpy).toBeCalledWith(Buffer.from(TEST_TOPIC))
})
it('creates a key pair', () => {
expect(keyPairSpy).toBeCalledWith(expect.any(Buffer))
})
it('initializes a new DHT object', () => {
expect(DHT).toBeCalledWith({ keyPair: keyPairMock })
})
describe('DHT node', () => {
it('creates a server', () => {
expect(nodeCreateServerMock).toBeCalled()
})
it('listens on the server', () => {
expect(serverListenSpy).toBeCalled()
})
describe('timers', () => {
beforeEach(() => {
jest.runOnlyPendingTimers()
})
it('announces on topic', () => {
expect(nodeAnnounceMock).toBeCalledWith(Buffer.from(TEST_TOPIC), keyPairMock)
})
it('looks up on topic', () => {
expect(nodeLookupMock).toBeCalledWith(Buffer.from(TEST_TOPIC))
})
})
describe('server connection event', () => {
beforeEach(() => {
serverEventMocks.connection({
remotePublicKey: Buffer.from('another-public-key'),
on: socketOnMock,
})
})
it('can be triggered', () => {
expect(socketOnMock).toBeCalled()
})
describe('socket events', () => {
describe('on data', () => {
describe('with receiving simply a string', () => {
beforeEach(() => {
jest.clearAllMocks()
socketEventMocks.data(Buffer.from('no-json string'))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith('data: no-json string')
})
it('logs an error of unexpected data format and structure', () => {
expect(logger.error).toBeCalledWith(
'Error on receiving data from socket:',
new SyntaxError('Unexpected token \'o\', "no-json string" is not valid JSON'),
)
})
})
describe('with receiving array of strings', () => {
beforeEach(() => {
jest.clearAllMocks()
const strArray: string[] = ['invalid type test', 'api', 'url']
socketEventMocks.data(Buffer.from(strArray.toString()))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith('data: invalid type test,api,url')
})
it('logs an error of unexpected data format and structure', () => {
expect(logger.error).toBeCalledWith(
'Error on receiving data from socket:',
new SyntaxError('Unexpected token \'i\', "invalid ty"... is not valid JSON'),
)
})
})
describe('with receiving array of string-arrays', () => {
beforeEach(async () => {
jest.clearAllMocks()
const strArray: string[][] = [
[`api`, `url`, `invalid type in array test`],
[`wrong`, `api`, `url`],
]
await socketEventMocks.data(Buffer.from(strArray.toString()))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(
'data: api,url,invalid type in array test,wrong,api,url',
)
})
it('logs an error of unexpected data format and structure', () => {
expect(logger.error).toBeCalledWith(
'Error on receiving data from socket:',
new SyntaxError('Unexpected token \'a\', "api,url,in"... is not valid JSON'),
)
})
})
describe('with receiving JSON-Array with too much entries', () => {
let jsonArray: { api: string; url: string }[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 'v1_0', url: 'too much versions at the same time test' },
{ api: 'v1_0', url: 'url2' },
{ api: 'v1_0', url: 'url3' },
{ api: 'v1_0', url: 'url4' },
{ api: 'v1_0', url: 'url5' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(
'data: [{"api":"v1_0","url":"too much versions at the same time test"},{"api":"v1_0","url":"url2"},{"api":"v1_0","url":"url3"},{"api":"v1_0","url":"url4"},{"api":"v1_0","url":"url5"}]',
)
})
it('logs a warning of too much apiVersion-Definitions', () => {
expect(logger.warn).toBeCalledWith(
`received totaly wrong or too much apiVersions-Definition JSON-String: ${JSON.stringify(
jsonArray,
)}`,
)
})
})
describe('with receiving wrong but tolerated property data', () => {
let jsonArray: any[]
let result: DbCommunity[] = []
beforeAll(async () => {
jest.clearAllMocks()
jsonArray = [
{
wrong: 'wrong but tolerated property test',
api: 'v1_0',
url: 'url1',
},
{
api: 'v2_0',
url: 'url2',
wrong: 'wrong but tolerated property test',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
result = await DbCommunity.find()
})
afterAll(async () => {
await cleanDB()
})
it('has two Communty entries in database', () => {
expect(result).toHaveLength(2)
})
it('has an entry for api version v1_0', () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'v1_0',
endPoint: 'url1',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it('has an entry for api version v2_0', () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'v2_0',
endPoint: 'url2',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
})
describe('with receiving data but missing api property', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ test1: 'missing api proterty test', url: 'any url definition as string' },
{ api: 'some api', test2: 'missing url property test' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but missing url property', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 'some api', test2: 'missing url property test' },
{ test1: 'missing api proterty test', url: 'any url definition as string' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but wrong type of api property', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 1, url: 'wrong property type tests' },
{ api: 'urltyptest', url: 2 },
{ api: 1, url: 2 },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but wrong type of url property', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 'urltyptest', url: 2 },
{ api: 1, url: 'wrong property type tests' },
{ api: 1, url: 2 },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but wrong type of both properties', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 1, url: 2 },
{ api: 'urltyptest', url: 2 },
{ api: 1, url: 'wrong property type tests' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but too long api string', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 'toolong api', url: 'some valid url' },
{
api: 'valid api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{
api: 'toolong api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received apiVersion with content longer than max length: ${JSON.stringify(
jsonArray[0],
)}`,
)
})
})
describe('with receiving data but too long url string', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{ api: 'toolong api', url: 'some valid url' },
{
api: 'toolong api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received apiVersion with content longer than max length: ${JSON.stringify(
jsonArray[0],
)}`,
)
})
})
describe('with receiving data but both properties with too long strings', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'toolong api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{
api: 'api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{ api: 'toolong api', url: 'some valid url' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
})
describe('with receiving data of exact max allowed properties length', () => {
let jsonArray: any[]
let result: DbCommunity[] = []
beforeAll(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'valid api',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{ api: 'toolong api', url: 'some valid url' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
result = await DbCommunity.find()
})
afterAll(async () => {
await cleanDB()
})
it('has one Communty entry in database', () => {
expect(result).toHaveLength(1)
})
it(`has an entry with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
})
describe('with receiving data of exact max allowed buffer length', () => {
let jsonArray: any[]
let result: DbCommunity[] = []
beforeAll(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'valid api1',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api2',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api3',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api4',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
result = await DbCommunity.find()
})
afterAll(async () => {
await cleanDB()
})
it('has five Communty entries in database', () => {
expect(result).toHaveLength(4)
})
it(`has an entry 'valid api1' with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api1',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it(`has an entry 'valid api2' with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api2',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it(`has an entry 'valid api3' with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api3',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it(`has an entry 'valid api4' with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api4',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
})
describe('with receiving data longer than max allowed buffer length', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'Xvalid api1',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api2',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api3',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api4',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.warn).toBeCalledWith(
`received more than max allowed length of data buffer: ${
JSON.stringify(jsonArray).length
} against 1141 max allowed`,
)
})
})
describe('with proper data', () => {
let result: DbCommunity[] = []
beforeAll(async () => {
jest.clearAllMocks()
await socketEventMocks.data(
Buffer.from(
JSON.stringify([
{
api: 'v1_0',
url: 'http://localhost:4000/api/v1_0',
},
{
api: 'v2_0',
url: 'http://localhost:4000/api/v2_0',
},
]),
),
)
result = await DbCommunity.find()
})
afterAll(async () => {
await cleanDB()
})
it('has two Communty entries in database', () => {
expect(result).toHaveLength(2)
})
it('has an entry for api version v1_0', () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'v1_0',
endPoint: 'http://localhost:4000/api/v1_0',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it('has an entry for api version v2_0', () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'v2_0',
endPoint: 'http://localhost:4000/api/v2_0',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
})
})
describe('on open', () => {
beforeEach(() => {
socketEventMocks.open()
})
it.skip('calls socket write with own api versions', () => {
expect(socketWriteMock).toBeCalledWith(
Buffer.from(
JSON.stringify([
{
api: 'v1_0',
url: 'http://localhost:4000/api/v1_0',
},
{
api: 'v1_1',
url: 'http://localhost:4000/api/v1_1',
},
{
api: 'v2_0',
url: 'http://localhost:4000/api/v2_0',
},
]),
),
)
})
})
})
})
})
})
})

View File

@ -0,0 +1,186 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import DHT from '@hyperswarm/dht'
import { logger } from '@/server/logger'
import CONFIG from '@/config'
import { Community as DbCommunity } from '@entity/Community'
const KEY_SECRET_SEEDBYTES = 32
const getSeed = (): Buffer | null =>
CONFIG.FEDERATION_DHT_SEED ? Buffer.alloc(KEY_SECRET_SEEDBYTES, CONFIG.FEDERATION_DHT_SEED) : null
const POLLTIME = 20000
const SUCCESSTIME = 120000
const ERRORTIME = 240000
const ANNOUNCETIME = 30000
enum ApiVersionType {
V1_0 = 'v1_0',
V1_1 = 'v1_1',
V2_0 = 'v2_0',
}
type CommunityApi = {
api: string
url: string
}
export const startDHT = async (topic: string): Promise<void> => {
try {
const TOPIC = DHT.hash(Buffer.from(topic))
const keyPair = DHT.keyPair(getSeed())
logger.info(`keyPairDHT: publicKey=${keyPair.publicKey.toString('hex')}`)
logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex')}`)
const ownApiVersions = Object.values(ApiVersionType).map(function (apiEnum) {
const comApi: CommunityApi = {
api: apiEnum,
url: CONFIG.FEDERATION_COMMUNITY_URL + apiEnum,
}
return comApi
})
logger.debug(`ApiList: ${JSON.stringify(ownApiVersions)}`)
const node = new DHT({ keyPair })
const server = node.createServer()
server.on('connection', function (socket: any) {
logger.info(`server on... with Remote public key: ${socket.remotePublicKey.toString('hex')}`)
socket.on('data', async (data: Buffer) => {
try {
if (data.length > 1141) {
logger.warn(
`received more than max allowed length of data buffer: ${data.length} against 1141 max allowed`,
)
return
}
logger.info(`data: ${data.toString('ascii')}`)
const recApiVersions: CommunityApi[] = JSON.parse(data.toString('ascii'))
// TODO better to introduce the validation by https://github.com/typestack/class-validator
if (!recApiVersions || !Array.isArray(recApiVersions) || recApiVersions.length >= 5) {
logger.warn(
`received totaly wrong or too much apiVersions-Definition JSON-String: ${JSON.stringify(
recApiVersions,
)}`,
)
return
}
for (const recApiVersion of recApiVersions) {
if (
!recApiVersion.api ||
typeof recApiVersion.api !== 'string' ||
!recApiVersion.url ||
typeof recApiVersion.url !== 'string'
) {
logger.warn(
`received invalid apiVersion-Definition: ${JSON.stringify(recApiVersion)}`,
)
return
}
// TODO better to introduce the validation on entity-Level by https://github.com/typestack/class-validator
if (recApiVersion.api.length > 10 || recApiVersion.url.length > 255) {
logger.warn(
`received apiVersion with content longer than max length: ${JSON.stringify(
recApiVersion,
)}`,
)
return
}
const variables = {
apiVersion: recApiVersion.api,
endPoint: recApiVersion.url,
publicKey: socket.remotePublicKey.toString('hex'),
lastAnnouncedAt: new Date(),
}
logger.debug(`upsert with variables=${JSON.stringify(variables)}`)
// this will NOT update the updatedAt column, to distingue between a normal update and the last announcement
await DbCommunity.createQueryBuilder()
.insert()
.into(DbCommunity)
.values(variables)
.orUpdate({
conflict_target: ['id', 'publicKey', 'apiVersion'],
overwrite: ['end_point', 'last_announced_at'],
})
.execute()
logger.info(`federation community upserted successfully...`)
}
} catch (e) {
logger.error('Error on receiving data from socket:', e)
}
})
})
await server.listen()
setInterval(async () => {
logger.info(`Announcing on topic: ${TOPIC.toString('hex')}`)
await node.announce(TOPIC, keyPair).finished()
}, ANNOUNCETIME)
let successfulRequests: string[] = []
let errorfulRequests: string[] = []
setInterval(async () => {
logger.info('Refreshing successful nodes')
successfulRequests = []
}, SUCCESSTIME)
setInterval(async () => {
logger.info('Refreshing errorful nodes')
errorfulRequests = []
}, ERRORTIME)
setInterval(async () => {
const result = await node.lookup(TOPIC)
const collectedPubKeys: string[] = []
for await (const data of result) {
data.peers.forEach((peer: any) => {
const pubKey = peer.publicKey.toString('hex')
if (
pubKey !== keyPair.publicKey.toString('hex') &&
!successfulRequests.includes(pubKey) &&
!errorfulRequests.includes(pubKey) &&
!collectedPubKeys.includes(pubKey)
) {
collectedPubKeys.push(peer.publicKey.toString('hex'))
}
})
}
if (collectedPubKeys.length) {
logger.info(`Found new peers: ${collectedPubKeys}`)
}
collectedPubKeys.forEach((remotePubKey) => {
const socket = node.connect(Buffer.from(remotePubKey, 'hex'))
// socket.once("connect", function () {
// console.log("client side emitted connect");
// });
// socket.once("end", function () {
// console.log("client side ended");
// });
socket.once('error', (err: any) => {
errorfulRequests.push(remotePubKey)
logger.error(`error on peer ${remotePubKey}: ${err.message}`)
})
socket.on('open', function () {
socket.write(Buffer.from(JSON.stringify(ownApiVersions)))
successfulRequests.push(remotePubKey)
})
})
}, POLLTIME)
} catch (err) {
logger.error('DHT unexpected error:', err)
}
}

38
dht-node/src/index.ts Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { startDHT } from '@/dht_node/index'
// config
import CONFIG from './config'
import { logger } from './server/logger'
import connection from './typeorm/connection'
import { checkDBVersion } from './typeorm/DBVersion'
async function main() {
// open mysql connection
const con = await connection()
if (!con || !con.isConnected) {
logger.fatal(`Couldn't open connection to database!`)
throw new Error(`Fatal: Couldn't open connection to database`)
}
// check for correct database version
const dbVersion = await checkDBVersion(CONFIG.DB_VERSION)
if (!dbVersion) {
logger.fatal('Fatal: Database Version incorrect')
throw new Error('Fatal: Database Version incorrect')
}
// eslint-disable-next-line no-console
console.log(
`starting Federation on ${CONFIG.FEDERATION_DHT_TOPIC} ${
CONFIG.FEDERATION_DHT_SEED ? 'with seed...' : 'without seed...'
}`,
)
await startDHT(CONFIG.FEDERATION_DHT_TOPIC)
}
main().catch((e) => {
// eslint-disable-next-line no-console
console.error(e)
process.exit(1)
})

View File

@ -0,0 +1,20 @@
import log4js from 'log4js'
import CONFIG from '@/config'
import { readFileSync } from 'fs'
const options = JSON.parse(readFileSync(CONFIG.LOG4JS_CONFIG, 'utf-8'))
options.categories.dht.level = CONFIG.LOG_LEVEL
let filename: string = options.appenders.dht.filename
options.appenders.dht.filename = filename.replace(
'apiversion-%v',
'dht-' + CONFIG.FEDERATION_DHT_TOPIC,
)
filename = options.appenders.errorFile.filename
log4js.configure(options)
const logger = log4js.getLogger('dht')
export { logger }

View File

@ -0,0 +1,27 @@
import { Migration } from '@entity/Migration'
import { logger } from '@/server/logger'
const getDBVersion = async (): Promise<string | null> => {
try {
const dbVersion = await Migration.findOne({ order: { version: 'DESC' } })
return dbVersion ? dbVersion.fileName : null
} catch (error) {
logger.error(error)
return null
}
}
const checkDBVersion = async (DB_VERSION: string): Promise<boolean> => {
const dbVersion = await getDBVersion()
if (!dbVersion || dbVersion.indexOf(DB_VERSION) === -1) {
logger.error(
`Wrong database version detected - the dht-node requires '${DB_VERSION}' but found '${
dbVersion || 'None'
}`,
)
return false
}
return true
}
export { checkDBVersion, getDBVersion }

View File

@ -0,0 +1,34 @@
// TODO This is super weird - since the entities are defined in another project they have their own globals.
// We cannot use our connection here, but must use the external typeorm installation
import { Connection, createConnection, FileLogger } from '@dbTools/typeorm'
import CONFIG from '@/config'
import { entities } from '@entity/index'
const connection = async (): Promise<Connection | null> => {
try {
return createConnection({
name: 'default',
type: 'mysql',
host: CONFIG.DB_HOST,
port: CONFIG.DB_PORT,
username: CONFIG.DB_USER,
password: CONFIG.DB_PASSWORD,
database: CONFIG.DB_DATABASE,
entities,
synchronize: false,
logging: true,
logger: new FileLogger('all', {
logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
}),
extra: {
charset: 'utf8mb4_unicode_ci',
},
})
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
return null
}
}
export default connection

View File

@ -0,0 +1,7 @@
import { contributionDateFormatter } from '@test/helpers'
describe('contributionDateFormatter', () => {
it('formats the date correctly', () => {
expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024')
})
})

68
dht-node/test/helpers.ts Normal file
View File

@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import CONFIG from '@/config'
import connection from '@/typeorm/connection'
import { checkDBVersion } from '@/typeorm/DBVersion'
import { initialize } from '@dbTools/helpers'
import { entities } from '@entity/index'
import { logger } from './testSetup'
export const headerPushMock = jest.fn((t) => {
context.token = t.value
})
const context = {
token: '',
setHeaders: {
push: headerPushMock,
forEach: jest.fn(),
},
clientTimezoneOffset: 0,
}
export const cleanDB = async () => {
// this only works as long we do not have foreign key constraints
for (let i = 0; i < entities.length; i++) {
await resetEntity(entities[i])
}
}
export const testEnvironment = async () => {
// open mysql connection
const con = await connection()
if (!con || !con.isConnected) {
logger.fatal(`Couldn't open connection to database!`)
throw new Error(`Fatal: Couldn't open connection to database`)
}
// check for correct database version
const dbVersion = await checkDBVersion(CONFIG.DB_VERSION)
if (!dbVersion) {
logger.fatal('Fatal: Database Version incorrect')
throw new Error('Fatal: Database Version incorrect')
}
await initialize()
return { con }
}
export const resetEntity = async (entity: any) => {
const items = await entity.find({ withDeleted: true })
if (items.length > 0) {
const ids = items.map((i: any) => i.id)
await entity.delete(ids)
}
}
export const resetToken = () => {
context.token = ''
}
// format date string as it comes from the frontend for the contribution date
export const contributionDateFormatter = (date: Date): string => {
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
}
export const setClientTimezoneOffset = (offset: number): void => {
context.clientTimezoneOffset = offset
}

View File

@ -0,0 +1,22 @@
import { logger } from '@/server/logger'
jest.setTimeout(1000000)
jest.mock('@/server/logger', () => {
const originalModule = jest.requireActual('@/server/logger')
return {
__esModule: true,
...originalModule,
logger: {
addContext: jest.fn(),
trace: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
fatal: jest.fn(),
},
}
})
export { logger }

86
dht-node/tsconfig.json Normal file
View File

@ -0,0 +1,86 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./build", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": ".", /* Base directory to resolve non-absolute module names. */
"paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
"@/*": ["src/*"],
"@test/*": ["test/*"],
/* external */
"@typeorm/*": ["../backend/src/typeorm/*", "../../backend/src/typeorm/*"],
"@dbTools/*": ["../database/src/*", "../../database/build/src/*"],
"@entity/*": ["../database/entity/*", "../../database/build/entity/*"]
},
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
"typeRoots": ["src/dht_node/@types", "node_modules/@types"], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"references": [
{
"path": "../database/tsconfig.json",
// add 'prepend' if you want to include the referenced project in your output file
// "prepend": true
}
]
}

4320
dht-node/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,29 @@ services:
- ./backend:/app
- ./database:/database
########################################################
# DHT-NODE #############################################
########################################################
dht-node:
# 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/dht-node:local-development
build:
target: development
networks:
- external-net
- internal-net
environment:
- NODE_ENV="development"
volumes:
# This makes sure the docker container has its own node modules.
# Therefore it is possible to have a different node version on the host machine
- dht_node_modules:/app/node_modules
- dht_database_node_modules:/database/node_modules
- dht_database_build:/database/build
# bind the local folder to the docker to allow live reload
- ./dht-node:/app
- ./database:/database
########################################################
# DATABASE ##############################################
########################################################
@ -129,5 +152,8 @@ volumes:
backend_node_modules:
backend_database_node_modules:
backend_database_build:
dht_node_modules:
dht_database_node_modules:
dht_database_build:
database_node_modules:
database_build:

View File

@ -111,6 +111,42 @@ services:
# <host_machine_directory>:<container_directory> mirror bidirectional path in local context with path in Docker container
- ./logs/backend:/logs/backend
########################################################
# DHT-NODE #############################################
########################################################
dht-node:
# 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/dht-node: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 ?!
context: ./
dockerfile: ./dht-node/Dockerfile
target: production
networks:
- internal-net
- external-net
#ports:
# - 5000:5000
depends_on:
- mariadb
restart: always
environment:
# Envs used in Dockerfile
# - DOCKER_WORKDIR="/app"
# - PORT=5000
- BUILD_DATE
- BUILD_VERSION
- BUILD_COMMIT
- NODE_ENV="production"
- DB_HOST=mariadb
# Application only envs
#env_file:
# - ./frontend/.env
volumes:
# <host_machine_directory>:<container_directory> mirror bidirectional path in local context with path in Docker container
- ./logs/dht-node:/logs/dht-node
########################################################
# DATABASE #############################################
########################################################

View File

@ -52,10 +52,6 @@ const community = {
process.env.COMMUNITY_DESCRIPTION || 'Die lokale Entwicklungsumgebung von Gradido.',
}
*/
// const eventProtocol = {
// global switch to enable writing of EventProtocol-Entries
// EVENT_PROTOCOL_DISABLED: process.env.EVENT_PROTOCOL_DISABLED === 'true' || false,
// }
// This is needed by graphql-directive-auth
// process.env.APP_SECRET = server.JWT_SECRET

View File

@ -1,5 +1,3 @@
CONFIG_VERSION=v4.2022-12-20
# Environment
DEFAULT_PUBLISHER_ID=2896

View File

@ -1,6 +1,6 @@
{
"name": "bootstrap-vue-gradido-wallet",
"version": "1.17.1",
"version": "1.18.0",
"private": true,
"scripts": {
"start": "node run/server.js",
@ -104,5 +104,10 @@
],
"author": "Gradido-Akademie - https://www.gradido.net/",
"license": "Apache-2.0",
"description": "Gradido, the Natural Economy of Life, is a way to worldwide prosperity and peace in harmony with nature. - Gradido, die Natürliche Ökonomie des lebens, ist ein Weg zu weltweitem Wohlstand und Frieden in Harmonie mit der Natur."
"description": "Gradido, the Natural Economy of Life, is a way to worldwide prosperity and peace in harmony with nature. - Gradido, die Natürliche Ökonomie des lebens, ist ein Weg zu weltweitem Wohlstand und Frieden in Harmonie mit der Natur.",
"nodemonConfig": {
"ignore": [
"**/*.spec.js"
]
}
}

View File

@ -3,7 +3,7 @@
<b-form
ref="form"
@submit.prevent="submit"
class="p-3 bg-white appBoxShadow gradido-border-radius"
class="form-style p-3 bg-white appBoxShadow gradido-border-radius"
>
<label>{{ $t('contribution.selectDate') }}</label>
<b-form-datepicker
@ -23,6 +23,13 @@
<template #nav-next-year><span></span></template>
</b-form-datepicker>
<div
v-if="(isThisMonth && maxGddThisMonth <= 0) || (!isThisMonth && maxGddLastMonth <= 0)"
class="p-3"
>
{{ noOpenCreation }}
</div>
<div v-else>
<input-textarea
id="contribution-memo"
v-model="form.memo"
@ -56,17 +63,30 @@
></input-amount>
<b-row class="mt-5">
<b-col>
<b-button type="reset" variant="secondary" @click="reset" data-test="button-cancel">
<b-col cols="12" lg="6">
<b-button
block
type="reset"
variant="secondary"
@click="reset"
data-test="button-cancel"
>
{{ $t('form.cancel') }}
</b-button>
</b-col>
<b-col class="text-right">
<b-button type="submit" variant="gradido" :disabled="disabled" data-test="button-submit">
<b-col cols="12" lg="6" class="text-right mt-4 mt-lg-0">
<b-button
block
type="submit"
variant="gradido"
:disabled="disabled"
data-test="button-submit"
>
{{ form.id ? $t('form.change') : $t('contribution.submit') }}
</b-button>
</b-col>
</b-row>
</div>
</b-form>
</div>
</template>
@ -133,6 +153,18 @@ export default {
validMaxTime() {
return Number(this.validMaxGDD / 20)
},
noOpenCreation() {
if (this.maxGddThisMonth <= 0 && this.maxGddLastMonth <= 0) {
return this.$t('contribution.noOpenCreation.allMonth')
}
if (this.isThisMonth && this.maxGddThisMonth <= 0) {
return this.$t('contribution.noOpenCreation.thisMonth')
}
if (!this.isThisMonth && this.maxGddLastMonth <= 0) {
return this.$t('contribution.noOpenCreation.lastMonth')
}
return ''
},
},
watch: {
value() {
@ -142,6 +174,9 @@ export default {
}
</script>
<style>
.form-style {
min-height: 410px;
}
span.errors {
color: red;
}

View File

@ -116,5 +116,15 @@ describe('ContributionList', () => {
expect(wrapper.emitted('delete-contribution')).toEqual([[{ id: 2 }]])
})
})
describe('update status', () => {
beforeEach(() => {
wrapper.findComponent({ name: 'ContributionListItem' }).vm.$emit('update-state', { id: 2 })
})
it('emits update status', () => {
expect(wrapper.emitted('update-state')).toEqual([[{ id: 2 }]])
})
})
})
})

View File

@ -2,10 +2,10 @@
<div class="transaction-form">
<b-row>
<b-col cols="12">
<b-card class="appBoxShadow gradido-border-radius" body-class="p-3">
<b-card class="appBoxShadow gradido-border-radius" body-class="p-4">
<validation-observer v-slot="{ handleSubmit }" ref="formValidator">
<b-form role="form" @submit.prevent="handleSubmit(onSubmit)" @reset="onReset">
<b-form-radio-group v-model="radioSelected" class="container">
<b-form-radio-group v-model="radioSelected">
<b-row class="mb-4">
<b-col cols="12" lg="6">
<b-row class="bg-248 gradido-border-radius pt-lg-2 mr-lg-2">
@ -39,14 +39,13 @@
</b-row>
</b-col>
</b-row>
</b-form-radio-group>
<div class="mt-4 mb-4" v-if="radioSelected === sendTypes.link">
<h2 class="alert-heading">{{ $t('gdd_per_link.header') }}</h2>
<div>
{{ $t('gdd_per_link.choose-amount') }}
</div>
</div>
</b-form-radio-group>
<b-row>
<b-col>
<b-row>
@ -105,8 +104,11 @@
</b-button>
</b-col>
<b-col cols="12" md="6" lg="6" class="text-lg-right">
<b-button block type="submit" variant="gradido" :disabled="disabled">
{{ $t('form.check_now') }}
<b-button block type="submit" variant="gradido">
<span v-if="radioSelected === sendTypes.send">{{ $t('form.check_now') }}</span>
<span v-if="radioSelected === sendTypes.link">
{{ $t('form.generate_now') }}
</span>
</b-button>
</b-col>
</b-row>

View File

@ -78,7 +78,24 @@ export default {
min-width: 200px;
}
@media screen and (max-width: 1024px) {
@media screen and (min-width: 1025px) {
#side-menu {
max-width: 180px;
}
#component-sidebar {
min-width: 180px;
}
}
@media screen and (min-width: 1075px) {
#side-menu {
max-width: 200px;
}
#component-sidebar {
min-width: 200px;
}
}
@media screen and (max-width: 1108px) {
#side-menu {
max-width: 100%;
}

View File

@ -67,6 +67,7 @@ export default {
}
if (this.tokenExpiresInSeconds === 0) {
this.$timer.stop('tokenExpires')
this.toastInfoNoHide(this.$t('session.automaticallyLoggedOut'))
this.$emit('logout')
}
},
@ -84,6 +85,7 @@ export default {
})
.catch(() => {
this.$timer.stop('tokenExpires')
this.toastInfoNoHide(this.$t('session.automaticallyLoggedOut'))
this.$emit('logout')
})
},

View File

@ -51,13 +51,22 @@
"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.",
"describeYourCommunity": "Beschreibe deine Gemeinwohl-Tätigkeit und gib die Anzahl der Stunden an! Der Betrag von 20 GDD pro Stunde wird automatisch berechnet. 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"
},
"lastContribution": "Letzte Beiträge",
"noContributions": {
"allContributions": "Es wurden noch keine Beiträge eingereicht.",
"myContributions": "Du hast noch keine Beiträge eingereicht."
},
"noDateSelected": "Wähle irgendein Datum im Monat",
"noOpenCreation": {
"allMonth": "Für alle beiden Monate ist dein Schöpfungslimit erreicht. Den Nächsten Monat kannst du wieder 1000 GDD Schöpfen.",
"lastMonth": "Für den ausgewählten Monat ist das Schöpfungslimit erreicht.",
"thisMonth": "Für den aktuellen Monat ist das Schöpfungslimit erreicht."
},
"selectDate": "Wann war dein Beitrag?",
"submit": "Einreichen",
"submitted": "Der Beitrag wurde eingereicht.",
@ -164,7 +173,7 @@
"GDD": "GDD",
"gddKonto": "GDD Konto",
"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.",
"choose-amount": "Wähle einen Betrag aus, welchen du per Link versenden möchtest, und trage eine Nachricht ein. Die Nachricht ist ein Pflichtfeld.",
"copy-link": "Link kopieren",
"copy-link-with-text": "Link und Text kopieren",
"created": "Der Link wurde erstellt!",
@ -251,7 +260,7 @@
},
"openHours": "Offene Stunden",
"pageTitle": {
"community": "Meine Gemeinschaft",
"community": "Gradido schöpfen",
"gdt": "Deine GDT Transaktionen",
"information": "{community}",
"overview": "Willkommen {name}",
@ -263,6 +272,7 @@
"send_gdd": "GDD versenden",
"send_per_link": "GDD versenden per Link",
"session": {
"automaticallyLoggedOut": "Du wurdest automatisch abgemeldet",
"extend": "Angemeldet bleiben",
"lightText": "Wenn du länger als 10 Minuten keine Aktion getätigt hast, wirst du aus Sicherheitsgründen abgemeldet.",
"logoutIn": "Abmelden in ",

View File

@ -50,14 +50,23 @@
"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.",
"bringYourTalentsTo": "Bring your talents to the community! We reward your voluntary commitment with 20 GDD per hour up to a maximum of 1,000 GDD per month.",
"describeYourCommunity": "Describe your community service activity and specify the number of hours! The amount of 20 GDD per hour will be calculated automatically. 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"
"yourContribution": "Your Contributions to the Common Good"
},
"lastContribution": "Last Contributions",
"noContributions": {
"allContributions": "No contributions have been submitted yet.",
"myContributions": "You have not submitted any entries yet."
},
"noDateSelected": "Choose any date in the month",
"noOpenCreation": {
"allMonth": "For all two months your creation limit is reached. The next month you can create 1000 GDD again.",
"lastMonth": "The creation limit is reached for the selected month.",
"thisMonth": "The creation limit has been reached for the current month."
},
"selectDate": "When was your contribution?",
"submit": "Submit",
"submitted": "The contribution was submitted.",
@ -164,7 +173,7 @@
"GDD": "GDD",
"gddKonto": "GDD Konto",
"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.",
"choose-amount": "Select an amount you want to send via link and enter a message. The message is mandatory.",
"copy-link": "Copy link",
"copy-link-with-text": "Copy link and text",
"created": "Link was created!",
@ -251,7 +260,7 @@
},
"openHours": "Open Hours",
"pageTitle": {
"community": "My community",
"community": "Create Gradido",
"gdt": "Your GDT transactions",
"information": "{community}",
"overview": "Welcome {name}",
@ -263,6 +272,7 @@
"send_gdd": "Send GDD",
"send_per_link": "Send GDD via Link",
"session": {
"automaticallyLoggedOut": "You have been automatically logged out.",
"extend": "Stay logged in",
"lightText": "If you have not performed any action for more than 10 minutes, you will be logged out for security reasons.",
"logoutIn": "Log out in ",

View File

@ -18,11 +18,18 @@ export const toasters = {
variant: 'warning',
})
},
toastInfoNoHide(message) {
this.toast(message, {
title: this.$t('navigation.info'),
variant: 'warning',
noAutoHide: true,
})
},
toast(message, options) {
if (message.replace) message = message.replace(/^GraphQL error: /, '')
this.$root.$bvToast.toast(message, {
autoHideDelay: 5000,
appendToast: true,
autoHideDelay: 5000,
solid: true,
toaster: 'b-toaster-top-right',
headerClass: 'gdd-toaster-title',

View File

@ -70,6 +70,8 @@ describe('Community', () => {
lastName: 'Bloxberg',
state: 'IN_PROGRESS',
messagesCount: 0,
deniedAt: null,
deniedBy: null,
},
{
id: 1550,
@ -84,6 +86,8 @@ describe('Community', () => {
lastName: 'Bloxberg',
state: 'CONFIRMED',
messagesCount: 0,
deniedAt: null,
deniedBy: null,
},
],
contributionCount: 1,
@ -112,6 +116,10 @@ describe('Community', () => {
confirmedAt: null,
firstName: 'Bibi',
lastName: 'Bloxberg',
deniedAt: null,
deniedBy: null,
messagesCount: 0,
state: 'IN_PROGRESS',
},
{
id: 1550,
@ -124,7 +132,10 @@ describe('Community', () => {
firstName: 'Bibi',
contributionDate: '2022-06-15T08:47:06.000Z',
lastName: 'Bloxberg',
deniedAt: null,
deniedBy: null,
messagesCount: 0,
state: 'IN_PROGRESS',
},
{
id: 1556,
@ -137,6 +148,10 @@ describe('Community', () => {
confirmedAt: null,
firstName: 'Bob',
lastName: 'der Baumeister',
deniedAt: null,
deniedBy: null,
messagesCount: 0,
state: 'IN_PROGRESS',
},
],
contributionCount: 3,

View File

@ -20,6 +20,10 @@
/>
</b-tab>
<b-tab no-body>
<div v-if="items.length === 0">
{{ $t('contribution.noContributions.myContributions') }}
</div>
<div v-else>
<contribution-list
@closeAllOpenCollapse="closeAllOpenCollapse"
:items="items"
@ -31,8 +35,13 @@
:showPagination="true"
:pageSize="pageSize"
/>
</div>
</b-tab>
<b-tab no-body>
<div v-if="itemsAll.length === 0">
{{ $t('contribution.noContributions.allContributions') }}
</div>
<div v-else>
<contribution-list
:items="itemsAll"
@update-list-contributions="updateListAllContributions"
@ -42,6 +51,7 @@
:pageSize="pageSizeAll"
:allContribution="true"
/>
</div>
</b-tab>
</b-tabs>
</div>
@ -296,8 +306,8 @@ export default {
},
created() {
this.updateTransactions(0)
this.tabIndex = 1
this.$router.push({ path: '/community#my' })
this.tabIndex = 0
this.$router.push({ path: '/community#edit' })
},
}
</script>

View File

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