merge with master

This commit is contained in:
einhorn_b 2021-10-02 12:38:17 +02:00
commit e84b96a1f9
71 changed files with 1320 additions and 4231 deletions

View File

@ -328,7 +328,7 @@ jobs:
docker run -v ~/coverage:/app/coverage --rm gradido/frontend:test yarn run test
cp -r ~/coverage ./coverage
##########################################################################
# COVERAGE REPORT FRONTEND ################################################
# COVERAGE REPORT FRONTEND ###############################################
##########################################################################
#- name: frontend | Coverage report
# uses: romeovs/lcov-reporter-action@v0.2.21
@ -344,9 +344,51 @@ jobs:
report_name: Coverage Frontend
type: lcov
result_path: ./coverage/lcov.info
min_coverage: 67
min_coverage: 69
token: ${{ github.token }}
##############################################################################
# JOB: UNIT TEST BACKEND ####################################################
##############################################################################
unit_test_backend:
name: Unit tests - Backend
runs-on: ubuntu-latest
needs: [build_test_backend]
steps:
##########################################################################
# CHECKOUT CODE ##########################################################
##########################################################################
- name: Checkout code
uses: actions/checkout@v2
##########################################################################
# DOWNLOAD DOCKER IMAGES #################################################
##########################################################################
- name: Download Docker Image (Backend)
uses: actions/download-artifact@v2
with:
name: docker-backend-test
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/backend.tar
##########################################################################
# UNIT TESTS BACKEND #####################################################
##########################################################################
- name: backend | Unit tests
run: |
docker run -v ~/coverage:/app/coverage --rm gradido/backend:test yarn run test
cp -r ~/coverage ./coverage
##########################################################################
# COVERAGE CHECK BACKEND #################################################
##########################################################################
- name: backend | Coverage check
uses: webcraftmedia/coverage-check-action@master
with:
report_name: Coverage Backend
type: lcov
result_path: ./coverage/lcov.info
min_coverage: 4
token: ${{ github.token }}
##############################################################################
# JOB: UNIT TEST LOGIN-SERVER ###############################################
##############################################################################

1
backend/.gitignore vendored
View File

@ -2,5 +2,6 @@
/.env
/build/
coverage
# emacs
*~

7
backend/jest.config.js Normal file
View File

@ -0,0 +1,7 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
verbose: true,
preset: 'ts-jest',
collectCoverage: true,
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**'],
}

View File

@ -12,9 +12,11 @@
"clean": "tsc --build --clean",
"start": "node build/index.js",
"dev": "nodemon -w src --ext ts --exec ts-node src/index.ts",
"lint": "eslint . --ext .js,.ts"
"lint": "eslint . --ext .js,.ts",
"test": "jest --coverage"
},
"dependencies": {
"@types/jest": "^27.0.2",
"apollo-server-express": "^2.25.2",
"axios": "^0.21.1",
"class-validator": "^0.13.1",
@ -22,10 +24,12 @@
"dotenv": "^10.0.0",
"express": "^4.17.1",
"graphql": "^15.5.1",
"jest": "^27.2.4",
"jsonwebtoken": "^8.5.1",
"libsodium-wrappers": "^0.7.9",
"mysql2": "^2.3.0",
"reflect-metadata": "^0.1.13",
"ts-jest": "^27.0.5",
"type-graphql": "^1.1.1",
"typeorm": "^0.2.37"
},

View File

@ -15,9 +15,10 @@ export const isAuthorized: AuthChecker<any> = async ({ root, args, context, info
`${CONFIG.LOGIN_API_URL}checkSessionState?session_id=${decoded.sessionId}`,
)
context.sessionId = decoded.sessionId
context.setHeaders.push({ key: 'token', value: encode(decoded.sessionId) })
context.pubKey = decoded.pubKey
context.setHeaders.push({ key: 'token', value: encode(decoded.sessionId, decoded.pubKey) })
return result.success
}
}
return false
throw new Error('401 Unauthorized')
}

View File

@ -0,0 +1,13 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export default class ChangePasswordArgs {
@Field(() => Number)
sessionId: number
@Field(() => String)
email: string
@Field(() => String)
password: string
}

View File

@ -0,0 +1,10 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export default class CheckUsernameArgs {
@Field(() => String)
username: string
@Field(() => Number, { nullable: true })
groupId?: number
}

View File

@ -0,0 +1,19 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export default class CreateUserArgs {
@Field(() => String)
email: string
@Field(() => String)
firstName: string
@Field(() => String)
lastName: string
@Field(() => String)
password: string
@Field(() => String)
language: string
}

View File

@ -0,0 +1,14 @@
import { ArgsType, Field, Int } from 'type-graphql'
import { Order } from '../enum/Order'
@ArgsType()
export default class Paginated {
@Field(() => Int, { nullable: true })
currentPage?: number
@Field(() => Int, { nullable: true })
pageSize?: number
@Field(() => Order, { nullable: true })
order?: Order
}

View File

@ -1,7 +1,7 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export class SubscribeNewsletterArguments {
export default class SubscribeNewsletterArgs {
@Field(() => String)
email: string

View File

@ -0,0 +1,13 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export default class TransactionSendArgs {
@Field(() => String)
email: string
@Field(() => Number)
amount: number
@Field(() => String)
memo: string
}

View File

@ -0,0 +1,10 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export default class UnsecureLoginArgs {
@Field(() => String)
email: string
@Field(() => String)
password: string
}

View File

@ -0,0 +1,31 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export default class UpdateUserInfosArgs {
@Field(() => String)
email!: string
@Field({ nullable: true })
firstName?: string
@Field({ nullable: true })
lastName?: string
@Field({ nullable: true })
description?: string
@Field({ nullable: true })
username?: string
@Field({ nullable: true })
language?: string
@Field({ nullable: true })
publisherId?: number
@Field({ nullable: true })
password?: string
@Field({ nullable: true })
passwordNew?: string
}

View File

@ -0,0 +1,16 @@
import { registerEnumType } from 'type-graphql'
export enum GdtEntryType {
FORM = 1,
CVS = 2,
ELOPAGE = 3,
ELOPAGE_PUBLISHER = 4,
DIGISTORE = 5,
CVS2 = 6,
GLOBAL_MODIFICATOR = 7,
}
registerEnumType(GdtEntryType, {
name: 'GdtEntryType', // this one is mandatory
description: 'Gdt Entry Source Type', // this one is optional
})

View File

@ -0,0 +1,11 @@
import { registerEnumType } from 'type-graphql'
export enum Order {
ASC = 'ASC',
DESC = 'DESC',
}
registerEnumType(Order, {
name: 'Order', // this one is mandatory
description: 'Order direction - ascending or descending', // this one is optional
})

View File

@ -0,0 +1,12 @@
import { registerEnumType } from 'type-graphql'
export enum TransactionType {
CREATION = 'creation',
SEND = 'send',
RECIEVE = 'receive',
}
registerEnumType(TransactionType, {
name: 'TransactionType', // this one is mandatory
description: 'Name of the Type of the transaction', // this one is optional
})

View File

@ -0,0 +1,11 @@
import { registerEnumType } from 'type-graphql'
export enum TransactionTypeId {
CREATION = 1,
SEND = 2,
}
registerEnumType(TransactionTypeId, {
name: 'TransactionTypeId', // this one is mandatory
description: 'Type of the transaction', // this one is optional
})

View File

@ -1,28 +0,0 @@
import { ArgsType, Field, Int } from 'type-graphql'
@ArgsType()
export class GdtTransactionInput {
@Field(() => String)
email: string
@Field(() => Int, { nullable: true })
currentPage?: number
@Field(() => Int, { nullable: true })
pageSize?: number
@Field(() => String, { nullable: true })
order?: string
}
@ArgsType()
export class GdtTransactionSessionIdInput {
@Field(() => Int, { nullable: true })
currentPage?: number
@Field(() => Int, { nullable: true })
pageSize?: number
@Field(() => String, { nullable: true })
order?: string
}

View File

@ -1,79 +0,0 @@
import { ArgsType, Field } from 'type-graphql'
@ArgsType()
export class UnsecureLoginArgs {
@Field(() => String)
email: string
@Field(() => String)
password: string
}
@ArgsType()
export class CreateUserArgs {
@Field(() => String)
email: string
@Field(() => String)
firstName: string
@Field(() => String)
lastName: string
@Field(() => String)
password: string
@Field(() => String)
language: string
}
@ArgsType()
export class ChangePasswordArgs {
@Field(() => Number)
sessionId: number
@Field(() => String)
email: string
@Field(() => String)
password: string
}
@ArgsType()
export class UpdateUserInfosArgs {
@Field(() => String)
email!: string
@Field({ nullable: true })
firstName?: string
@Field({ nullable: true })
lastName?: string
@Field({ nullable: true })
description?: string
@Field({ nullable: true })
username?: string
@Field({ nullable: true })
language?: string
@Field({ nullable: true })
publisherId?: number
@Field({ nullable: true })
password?: string
@Field({ nullable: true })
passwordNew?: string
}
@ArgsType()
export class CheckUsernameArgs {
@Field(() => String)
username: string
@Field(() => Number, { nullable: true })
groupId?: number
}

View File

@ -1,25 +0,0 @@
import { ArgsType, Field, Int } from 'type-graphql'
@ArgsType()
export class TransactionListInput {
@Field(() => Int)
firstPage: number
@Field(() => Int)
items: number
@Field(() => String)
order: string
}
@ArgsType()
export class TransactionSendArgs {
@Field(() => String)
email: string
@Field(() => Number)
amount: number
@Field(() => String)
memo: string
}

View File

@ -5,25 +5,29 @@ import { ObjectType, Field, Int } from 'type-graphql'
@ObjectType()
export class Decay {
constructor(json: any) {
this.balance = Number(json.balance)
this.decayStart = json.decay_start
this.decayEnd = json.decay_end
this.decayDuration = json.decay_duration
this.decayStartBlock = json.decay_start_block
if (json) {
this.balance = Number(json.balance)
this.decayStart = json.decay_start
this.decayEnd = json.decay_end
this.decayDuration = json.decay_duration
this.decayStartBlock = json.decay_start_block
}
}
@Field(() => Number)
balance: number
// timestamp in seconds
@Field(() => Int, { nullable: true })
decayStart?: number
decayStart: string
// timestamp in seconds
@Field(() => Int, { nullable: true })
decayEnd?: number
decayEnd: string
@Field(() => String, { nullable: true })
decayDuration?: string
decayDuration?: number
@Field(() => Int, { nullable: true })
decayStartBlock?: number
decayStartBlock?: string
}

View File

@ -1,16 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field } from 'type-graphql'
export enum GdtEntryType {
FORM = 1,
CVS = 2,
ELOPAGE = 3,
ELOPAGE_PUBLISHER = 4,
DIGISTORE = 5,
CVS2 = 6,
GLOBAL_MODIFICATOR = 7,
}
import { GdtEntryType } from '../enum/GdtEntryType'
@ObjectType()
export class GdtEntry {
@ -46,7 +37,7 @@ export class GdtEntry {
@Field(() => String)
couponCode: string
@Field(() => Number)
@Field(() => GdtEntryType)
gdtEntryType: GdtEntryType
@Field(() => Number)

View File

@ -3,20 +3,6 @@
import { GdtEntry } from './GdtEntry'
import { ObjectType, Field } from 'type-graphql'
@ObjectType()
export class GdtSumPerEmail {
constructor(email: string, summe: number) {
this.email = email
this.summe = summe
}
@Field(() => String)
email: string
@Field(() => Number)
summe: number
}
@ObjectType()
export class GdtEntryList {
constructor(json: any) {

View File

@ -0,0 +1,19 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/*
import { ObjectType, Field } from 'type-graphql'
@ObjectType()
export class GdtSumPerEmail {
constructor(email: string, summe: number) {
this.email = email
this.summe = summe
}
@Field(() => String)
email: string
@Field(() => Number)
summe: number
}
*/

View File

@ -10,18 +10,11 @@ import { Decay } from './Decay'
@ObjectType()
export class Transaction {
constructor(json: any) {
this.type = json.type
this.balance = Number(json.balance)
this.decayStart = json.decay_start
this.decayEnd = json.decay_end
this.decayDuration = json.decay_duration
this.memo = json.memo
this.transactionId = json.transaction_id
this.name = json.name
this.email = json.email
this.date = json.date
this.decay = json.decay ? new Decay(json.decay) : undefined
constructor() {
this.type = ''
this.balance = 0
this.totalBalance = 0
this.memo = ''
}
@Field(() => String)
@ -30,14 +23,17 @@ export class Transaction {
@Field(() => Number)
balance: number
@Field({ nullable: true })
decayStart?: number
@Field(() => Number)
totalBalance: number
@Field({ nullable: true })
decayEnd?: number
decayStart?: string
@Field({ nullable: true })
decayDuration?: string
decayEnd?: string
@Field({ nullable: true })
decayDuration?: number
@Field(() => String)
memo: string
@ -57,35 +53,3 @@ export class Transaction {
@Field({ nullable: true })
decay?: Decay
}
@ObjectType()
export class TransactionList {
constructor(json: any) {
this.gdtSum = Number(json.gdtSum)
this.count = json.count
this.balance = Number(json.balance)
this.decay = Number(json.decay)
this.decayDate = json.decay_date
this.transactions = json.transactions.map((el: any) => {
return new Transaction(el)
})
}
@Field(() => Number)
gdtSum: number
@Field(() => Number)
count: number
@Field(() => Number)
balance: number
@Field(() => Number)
decay: number
@Field(() => String)
decayDate: string
@Field(() => [Transaction])
transactions: Transaction[]
}

View File

@ -0,0 +1,33 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { ObjectType, Field } from 'type-graphql'
import { Transaction } from './Transaction'
@ObjectType()
export class TransactionList {
constructor() {
this.gdtSum = 0
this.count = 0
this.balance = 0
this.decay = 0
this.decayDate = ''
}
@Field(() => Number)
gdtSum: number
@Field(() => Number)
count: number
@Field(() => Number)
balance: number
@Field(() => Number)
decay: number
@Field(() => String)
decayDate: string
@Field(() => [Transaction])
transactions: Transaction[]
}

View File

@ -2,12 +2,11 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Resolver, Query, Ctx, Authorized } from 'type-graphql'
import CONFIG from '../../config'
import { getCustomRepository } from 'typeorm'
import { Balance } from '../models/Balance'
import { apiGet } from '../../apis/HttpRequest'
import { User as dbUser } from '../../typeorm/entity/User'
import { Balance as dbBalance } from '../../typeorm/entity/Balance'
import calculateDecay from '../../util/decay'
import { BalanceRepository } from '../../typeorm/repository/Balance'
import { UserRepository } from '../../typeorm/repository/User'
import { calculateDecay } from '../../util/decay'
import { roundFloorFrom4 } from '../../util/round'
@Resolver()
@ -15,20 +14,29 @@ export class BalanceResolver {
@Authorized()
@Query(() => Balance)
async balance(@Ctx() context: any): Promise<Balance> {
// get public key for current logged in user
const result = await apiGet(CONFIG.LOGIN_API_URL + 'login?session_id=' + context.sessionId)
if (!result.success) throw new Error(result.data)
// load user and balance
const userEntity = await dbUser.findByPubkeyHex(result.data.user.public_hex)
const balanceEntity = await dbBalance.findByUser(userEntity.id)
const now = new Date()
const balance = new Balance({
balance: roundFloorFrom4(balanceEntity.amount),
decay: roundFloorFrom4(calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now)),
decay_date: now.toString(),
})
const balanceRepository = getCustomRepository(BalanceRepository)
const userRepository = getCustomRepository(UserRepository)
const userEntity = await userRepository.findByPubkeyHex(context.pubKey)
const balanceEntity = await balanceRepository.findByUser(userEntity.id)
let balance: Balance
const now = new Date()
if (balanceEntity) {
balance = new Balance({
balance: roundFloorFrom4(balanceEntity.amount),
decay: roundFloorFrom4(
await calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now),
),
decay_date: now.toString(),
})
} else {
balance = new Balance({
balance: 0,
decay: 0,
decay_date: now.toString(),
})
}
return balance
}
}

View File

@ -2,11 +2,13 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Resolver, Query, Args, Ctx, Authorized } from 'type-graphql'
import { getCustomRepository } from 'typeorm'
import CONFIG from '../../config'
import { GdtEntryList } from '../models/GdtEntryList'
import { GdtTransactionSessionIdInput } from '../inputs/GdtInputs'
import Paginated from '../args/Paginated'
import { apiGet } from '../../apis/HttpRequest'
import { User as dbUser } from '../../typeorm/entity/User'
import { UserRepository } from '../../typeorm/repository/User'
import { Order } from '../enum/Order'
@Resolver()
export class GdtResolver {
@ -15,21 +17,18 @@ export class GdtResolver {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async listGDTEntries(
@Args()
{ currentPage = 1, pageSize = 5, order = 'DESC' }: GdtTransactionSessionIdInput,
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Ctx() context: any,
): Promise<GdtEntryList> {
// get public key for current logged in user
const result = await apiGet(CONFIG.LOGIN_API_URL + 'login?session_id=' + context.sessionId)
if (!result.success) throw new Error(result.data)
// load user
const userEntity = await dbUser.findByPubkeyHex(result.data.user.public_hex)
const userRepository = getCustomRepository(UserRepository)
const userEntity = await userRepository.findByPubkeyHex(context.pubKey)
const resultGDT = await apiGet(
`${CONFIG.GDT_API_URL}/GdtEntries/listPerEmailApi/${userEntity.email}/${currentPage}/${pageSize}/${order}`,
)
if (!resultGDT.success) {
throw new Error(result.data)
throw new Error(resultGDT.data)
}
return new GdtEntryList(resultGDT.data)

View File

@ -8,7 +8,7 @@ import {
unsubscribe,
signIn,
} from '../../apis/KlicktippController'
import { SubscribeNewsletterArguments } from '../inputs/KlickTippInputs'
import SubscribeNewsletterArgs from '../args/SubscribeNewsletterArgs'
@Resolver()
export class KlicktippResolver {
@ -33,7 +33,7 @@ export class KlicktippResolver {
@Authorized()
@Mutation(() => Boolean)
async subscribeNewsletter(
@Args() { email, language }: SubscribeNewsletterArguments,
@Args() { email, language }: SubscribeNewsletterArgs,
): Promise<boolean> {
return await signIn(email, language)
}

View File

@ -2,24 +2,250 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Resolver, Query, Args, Authorized, Ctx, Mutation } from 'type-graphql'
import { getCustomRepository } from 'typeorm'
import CONFIG from '../../config'
import { TransactionList } from '../models/Transaction'
import { TransactionListInput, TransactionSendArgs } from '../inputs/TransactionInput'
import { Transaction } from '../models/Transaction'
import { TransactionList } from '../models/TransactionList'
import TransactionSendArgs from '../args/TransactionSendArgs'
import Paginated from '../args/Paginated'
import { Order } from '../enum/Order'
import { BalanceRepository } from '../../typeorm/repository/Balance'
import { UserRepository } from '../../typeorm/repository/User'
import { UserTransactionRepository } from '../../typeorm/repository/UserTransaction'
import { TransactionRepository } from '../../typeorm/repository/Transaction'
import { User as dbUser } from '../../typeorm/entity/User'
import { UserTransaction as dbUserTransaction } from '../../typeorm/entity/UserTransaction'
import { Transaction as dbTransaction } from '../../typeorm/entity/Transaction'
import { apiGet, apiPost } from '../../apis/HttpRequest'
import { roundFloorFrom4 } from '../../util/round'
import { calculateDecay, calculateDecayWithInterval } from '../../util/decay'
import { TransactionTypeId } from '../enum/TransactionTypeId'
import { TransactionType } from '../enum/TransactionType'
// Helper function
async function calculateAndAddDecayTransactions(
userTransactions: dbUserTransaction[],
user: dbUser,
decay: boolean,
skipFirstTransaction: boolean,
): Promise<Transaction[]> {
const finalTransactions: Transaction[] = []
const transactionIds: number[] = []
const involvedUserIds: number[] = []
userTransactions.forEach((userTransaction: dbUserTransaction) => {
transactionIds.push(userTransaction.transactionId)
})
const transactionRepository = getCustomRepository(TransactionRepository)
const transactions = await transactionRepository.joinFullTransactionsByIds(transactionIds)
const transactionIndiced: dbTransaction[] = []
transactions.forEach((transaction: dbTransaction) => {
transactionIndiced[transaction.id] = transaction
if (transaction.transactionTypeId === 2) {
involvedUserIds.push(transaction.transactionSendCoin.userId)
involvedUserIds.push(transaction.transactionSendCoin.recipiantUserId)
}
})
// remove duplicates
// https://stackoverflow.com/questions/1960473/get-all-unique-values-in-a-javascript-array-remove-duplicates
const involvedUsersUnique = involvedUserIds.filter((v, i, a) => a.indexOf(v) === i)
const userRepository = getCustomRepository(UserRepository)
const userIndiced = await userRepository.getUsersIndiced(involvedUsersUnique)
const decayStartTransaction = await transactionRepository.findDecayStartBlock()
for (let i = 0; i < userTransactions.length; i++) {
const userTransaction = userTransactions[i]
const transaction = transactionIndiced[userTransaction.transactionId]
const finalTransaction = new Transaction()
finalTransaction.transactionId = transaction.id
finalTransaction.date = transaction.received.toISOString()
finalTransaction.memo = transaction.memo
finalTransaction.totalBalance = roundFloorFrom4(userTransaction.balance)
const prev = i > 0 ? userTransactions[i - 1] : null
if (prev && prev.balance > 0) {
const current = userTransaction
const decay = await calculateDecayWithInterval(
prev.balance,
prev.balanceDate,
current.balanceDate,
)
const balance = prev.balance - decay.balance
if (balance) {
finalTransaction.decay = decay
finalTransaction.decay.balance = roundFloorFrom4(balance)
if (
decayStartTransaction &&
prev.transactionId < decayStartTransaction.id &&
current.transactionId > decayStartTransaction.id
) {
finalTransaction.decay.decayStartBlock = (
decayStartTransaction.received.getTime() / 1000
).toString()
}
}
}
// sender or receiver when user has sended money
// group name if creation
// type: gesendet / empfangen / geschöpft
// transaktion nr / id
// date
// balance
if (userTransaction.transactionTypeId === TransactionTypeId.CREATION) {
// creation
const creation = transaction.transactionCreation
finalTransaction.name = 'Gradido Akademie'
finalTransaction.type = TransactionType.CREATION
// finalTransaction.targetDate = creation.targetDate
finalTransaction.balance = roundFloorFrom4(creation.amount)
} else if (userTransaction.transactionTypeId === TransactionTypeId.SEND) {
// send coin
const sendCoin = transaction.transactionSendCoin
let otherUser: dbUser | undefined
finalTransaction.balance = roundFloorFrom4(sendCoin.amount)
if (sendCoin.userId === user.id) {
finalTransaction.type = TransactionType.SEND
otherUser = userIndiced[sendCoin.recipiantUserId]
// finalTransaction.pubkey = sendCoin.recipiantPublic
} else if (sendCoin.recipiantUserId === user.id) {
finalTransaction.type = TransactionType.RECIEVE
otherUser = userIndiced[sendCoin.userId]
// finalTransaction.pubkey = sendCoin.senderPublic
} else {
throw new Error('invalid transaction')
}
if (otherUser) {
finalTransaction.name = otherUser.firstName + ' ' + otherUser.lastName
finalTransaction.email = otherUser.email
}
}
if (i > 0 || !skipFirstTransaction) {
finalTransactions.push(finalTransaction)
}
if (i === userTransactions.length - 1 && decay) {
const now = new Date()
const decay = await calculateDecayWithInterval(
userTransaction.balance,
userTransaction.balanceDate,
now.getTime(),
)
const balance = userTransaction.balance - decay.balance
if (balance) {
const decayTransaction = new Transaction()
decayTransaction.type = 'decay'
decayTransaction.balance = roundFloorFrom4(balance)
decayTransaction.decayDuration = decay.decayDuration
decayTransaction.decayStart = decay.decayStart
decayTransaction.decayEnd = decay.decayEnd
finalTransactions.push(decayTransaction)
}
}
}
return finalTransactions
}
// Helper function
async function listTransactions(
currentPage: number,
pageSize: number,
order: Order,
user: dbUser,
): Promise<TransactionList> {
let limit = pageSize
let offset = 0
let skipFirstTransaction = false
if (currentPage > 1) {
offset = (currentPage - 1) * pageSize - 1
limit++
}
if (offset && order === Order.ASC) {
offset--
}
const userTransactionRepository = getCustomRepository(UserTransactionRepository)
let [userTransactions, userTransactionsCount] = await userTransactionRepository.findByUserPaged(
user.id,
limit,
offset,
order,
)
skipFirstTransaction = userTransactionsCount > offset + limit
const decay = !(currentPage > 1)
let transactions: Transaction[] = []
if (userTransactions.length) {
if (order === Order.DESC) {
userTransactions = userTransactions.reverse()
}
transactions = await calculateAndAddDecayTransactions(
userTransactions,
user,
decay,
skipFirstTransaction,
)
if (order === Order.DESC) {
transactions = transactions.reverse()
}
}
const transactionList = new TransactionList()
transactionList.count = userTransactionsCount
transactionList.transactions = transactions
return transactionList
}
@Resolver()
export class TransactionResolver {
@Authorized()
@Query(() => TransactionList)
async transactionList(
@Args() { firstPage = 1, items = 25, order = 'DESC' }: TransactionListInput,
@Args() { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
@Ctx() context: any,
): Promise<TransactionList> {
const result = await apiGet(
`${CONFIG.COMMUNITY_API_URL}listTransactions/${firstPage}/${items}/${order}/${context.sessionId}`,
)
// get public key for current logged in user
const result = await apiGet(CONFIG.LOGIN_API_URL + 'login?session_id=' + context.sessionId)
if (!result.success) throw new Error(result.data)
return new TransactionList(result.data)
// load user
const userRepository = getCustomRepository(UserRepository)
const userEntity = await userRepository.findByPubkeyHex(result.data.user.public_hex)
const transactions = await listTransactions(currentPage, pageSize, order, userEntity)
// get gdt sum
const resultGDTSum = await apiPost(`${CONFIG.GDT_API_URL}/GdtEntries/sumPerEmailApi`, {
email: userEntity.email,
})
if (!resultGDTSum.success) throw new Error(resultGDTSum.data)
transactions.gdtSum = resultGDTSum.data.sum
// get balance
const balanceRepository = getCustomRepository(BalanceRepository)
const balanceEntity = await balanceRepository.findByUser(userEntity.id)
if (balanceEntity) {
const now = new Date()
transactions.balance = roundFloorFrom4(balanceEntity.amount)
transactions.decay = roundFloorFrom4(
await calculateDecay(balanceEntity.amount, balanceEntity.recordDate, now),
)
transactions.decayDate = now.toString()
}
return transactions
}
@Authorized()

View File

@ -11,19 +11,18 @@ import { UpdateUserInfosResponse } from '../models/UpdateUserInfosResponse'
import { User } from '../models/User'
import { User as DbUser } from '../../typeorm/entity/User'
import encode from '../../jwt/encode'
import {
ChangePasswordArgs,
CheckUsernameArgs,
CreateUserArgs,
UnsecureLoginArgs,
UpdateUserInfosArgs,
} from '../inputs/LoginUserInput'
import ChangePasswordArgs from '../args/ChangePasswordArgs'
import CheckUsernameArgs from '../args/CheckUsernameArgs'
import CreateUserArgs from '../args/CreateUserArgs'
import UnsecureLoginArgs from '../args/UnsecureLoginArgs'
import UpdateUserInfosArgs from '../args/UpdateUserInfosArgs'
import { apiPost, apiGet } from '../../apis/HttpRequest'
import {
klicktippRegistrationMiddleware,
klicktippNewsletterStateMiddleware,
} from '../../middleware/klicktippMiddleware'
import { CheckEmailResponse } from '../models/CheckEmailResponse'
@Resolver()
export class UserResolver {
@Query(() => User)
@ -37,7 +36,10 @@ export class UserResolver {
throw new Error(result.data)
}
context.setHeaders.push({ key: 'token', value: encode(result.data.session_id) })
context.setHeaders.push({
key: 'token',
value: encode(result.data.session_id, result.data.user.public_hex),
})
return new User(result.data.user)
}

View File

@ -1,18 +1,29 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import jwt from 'jsonwebtoken'
import jwt, { JwtPayload } from 'jsonwebtoken'
import CONFIG from '../config/'
export default (token: string): any => {
if (!token) return new Error('401 Unauthorized')
interface CustomJwtPayload extends JwtPayload {
sessionId: number
pubKey: Buffer
}
type DecodedJwt = {
token: string
sessionId: number
pubKey: Buffer
}
export default (token: string): DecodedJwt => {
if (!token) throw new Error('401 Unauthorized')
let sessionId = null
let pubKey = null
try {
const decoded = jwt.verify(token, CONFIG.JWT_SECRET)
sessionId = decoded.sub
const decoded = <CustomJwtPayload>jwt.verify(token, CONFIG.JWT_SECRET)
sessionId = decoded.sessionId
pubKey = decoded.pubKey
return {
token,
sessionId,
pubKey,
}
} catch (err) {
throw new Error('403.13 - Client certificate revoked')

View File

@ -5,8 +5,8 @@ import jwt from 'jsonwebtoken'
import CONFIG from '../config/'
// Generate an Access Token
export default function encode(sessionId: string): string {
const token = jwt.sign({ sessionId }, CONFIG.JWT_SECRET, {
export default function encode(sessionId: number, pubKey: Buffer): string {
const token = jwt.sign({ sessionId, pubKey }, CONFIG.JWT_SECRET, {
expiresIn: CONFIG.JWT_EXPIRES_IN,
subject: sessionId.toString(),
})

View File

@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
const context = (args: any) => {
const authorization = args.req.headers.authorization
let token = null

View File

@ -1,3 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
const plugins = [
{
requestDidStart() {

View File

@ -16,10 +16,4 @@ export class Balance extends BaseEntity {
@Column({ type: 'bigint' })
amount: number
static findByUser(userId: number): Promise<Balance> {
return this.createQueryBuilder('balance')
.where('balance.userId = :userId', { userId })
.getOneOrFail()
}
}

View File

@ -1,25 +0,0 @@
/* import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm";
import { User } from "./User"
@Entity()
export class Group {
@PrimaryGeneratedColumn()
id: number;
@Column()
alias: string;
@Column()
name: string;
@Column()
url: string;
@Column()
description: string;
@OneToMany(type => User, user => user.group)
users: User[];
} */

View File

@ -0,0 +1,30 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm'
import { TransactionCreation } from './TransactionCreation'
import { TransactionSendCoin } from './TransactionSendCoin'
@Entity('transactions')
export class Transaction extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ name: 'transaction_type_id' })
transactionTypeId: number
@Column({ name: 'tx_hash', type: 'binary', length: 48 })
txHash: Buffer
@Column()
memo: string
@Column({ type: 'timestamp' })
received: Date
@Column({ name: 'blockchain_type_id' })
blockchainTypeId: number
@OneToOne(() => TransactionSendCoin, (transactionSendCoin) => transactionSendCoin.transaction)
transactionSendCoin: TransactionSendCoin
@OneToOne(() => TransactionCreation, (transactionCreation) => transactionCreation.transaction)
transactionCreation: TransactionCreation
}

View File

@ -0,0 +1,32 @@
import {
BaseEntity,
Entity,
PrimaryGeneratedColumn,
Column,
Timestamp,
OneToOne,
JoinColumn,
} from 'typeorm'
import { Transaction } from './Transaction'
@Entity('transaction_creations')
export class TransactionCreation extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ name: 'transaction_id' })
transactionId: number
@Column({ name: 'state_user_id' })
userId: number
@Column()
amount: number
@Column({ name: 'target_date', type: 'timestamp' })
targetDate: Timestamp
@OneToOne(() => Transaction)
@JoinColumn({ name: 'transaction_id' })
transaction: Transaction
}

View File

@ -0,0 +1,30 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne, JoinColumn } from 'typeorm'
import { Transaction } from './Transaction'
@Entity('transaction_send_coins')
export class TransactionSendCoin extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ name: 'transaction_id' })
transactionId: number
@Column({ name: 'sender_public_key', type: 'binary', length: 32 })
senderPublic: Buffer
@Column({ name: 'state_user_id' })
userId: number
@Column({ name: 'receiver_public_key', type: 'binary', length: 32 })
recipiantPublic: Buffer
@Column({ name: 'receiver_user_id' })
recipiantUserId: number
@Column()
amount: number
@OneToOne(() => Transaction)
@JoinColumn({ name: 'transaction_id' })
transaction: Transaction
}

View File

@ -1,6 +1,8 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
// import { Group } from "./Group"
// Moriz: I do not like the idea of having two user tables
@Entity('state_users')
export class User extends BaseEntity {
@PrimaryGeneratedColumn()
@ -26,10 +28,4 @@ export class User extends BaseEntity {
@Column()
disabled: boolean
static findByPubkeyHex(pubkeyHex: string): Promise<User> {
return this.createQueryBuilder('user')
.where('hex(user.pubkey) = :pubkeyHex', { pubkeyHex })
.getOneOrFail()
}
}

View File

@ -0,0 +1,22 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
@Entity('state_user_transactions')
export class UserTransaction extends BaseEntity {
@PrimaryGeneratedColumn()
id: number
@Column({ name: 'state_user_id' })
userId: number
@Column({ name: 'transaction_id' })
transactionId: number
@Column({ name: 'transaction_type_id' })
transactionTypeId: number
@Column({ name: 'balance', type: 'bigint' })
balance: number
@Column({ name: 'balance_date', type: 'timestamp' })
balanceDate: Date
}

View File

@ -0,0 +1,11 @@
import { EntityRepository, Repository } from 'typeorm'
import { Balance } from '../entity/Balance'
@EntityRepository(Balance)
export class BalanceRepository extends Repository<Balance> {
findByUser(userId: number): Promise<Balance | undefined> {
return this.createQueryBuilder('balance')
.where('balance.userId = :userId', { userId })
.getOneOrFail()
}
}

View File

@ -0,0 +1,28 @@
import { EntityRepository, Repository } from 'typeorm'
import { Transaction } from '../entity/Transaction'
@EntityRepository(Transaction)
export class TransactionRepository extends Repository<Transaction> {
async findDecayStartBlock(): Promise<Transaction | undefined> {
return this.createQueryBuilder('transaction')
.where('transaction.transactionTypeId = :transactionTypeId', { transactionTypeId: 9 })
.orderBy('received', 'ASC')
.getOne()
}
async joinFullTransactionsByIds(transactionIds: number[]): Promise<Transaction[]> {
return this.createQueryBuilder('transaction')
.where('transaction.id IN (:...transactions)', { transactions: transactionIds })
.leftJoinAndSelect(
'transaction.transactionSendCoin',
'transactionSendCoin',
// 'transactionSendCoin.transaction_id = transaction.id',
)
.leftJoinAndSelect(
'transaction.transactionCreation',
'transactionCreation',
// 'transactionSendCoin.transaction_id = transaction.id',
)
.getMany()
}
}

View File

@ -0,0 +1,23 @@
import { EntityRepository, Repository } from 'typeorm'
import { User } from '../entity/User'
@EntityRepository(User)
export class UserRepository extends Repository<User> {
async findByPubkeyHex(pubkeyHex: string): Promise<User> {
return this.createQueryBuilder('user')
.where('hex(user.pubkey) = :pubkeyHex', { pubkeyHex })
.getOneOrFail()
}
async getUsersIndiced(userIds: number[]): Promise<User[]> {
const users = await this.createQueryBuilder('user')
.select(['user.id', 'user.firstName', 'user.lastName', 'user.email'])
.where('user.id IN (:...users)', { users: userIds })
.getMany()
const usersIndiced: User[] = []
users.forEach((value) => {
usersIndiced[value.id] = value
})
return usersIndiced
}
}

View File

@ -0,0 +1,20 @@
import { EntityRepository, Repository } from 'typeorm'
import { Order } from '../../graphql/enum/Order'
import { UserTransaction } from '../entity/UserTransaction'
@EntityRepository(UserTransaction)
export class UserTransactionRepository extends Repository<UserTransaction> {
findByUserPaged(
userId: number,
limit: number,
offset: number,
order: Order,
): Promise<[UserTransaction[], number]> {
return this.createQueryBuilder('userTransaction')
.where('userTransaction.userId = :userId', { userId })
.orderBy('userTransaction.balanceDate', order)
.limit(limit)
.offset(offset)
.getManyAndCount()
}
}

View File

@ -0,0 +1,16 @@
import { calculateDecay } from './decay'
describe('utils/decay', () => {
it.skip('has base 0.99999997802044727', async () => {
const now = new Date()
now.setSeconds(1)
const oneSecondAgo = new Date(now.getTime())
oneSecondAgo.setSeconds(0)
expect(await calculateDecay(1.0, oneSecondAgo, now)).toBe(0.99999997802044727)
})
it.skip('returns input amount when from and to is the same', async () => {
const now = new Date()
expect(await calculateDecay(100.0, now, now)).toBe(100.0)
})
})

View File

@ -1,4 +1,59 @@
export default function (amount: number, from: Date, to: Date): number {
const decayDuration = (to.getTime() - from.getTime()) / 1000
return amount * Math.pow(0.99999997802044727, decayDuration)
import { getCustomRepository } from 'typeorm'
import { Decay } from '../graphql/models/Decay'
import { TransactionRepository } from '../typeorm/repository/Transaction'
function decayFormula(amount: number, durationInSeconds: number): number {
return amount * Math.pow(0.99999997802044727, durationInSeconds)
}
async function calculateDecay(amount: number, from: Date, to: Date): Promise<number> {
// load decay start block
const transactionRepository = getCustomRepository(TransactionRepository)
const decayStartBlock = await transactionRepository.findDecayStartBlock()
// if decay hasn't started yet we return input amount
if (!decayStartBlock) return amount
const decayDuration = (to.getTime() - from.getTime()) / 1000
return decayFormula(amount, decayDuration)
}
async function calculateDecayWithInterval(
amount: number,
from: number | Date,
to: number | Date,
): Promise<Decay> {
const transactionRepository = getCustomRepository(TransactionRepository)
const decayStartBlock = await transactionRepository.findDecayStartBlock()
const result = new Decay(undefined)
result.balance = amount
const fromMillis = typeof from === 'number' ? from : from.getTime()
const toMillis = typeof to === 'number' ? to : to.getTime()
result.decayStart = (fromMillis / 1000).toString()
result.decayEnd = (toMillis / 1000).toString()
// (amount, from.getTime(), to.getTime())
// if no decay start block exist or decay startet after end date
if (!decayStartBlock || decayStartBlock.received.getTime() > toMillis) {
return result
}
const decayStartBlockMillis = decayStartBlock.received.getTime()
// if decay start date is before start date we calculate decay for full duration
if (decayStartBlockMillis < fromMillis) {
result.decayDuration = toMillis - fromMillis
}
// if decay start in between start date and end date we caculcate decay from decay start time to end date
else {
result.decayDuration = toMillis - decayStartBlockMillis
result.decayStart = (decayStartBlockMillis / 1000).toString()
}
// js use timestamp in milliseconds but we calculate with seconds
result.decayDuration /= 1000
result.balance = decayFormula(amount, result.decayDuration)
return result
}
export { calculateDecay, calculateDecayWithInterval }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,208 @@
<mxfile host="65bd71144e">
<diagram id="1NBLOcaJ18vLSwe3OBlU" name="Page-1">
<mxGraphModel dx="1337" dy="381" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" background="#F1FAEE" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="78" value="External Service" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="600" y="1400" width="240" height="160" as="geometry"/>
</mxCell>
<mxCell id="60" value="Community Gradido Akademie" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="279" y="840" width="240" height="320" as="geometry"/>
</mxCell>
<mxCell id="57" value="Community B" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="600" y="840" width="240" height="320" as="geometry"/>
</mxCell>
<mxCell id="5" value="Community Gradido Akademie" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="280" y="40" width="240" height="320" as="geometry"/>
</mxCell>
<mxCell id="3" value="Current Process" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=21;fontStyle=1;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry y="10" width="180" height="20" as="geometry"/>
</mxCell>
<mxCell id="9" value="EMail" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;startArrow=classic;startFill=1;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="6" target="7" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="6" value="Backend" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="320" y="80" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="7" value="GDT-Server" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="320" y="240" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="10" value="Community B" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="600" y="40" width="240" height="320" as="geometry"/>
</mxCell>
<mxCell id="14" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;startArrow=none;startFill=0;endArrow=none;endFill=0;strokeWidth=3;labelBackgroundColor=#F1FAEE;strokeColor=#B20000;fontColor=#1D3557;fillColor=#e51400;" parent="1" source="12" target="7" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="12" value="Backend" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="640" y="80" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="16" value="" style="endArrow=none;html=1;strokeWidth=3;labelBackgroundColor=#F1FAEE;strokeColor=#B20000;fontColor=#1D3557;fillColor=#e51400;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="550" y="199" as="sourcePoint"/>
<mxPoint x="570" y="184" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="17" value="" style="endArrow=none;html=1;strokeWidth=3;labelBackgroundColor=#F1FAEE;strokeColor=#B20000;fontColor=#1D3557;fillColor=#e51400;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="550" y="209.5" as="sourcePoint"/>
<mxPoint x="570" y="194.5" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="18" value="The GDT-Server is only reachable internally.&lt;br&gt;&lt;br&gt;Another community cannot query it and therefore can not determin the GDT-Balance or the GDT-Transactions of a user. Since this data is queried via EMail identificator it is not possible to expose the GDT-Server to 3rd party communties for obvious privacy reasons.&lt;br&gt;&lt;br&gt;We need another process to expose the GDT data in a private manner, so the user data is protected and the GDT-Server can be exposed." style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;whiteSpace=wrap;rounded=0;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="10" y="180" width="260" height="180" as="geometry"/>
</mxCell>
<mxCell id="22" value="Community Gradido Akademie" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="280" y="440" width="240" height="320" as="geometry"/>
</mxCell>
<mxCell id="25" value="Suggested Process" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=21;fontStyle=1;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry y="410" width="200" height="20" as="geometry"/>
</mxCell>
<mxCell id="26" value="IdentifierHash" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;startArrow=classic;startFill=1;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="27" target="28" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="27" value="Backend" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="320" y="480" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="28" value="GDT-Server" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="320" y="640" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="29" value="Community B" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="600" y="440" width="240" height="320" as="geometry"/>
</mxCell>
<mxCell id="30" value="IdentifierHash" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;startArrow=classic;startFill=1;endArrow=classic;endFill=1;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="31" target="28" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="31" value="Backend" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="640" y="480" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="34" value="We expose the GDT-Server to the public, but allow the query of the GDT data by an anonymous identifier hash only.&lt;br&gt;This way the data can not be linked with a user and crawling of known EMail adresses is no longer possible.&lt;br&gt;&lt;br&gt;This requires us to provide an user a way to get his identifier hash and our Gradido Akademie Community to automatically obtain it.&lt;br&gt;&lt;br&gt;The following shall describe thoses processes." style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;whiteSpace=wrap;rounded=0;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="10" y="580" width="260" height="180" as="geometry"/>
</mxCell>
<mxCell id="37" value="" style="endArrow=none;html=1;fontSize=21;strokeWidth=3;fillColor=none;labelBackgroundColor=#F1FAEE;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint y="400" as="sourcePoint"/>
<mxPoint x="1090" y="400" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="38" value="" style="endArrow=none;html=1;fontSize=21;strokeWidth=3;fillColor=none;labelBackgroundColor=#F1FAEE;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint y="800" as="sourcePoint"/>
<mxPoint x="1090" y="800" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="39" value="User requests his identifier hash" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=21;fontStyle=1;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry y="810" width="330" height="20" as="geometry"/>
</mxCell>
<mxCell id="59" value="&lt;font style=&quot;font-size: 12px ; line-height: 120%&quot;&gt;Requests identifier hash &lt;br&gt;in his community's frontend&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;strokeWidth=3;jumpSize=6;startSize=6;spacing=2;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;exitX=0.5;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="52" target="58" edge="1">
<mxGeometry x="0.0041" y="-29" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="52" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontSize=21;strokeWidth=2;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="1049" y="889" width="30" height="60" as="geometry"/>
</mxCell>
<mxCell id="62" value="&lt;font style=&quot;font-size: 12px&quot;&gt;Forward&lt;br&gt;with EMail&lt;br&gt;from user&lt;br&gt;&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.25;entryDx=0;entryDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="58" target="61" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="58" value="Backend" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="640" y="879" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="64" value="&lt;font style=&quot;font-size: 12px&quot;&gt;GDT-Server sends EMail with identifier hash&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.75;exitDx=0;exitDy=0;entryX=0.5;entryY=0.5;entryDx=0;entryDy=0;entryPerimeter=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="61" target="63" edge="1">
<mxGeometry x="-0.0051" y="-11" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="61" value="GDT-Server" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="320" y="1040" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="63" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontSize=21;strokeWidth=2;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="1049" y="1070" width="30" height="60" as="geometry"/>
</mxCell>
<mxCell id="69" value="The user can request an identifier hash via his community's frontend.&lt;br&gt;This will task the community's backend to send a request to the Gradido Akademie's GDT-Server with the EMail of the user.&lt;br&gt;The GDT-Server in turn will send the user his identifier hash via EMail.&lt;br&gt;The user can input this identifier hash into his community's frontend and therefore allows the foreign community to be able to query the GDT data.&lt;br&gt;&lt;br&gt;Attributes of the identifier hash:&lt;br&gt;1. Not guessable: long, random, not just a hash of the EMail&lt;br&gt;2. Unique: Each user can have only one globally unique identifier&lt;br&gt;3. Persistent: The user might want to use the identifier hash on more then one service&lt;br&gt;4. Regeneratable: The user might want to request to replace his existing hash with a new one in case the old got compromised" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;whiteSpace=wrap;rounded=0;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="10" y="840" width="260" height="320" as="geometry"/>
</mxCell>
<mxCell id="70" value="" style="endArrow=none;html=1;fontSize=21;strokeWidth=3;fillColor=none;labelBackgroundColor=#F1FAEE;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint y="1200" as="sourcePoint"/>
<mxPoint x="1090" y="1200" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="71" value="Special case: Gradido Akademie Community" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=21;fontStyle=1;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry y="1210" width="450" height="20" as="geometry"/>
</mxCell>
<mxCell id="72" value="Community Gradido Akademie" style="rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="280" y="1240" width="240" height="480" as="geometry"/>
</mxCell>
<mxCell id="73" value="Backend" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="320" y="1280" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="76" value="&lt;font style=&quot;font-size: 12px&quot;&gt;Write identifier hash&lt;br&gt;directly into Database&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#B09500;fontColor=#1D3557;fillColor=#e3c800;" parent="1" source="74" target="73" edge="1">
<mxGeometry relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="74" value="GDT-Server" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="320" y="1440" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="75" value="Since the Gradido Akademie has access to all data it is not required to pester the user with an EMail confirmation process.&lt;br&gt;&lt;br&gt;Therefore we can define two processes to keep all data up to date:&lt;br&gt;&lt;br&gt;1. A donatation or other change to the GDT account of an EMail happens.&lt;br&gt;2. Continously write all existing data&lt;br&gt;&lt;br&gt;Process 1 is dynamic and esures that once a user donates his identifier hash is directly written into the database as soon as this information reaches us.&lt;br&gt;&lt;br&gt;Process 2 is in case a user registers with our service who already has donated in the past (legacy). This process is not syncronous, meaning it might take a moment till the newly registred user gets access to his old donation data. Furthermore we can use this to syncronize our data between the two services initially and periodically to prevent out-of-sync-data." style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=top;whiteSpace=wrap;rounded=0;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="10" y="1240" width="260" height="480" as="geometry"/>
</mxCell>
<mxCell id="80" value="&lt;font style=&quot;font-size: 12px&quot;&gt;User donates&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;exitX=0.5;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;" parent="1" source="77" target="79" edge="1">
<mxGeometry x="0.0323" y="-20" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="77" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontSize=21;strokeWidth=2;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="1048" y="1450" width="30" height="60" as="geometry"/>
</mxCell>
<mxCell id="81" value="&lt;font style=&quot;font-size: 12px&quot;&gt;webhook&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;entryX=1;entryY=0.5;entryDx=0;entryDy=0;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="79" target="74" edge="1">
<mxGeometry y="-20" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="79" value="&lt;font style=&quot;font-size: 12px&quot;&gt;Donation Service&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fontSize=21;strokeWidth=1;align=center;verticalAlign=middle;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="640" y="1440" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="84" value="&lt;font style=&quot;font-size: 12px&quot;&gt;Hook&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="83" target="74" edge="1">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="83" value="&lt;font style=&quot;font-size: 12px&quot;&gt;Cron&lt;/font&gt;" style="rounded=0;whiteSpace=wrap;html=1;fontSize=21;strokeWidth=1;align=center;verticalAlign=middle;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="320" y="1600" width="160" height="80" as="geometry"/>
</mxCell>
<mxCell id="87" value="&lt;span style=&quot;font-size: 12px&quot;&gt;Request GDT-Data&lt;/span&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="85" target="6" edge="1">
<mxGeometry x="0.0189" y="20" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="85" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontSize=21;strokeWidth=2;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="40" y="90" width="30" height="60" as="geometry"/>
</mxCell>
<mxCell id="89" value="&lt;span style=&quot;font-size: 12px;&quot;&gt;Request GDT-Data&lt;/span&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="88" target="27" edge="1">
<mxGeometry x="0.0189" y="20" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="88" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontSize=21;strokeWidth=2;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="40" y="490" width="30" height="60" as="geometry"/>
</mxCell>
<mxCell id="91" value="&lt;font style=&quot;font-size: 12px&quot;&gt;Request GDT-Data&lt;/font&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="90" target="12" edge="1">
<mxGeometry x="0.0189" y="-20" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="90" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontSize=21;strokeWidth=2;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="1050" y="90" width="30" height="60" as="geometry"/>
</mxCell>
<mxCell id="93" value="&lt;span style=&quot;font-size: 12px&quot;&gt;Request GDT-Data&lt;/span&gt;" style="edgeStyle=orthogonalEdgeStyle;rounded=1;jumpSize=6;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=0.5;exitDx=0;exitDy=0;exitPerimeter=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontSize=21;startArrow=none;startFill=0;endArrow=classic;endFill=1;startSize=6;strokeWidth=3;labelBackgroundColor=none;strokeColor=#2D7600;fontColor=#1D3557;fillColor=#60a917;" parent="1" source="92" target="31" edge="1">
<mxGeometry x="0.0189" y="-20" relative="1" as="geometry">
<mxPoint as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="92" value="User" style="shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;html=1;outlineConnect=0;fontSize=21;strokeWidth=2;fillColor=#A8DADC;strokeColor=#457B9D;fontColor=#1D3557;" parent="1" vertex="1">
<mxGeometry x="1050" y="490" width="30" height="60" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -1,5 +1,3 @@
LOGIN_API_URL=http://localhost/login_api/
COMMUNITY_API_URL=http://localhost/api/
ALLOW_REGISTER=true
GRAPHQL_URI=http://localhost:4000/graphql
//BUILD_COMMIT=0000000

View File

@ -14,9 +14,8 @@ export default {
return {
selected: null,
options: [
{ value: null, text: this.$t('settings.language.select_language') },
{ value: 'de', text: this.$t('settings.language.de') },
{ value: 'en', text: this.$t('settings.language.en') },
{ value: 'de', text: this.$t('languages.de') },
{ value: 'en', text: this.$t('languages.en') },
],
}
},

View File

@ -18,8 +18,6 @@ const environment = {
}
const server = {
LOGIN_API_URL: process.env.LOGIN_API_URL || 'http://localhost/login_api/',
COMMUNITY_API_URL: process.env.COMMUNITY_API_URL || 'http://localhost/api/',
GRAPHQL_URI: process.env.GRAPHQL_URI || 'http://localhost:4000/graphql',
}

View File

@ -32,8 +32,8 @@ export const loginViaEmailVerificationCode = gql`
`
export const transactionsQuery = gql`
query($firstPage: Int = 1, $items: Int = 25, $order: String = "DESC") {
transactionList(firstPage: $firstPage, items: $items, order: $order) {
query($currentPage: Int = 1, $pageSize: Int = 25, $order: String = "DESC") {
transactionList(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
gdtSum
count
balance

View File

@ -114,14 +114,14 @@ export const loadAllRules = (i18nCallback) => {
extend('atLeastOneSpecialCharater', {
validate(value) {
return !!value.match(/[^a-zA-Z0-9]/)
return !!value.match(/[^a-zA-Z0-9 \t\n\r]/)
},
message: (_, values) => i18nCallback.t('site.signup.special-char', values),
})
extend('noWhitespaceCharacters', {
validate(value) {
return !!value.match(/[^ \t\n\r]/)
return !value.match(/[ \t\n\r]+/)
},
message: (_, values) => i18nCallback.t('site.signup.no-whitespace', values),
})

View File

@ -32,6 +32,9 @@ describe('DashboardLayoutGdd', () => {
},
$router: {
push: routerPushMock,
currentRoute: {
path: '/overview',
},
},
$toasted: {
error: toasterMock,
@ -143,21 +146,23 @@ describe('DashboardLayoutGdd', () => {
it('redirects to login page', () => {
expect(routerPushMock).toBeCalledWith('/login')
})
})
describe('logout fails', () => {
beforeEach(() => {
apolloMock.mockRejectedValue({
message: 'error',
})
describe('logout fails', () => {
beforeEach(async () => {
apolloMock.mockRejectedValue({
message: 'error',
})
await wrapper.findComponent({ name: 'sidebar' }).vm.$emit('logout')
await flushPromises()
})
it('dispatches logout to store', () => {
expect(storeDispatchMock).toBeCalledWith('logout')
})
it('dispatches logout to store', () => {
expect(storeDispatchMock).toBeCalledWith('logout')
})
it('redirects to login page', () => {
expect(routerPushMock).toBeCalledWith('/login')
})
it('redirects to login page', () => {
expect(routerPushMock).toBeCalledWith('/login')
})
})
@ -184,7 +189,7 @@ describe('DashboardLayoutGdd', () => {
})
await wrapper
.findComponent({ ref: 'router-view' })
.vm.$emit('update-transactions', { firstPage: 2, items: 5 })
.vm.$emit('update-transactions', { currentPage: 2, pageSize: 5 })
await flushPromises()
})
@ -192,8 +197,8 @@ describe('DashboardLayoutGdd', () => {
expect(apolloMock).toBeCalledWith(
expect.objectContaining({
variables: {
firstPage: 2,
items: 5,
currentPage: 2,
pageSize: 5,
},
}),
)
@ -228,7 +233,7 @@ describe('DashboardLayoutGdd', () => {
})
await wrapper
.findComponent({ ref: 'router-view' })
.vm.$emit('update-transactions', { firstPage: 2, items: 5 })
.vm.$emit('update-transactions', { currentPage: 2, pageSize: 5 })
await flushPromises()
})

View File

@ -101,7 +101,7 @@ export default {
.catch(() => {
this.$sidebar.displaySidebar(false)
this.$store.dispatch('logout')
this.$router.push('/login')
if (this.$router.currentRoute.path !== '/login') this.$router.push('/login')
})
},
async updateTransactions(pagination) {
@ -110,8 +110,8 @@ export default {
.query({
query: transactionsQuery,
variables: {
firstPage: pagination.firstPage,
items: pagination.items,
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
},
fetchPolicy: 'network-only',
})

View File

@ -327,7 +327,9 @@ describe('GddTransactionList', () => {
it('emits update-transactions when next button is clicked', async () => {
await paginationButtons.find('button.next-page').trigger('click')
expect(wrapper.emitted('update-transactions')[1]).toEqual([{ firstPage: 2, items: 25 }])
expect(wrapper.emitted('update-transactions')[1]).toEqual([
{ currentPage: 2, pageSize: 25 },
])
})
it('shows text "2 / 2" when next button is clicked', async () => {
@ -348,7 +350,9 @@ describe('GddTransactionList', () => {
it('emits update-transactions when preivous button is clicked after next buton', async () => {
await paginationButtons.find('button.next-page').trigger('click')
await paginationButtons.find('button.previous-page').trigger('click')
expect(wrapper.emitted('update-transactions')[2]).toEqual([{ firstPage: 1, items: 25 }])
expect(wrapper.emitted('update-transactions')[2]).toEqual([
{ currentPage: 1, pageSize: 25 },
])
expect(scrollToMock).toBeCalled()
})
})

View File

@ -134,8 +134,8 @@ export default {
methods: {
updateTransactions() {
this.$emit('update-transactions', {
firstPage: this.currentPage,
items: this.pageSize,
currentPage: this.currentPage,
pageSize: this.pageSize,
})
window.scrollTo(0, 0)
},

View File

@ -25,7 +25,7 @@ describe('Register', () => {
$store: {
state: {
email: 'peter@lustig.de',
language: null,
language: 'en',
},
},
}
@ -55,11 +55,11 @@ describe('Register', () => {
describe('links', () => {
it('has a link "Back"', () => {
expect(wrapper.findAllComponents(RouterLinkStub).at(0).text()).toEqual('back')
expect(wrapper.find('.test-button-back').text()).toEqual('back')
})
it('links to /login when clicking "Back"', () => {
expect(wrapper.findAllComponents(RouterLinkStub).at(0).props().to).toBe('/login')
expect(wrapper.find('.test-button-back').props().to).toBe('/login')
})
})
@ -89,17 +89,17 @@ describe('Register', () => {
it('has Language selected field', () => {
expect(wrapper.find('.selectedLanguage').exists()).toBeTruthy()
})
it('selected Language value de', async () => {
it('selects Language value en', async () => {
wrapper.find('.selectedLanguage').findAll('option').at(1).setSelected()
expect(wrapper.find('.selectedLanguage').element.value).toBe('de')
expect(wrapper.find('.selectedLanguage').element.value).toBe('en')
})
it('has 1 checkbox input fields', () => {
expect(wrapper.find('#registerCheckbox').exists()).toBeTruthy()
})
it('has no submit button when not completely filled', () => {
expect(wrapper.find('button[type="submit"]').exists()).toBe(false)
it('has disabled submit button when not completely filled', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
})
it('displays a message that Email is required', async () => {
@ -127,60 +127,6 @@ describe('Register', () => {
})
})
describe('resetForm', () => {
beforeEach(() => {
wrapper.find('#registerFirstname').setValue('Max')
wrapper.find('#registerLastname').setValue('Mustermann')
wrapper.find('#Email-input-field').setValue('max.mustermann@gradido.net')
wrapper.find('input[name="form.password"]').setValue('Aa123456_')
wrapper.find('input[name="form.passwordRepeat"]').setValue('Aa123456_')
wrapper.find('.language-switch-select').findAll('option').at(1).setSelected()
wrapper.find('input[name="site.signup.agree"]').setChecked(true)
})
it('reset selected value language', async () => {
await wrapper.find('button.ml-2').trigger('click')
await flushPromises()
expect(wrapper.find('.language-switch-select').element.value).toBe(undefined)
})
it('resets the firstName field after clicking the reset button', async () => {
await wrapper.find('button.ml-2').trigger('click')
await flushPromises()
expect(wrapper.find('#registerFirstname').element.value).toBe('')
})
it('resets the lastName field after clicking the reset button', async () => {
await wrapper.find('button.ml-2').trigger('click')
await flushPromises()
expect(wrapper.find('#registerLastname').element.value).toBe('')
})
it('resets the email field after clicking the reset button', async () => {
await wrapper.find('button.ml-2').trigger('click')
await flushPromises()
expect(wrapper.find('#Email-input-field').element.value).toBe('')
})
it.skip('resets the password field after clicking the reset button', async () => {
await wrapper.find('button.ml-2').trigger('click')
await flushPromises()
expect(wrapper.find('input[name="form.password"]').element.value).toBe('')
})
it.skip('resets the passwordRepeat field after clicking the reset button', async () => {
await wrapper.find('button.ml-2').trigger('click')
await flushPromises()
expect(wrapper.find('input[name="form.passwordRepeat"]').element.value).toBe('')
})
it('resets the firstName field after clicking the reset button', async () => {
await wrapper.find('button.ml-2').trigger('click')
await flushPromises()
expect(wrapper.find('input[name="site.signup.agree"]').props.checked).not.toBeTruthy()
})
})
describe('API calls', () => {
beforeEach(() => {
wrapper.find('#registerFirstname').setValue('Max')
@ -191,6 +137,10 @@ describe('Register', () => {
wrapper.find('.language-switch-select').findAll('option').at(1).setSelected()
})
it('has enabled submit button when completely filled', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
})
describe('server sends back error', () => {
beforeEach(async () => {
registerUserMutationMock.mockRejectedValue({ message: 'Ouch!' })
@ -235,7 +185,7 @@ describe('Register', () => {
firstName: 'Max',
lastName: 'Mustermann',
password: 'Aa123456_',
language: 'de',
language: 'en',
},
}),
)

View File

@ -116,13 +116,19 @@
</span>
</b-alert>
<div
class="text-center"
v-if="namesFilled && emailFilled && form.agree && languageFilled"
>
<div class="text-center">
<div class="text-center">
<b-button class="ml-2" @click="resetForm()">{{ $t('form.reset') }}</b-button>
<b-button type="submit" variant="primary">{{ $t('signup') }}</b-button>
<b-button class="ml-2 test-button-back" to="/login">
{{ $t('back') }}
</b-button>
<b-button
:disabled="!(namesFilled && emailFilled && form.agree && languageFilled)"
type="submit"
variant="primary"
>
{{ $t('signup') }}
</b-button>
</div>
</div>
</b-form>
@ -131,9 +137,6 @@
</b-card>
</b-col>
</b-row>
<div class="text-center py-lg-4">
<router-link to="/login" class="mt-3">{{ $t('back') }}</router-link>
</div>
</b-container>
</div>
</template>
@ -172,22 +175,6 @@ export default {
getValidationState({ dirty, validated, valid = null }) {
return dirty || validated ? valid : null
},
resetForm() {
this.form = {
firstname: '',
lastname: '',
email: '',
password: {
password: '',
passwordRepeat: '',
},
agree: false,
}
this.language = ''
this.$nextTick(() => {
this.$refs.observer.reset()
})
},
async onSubmit() {
this.$apollo
.mutate({
@ -238,7 +225,7 @@ export default {
return this.form.email !== ''
},
languageFilled() {
return this.language !== null && this.language !== ''
return !!this.language
},
},
}

View File

@ -105,13 +105,20 @@ describe('UserCard_FormUserPasswort', () => {
describe('validation', () => {
it('displays all password requirements', () => {
const feedbackArray = wrapper.findAll('div.invalid-feedback').at(1).findAll('span')
expect(feedbackArray).toHaveLength(7)
expect(feedbackArray).toHaveLength(6)
expect(feedbackArray.at(0).text()).toBe('validations.messages.required')
expect(feedbackArray.at(1).text()).toBe('site.signup.lowercase')
expect(feedbackArray.at(2).text()).toBe('site.signup.uppercase')
expect(feedbackArray.at(3).text()).toBe('site.signup.one_number')
expect(feedbackArray.at(4).text()).toBe('site.signup.minimum')
expect(feedbackArray.at(5).text()).toBe('site.signup.special-char')
})
it('displays no whitespace error when a space character is entered', async () => {
await wrapper.findAll('input').at(1).setValue(' ')
await flushPromises()
const feedbackArray = wrapper.findAll('div.invalid-feedback').at(1).findAll('span')
expect(feedbackArray).toHaveLength(7)
expect(feedbackArray.at(6).text()).toBe('site.signup.no-whitespace')
})

View File

@ -3,7 +3,13 @@ import UserCardLanguage from './UserCard_Language'
const localVue = global.localVue
const mockAPIcall = jest.fn()
const mockAPIcall = jest.fn().mockResolvedValue({
data: {
updateUserInfos: {
validValues: 1,
},
},
})
const toastErrorMock = jest.fn()
const toastSuccessMock = jest.fn()
@ -17,6 +23,7 @@ describe('UserCard_Language', () => {
$store: {
state: {
language: 'de',
email: 'peter@lustig.de',
},
commit: storeCommitMock,
},
@ -27,6 +34,9 @@ describe('UserCard_Language', () => {
$apollo: {
mutate: mockAPIcall,
},
$i18n: {
locale: 'de',
},
}
const Wrapper = () => {
@ -45,5 +55,119 @@ describe('UserCard_Language', () => {
it('has an edit icon', () => {
expect(wrapper.find('svg.bi-pencil').exists()).toBeTruthy()
})
it('has change language as text', () => {
expect(wrapper.find('a').text()).toBe('settings.language.changeLanguage')
})
it('has no select field by default', () => {
expect(wrapper.find('select').exists()).toBeFalsy()
})
describe('edit button', () => {
beforeEach(() => {
wrapper.find('a').trigger('click')
})
it('has no edit icon anymore', () => {
expect(wrapper.find('svg.bi-pencil').exists()).toBeFalsy()
})
it('has x-circle icon', () => {
expect(wrapper.find('svg.bi-x-circle').exists()).toBeTruthy()
})
it('has a submit button', () => {
expect(wrapper.find('button[type="submit"]').exists()).toBeTruthy()
})
it('has the submit button disbaled by default', () => {
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
})
describe('change language', () => {
it('does not enable the submit button when same language is chosen', () => {
wrapper.findAll('option').at(0).setSelected()
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled')
})
it('enables the submit button when other language is chosen', async () => {
await wrapper.findAll('option').at(1).setSelected()
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe(undefined)
})
it('updates language data in component', async () => {
await wrapper.findAll('option').at(1).setSelected()
expect(wrapper.vm.language).toBe('en')
})
describe('cancel edit', () => {
beforeEach(async () => {
await wrapper.findAll('option').at(1).setSelected()
wrapper.find('a').trigger('click')
})
it('sets the language to initial value', () => {
expect(wrapper.vm.language).toBe('de')
})
it('has no select field anymore', () => {
expect(wrapper.find('select').exists()).toBeFalsy()
})
})
describe('submit', () => {
beforeEach(async () => {
await wrapper.findAll('option').at(1).setSelected()
wrapper.find('form').trigger('submit')
})
describe('with success', () => {
it('calls the API', () => {
expect(mockAPIcall).toBeCalledWith(
expect.objectContaining({
variables: {
email: 'peter@lustig.de',
locale: 'en',
},
}),
)
})
it('commits new language to store', () => {
expect(storeCommitMock).toBeCalledWith('language', 'en')
})
it('changes the i18n locale', () => {
expect(mocks.$i18n.locale).toBe('en')
})
it('has no select field anymore', () => {
expect(wrapper.find('select').exists()).toBeFalsy()
})
it('toasts a success message', () => {
expect(toastSuccessMock).toBeCalledWith('settings.language.success')
})
})
describe('with error', () => {
beforeEach(() => {
mockAPIcall.mockRejectedValue({
message: 'Ouch!',
})
})
it('sets the language back to initial value', () => {
expect(wrapper.vm.language).toBe('de')
})
it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('Ouch!')
})
})
})
})
})
})
})

View File

@ -82,6 +82,7 @@ export default {
},
cancelEdit() {
this.showLanguage = true
this.language = this.$store.state.language
},
async onSubmit() {
this.$apollo
@ -104,7 +105,6 @@ export default {
this.$toasted.error(error.message)
})
},
buildTagFromLanguageString() {
return 'languages.' + this.$store.state.language
},

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'
import UserCardNewsletter from './UserCard_Newsletter'
import { unsubscribeNewsletter } from '../../../graphql/mutations'
import { unsubscribeNewsletter, subscribeNewsletter } from '../../../graphql/mutations'
const localVue = global.localVue
@ -9,7 +9,6 @@ const mockAPIcall = jest.fn()
const toastErrorMock = jest.fn()
const toastSuccessMock = jest.fn()
const storeCommitMock = jest.fn()
const newsletterStateMock = jest.fn().mockReturnValue(true)
describe('UserCard_Newsletter', () => {
let wrapper
@ -20,7 +19,7 @@ describe('UserCard_Newsletter', () => {
state: {
language: 'de',
email: 'peter@lustig.de',
newsletterState: newsletterStateMock,
newsletterState: true,
},
commit: storeCommitMock,
},
@ -50,14 +49,15 @@ describe('UserCard_Newsletter', () => {
expect(wrapper.find('.Test-BFormCheckbox').exists()).toBeTruthy()
})
describe('unsubscribe with sucess', () => {
beforeEach(() => {
describe('unsubscribe with success', () => {
beforeEach(async () => {
await wrapper.setData({ newsletterState: false })
mockAPIcall.mockResolvedValue({
data: {
unsubscribeNewsletter: true,
},
})
wrapper.find('input').trigger('change')
await wrapper.find('input').trigger('change')
})
it('calls the unsubscribe mutation', () => {
@ -78,6 +78,36 @@ describe('UserCard_Newsletter', () => {
})
})
describe('subscribe with success', () => {
beforeEach(async () => {
await wrapper.setData({ newsletterState: true })
mockAPIcall.mockResolvedValue({
data: {
subscribeNewsletter: true,
},
})
wrapper.find('input').trigger('change')
})
it('calls the subscribe mutation', () => {
expect(mockAPIcall).toBeCalledWith({
mutation: subscribeNewsletter,
variables: {
email: 'peter@lustig.de',
language: 'de',
},
})
})
it('updates the store', () => {
expect(storeCommitMock).toBeCalledWith('newsletterState', true)
})
it('toasts a success message', () => {
expect(toastSuccessMock).toBeCalledWith('settings.newsletter.newsletterFalse')
})
})
describe('unsubscribe with server error', () => {
beforeEach(() => {
mockAPIcall.mockRejectedValue({

View File

@ -36,16 +36,16 @@ describe('UserProfileTransactionList', () => {
it('emits update-transactions after creation', () => {
expect(wrapper.emitted('update-transactions')).toEqual(
expect.arrayContaining([expect.arrayContaining([{ firstPage: 1, items: 25 }])]),
expect.arrayContaining([expect.arrayContaining([{ currentPage: 1, pageSize: 25 }])]),
)
})
it('emist update-transactions when update-transactions is called', () => {
wrapper
.findComponent({ name: 'GddTransactionList' })
.vm.$emit('update-transactions', { firstPage: 2, items: 25 })
.vm.$emit('update-transactions', { currentPage: 2, pageSize: 25 })
expect(wrapper.emitted('update-transactions')).toEqual(
expect.arrayContaining([expect.arrayContaining([{ firstPage: 2, items: 25 }])]),
expect.arrayContaining([expect.arrayContaining([{ currentPage: 2, pageSize: 25 }])]),
)
})